diff --git a/CHANGELOG.md b/CHANGELOG.md index 092333d..7126de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## Changelog +### v0.51.0 — offsite-backup UI (felhom-pbs DR) + Model-A double-nest fix (2026-06-12) + +Pairs with felhom-agent v0.28.0 (whole-guest backup re-targeted to the offsite PBS tier). + +**Backups page — the whole-guest backup is now shown as real DR (separate hardware).** The +"Rendszermentés" section's target label calls out the offsite tier: `backupTargetLabel` returns +**"Biztonsági szerver – külön hardver (PBS)"** for a PBS-stored backup (detected via `backupIsPBS` +on the target id / archive volid), so the customer sees the backup survives a host hardware failure. +The app-data section's **"Távoli mentés"** card stops reading "nincs beállítva": a new +`guestBackupView.Offsite` flag drives it to **"külön hardveren (PBS)"** with a ✓ when the whole-guest +backup landed on PBS. The restore-test "Visszaállítás ellenőrizve" trust signal is unchanged. + +**Model-A double-nest fix — drive-resident app backups land single-nested.** Under slice-10 Model A the +host agent binds `/felhom-data` onto the guest mountpoint, so an enrolled drive's in-guest mount +IS the felhom-data namespace root (basename need not be `felhom-data`, e.g. `/mnt/felhom-usb`). The +backup path helpers were re-prepending `felhom-data`, producing `.../felhom-data/felhom-data/...` on the +host. `appbackup` path helpers now take a NAMESPACE ROOT (no internal `felhom-data` join) plus a new +`NamespaceRoot(drivePath, inGuestDrive)`; `backup.Manager.namespaceRoot`/`AppNamespaceRoot` resolve +provenance (a drive-resident app's mount is the root as-is; only the SSD-only `systemDataPath` fallback +appends `felhom-data`). All parallel constructions updated coherently so writes, deletion +(`GetStackBackupData`, `RemoveStack` backups-base + `ProtectedHDDPaths` — legacy double-nest dirs kept +protected), the wipe-warning secondary scan, and export all agree. `api.router` passes the namespace +root across the package boundary. New `appbackup` test asserts no doubled `felhom-data` segment for an +in-guest drive and exactly one for the system fallback. + ### v0.50.0 — slice 10 P4: dual-role drives + backup-aware wipe warning (2026-06-12) Pairs with felhom-agent P3 (self-heal). Establishes the dual-role MODEL + the backup-aware wipe diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 5d4728c..a033ab2 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -561,13 +561,15 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name return } - // Compute the drive path for this stack (HDD or system data path) - var drivePath string + // Compute the felhom-data namespace root for this stack (drive-resident apps: the in-guest + // mount IS the namespace; SSD-only: /felhom-data). Passing the namespace root + // (not the raw drive) keeps GetStackBackupData's paths single-nested under Model A. + var nsRoot string if r.backupMgr != nil { - drivePath = r.backupMgr.GetAppDrivePath(name) + nsRoot = r.backupMgr.AppNamespaceRoot(name) } - resp, err := r.stackMgr.GetStackBackupData(name, drivePath) + resp, err := r.stackMgr.GetStackBackupData(name, nsRoot) if err != nil { writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) return @@ -598,9 +600,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri // backup has moved to the host agent; only the app-data DB-dump path is removed here. var backupPaths []string if body.RemoveBackups && r.backupMgr != nil { - drivePath := r.backupMgr.GetAppDrivePath(name) - if drivePath != "" { - backupPaths = append(backupPaths, backup.AppDBDumpPath(drivePath, name)) + nsRoot := r.backupMgr.AppNamespaceRoot(name) + if nsRoot != "" { + backupPaths = append(backupPaths, backup.AppDBDumpPath(nsRoot, name)) } } diff --git a/controller/internal/appbackup/paths.go b/controller/internal/appbackup/paths.go index a156fc3..cda8a34 100644 --- a/controller/internal/appbackup/paths.go +++ b/controller/internal/appbackup/paths.go @@ -10,22 +10,40 @@ import "path/filepath" // FelhomDataDir is the namespace directory on storage drives for all felhom-managed data. const FelhomDataDir = "felhom-data" -// PrimaryBackupPath returns the root primary backup directory for a drive. -func PrimaryBackupPath(drivePath string) string { - return filepath.Join(drivePath, FelhomDataDir, "backups", "primary") +// NamespaceRoot resolves the felhom-data namespace ROOT for a drive path. All the path helpers +// below take this namespace root (the directory that directly contains backups/ and appdata/), +// NOT a bare drive path — they do not append felhom-data themselves. +// +// Model A (slice 10): the host agent binds /felhom-data onto the guest mountpoint, so an +// enrolled user-data drive's IN-GUEST mount already IS the namespace root (and its basename need +// NOT be "felhom-data" — e.g. /mnt/felhom-usb). For such mounts pass inGuestDrive=true → the path +// is returned as-is, so callers no longer double-nest into .../felhom-data/felhom-data/... . +// +// For a bare drive root that still holds a felhom-data SUBDIR — the SSD-only system-data fallback, +// or any legacy host-side layout — pass inGuestDrive=false → the felhom-data segment is appended. +func NamespaceRoot(drivePath string, inGuestDrive bool) string { + if inGuestDrive { + return filepath.Clean(drivePath) + } + return filepath.Join(drivePath, FelhomDataDir) } -// AppDBDumpPath returns the DB dump directory for an app on its home drive. -func AppDBDumpPath(drivePath, stackName string) string { - return filepath.Join(drivePath, FelhomDataDir, "backups", "primary", stackName, "db-dumps") +// PrimaryBackupPath returns the root primary backup directory under a felhom-data namespace root. +func PrimaryBackupPath(nsRoot string) string { + return filepath.Join(nsRoot, "backups", "primary") } -// AppVolumeDumpPath returns the directory for Docker volume dump tars on an app's home drive. -func AppVolumeDumpPath(drivePath, stackName string) string { - return filepath.Join(drivePath, FelhomDataDir, "backups", "primary", stackName, "volume-dumps") +// AppDBDumpPath returns the DB dump directory for an app under a felhom-data namespace root. +func AppDBDumpPath(nsRoot, stackName string) string { + return filepath.Join(nsRoot, "backups", "primary", stackName, "db-dumps") } -// AppDataDir returns the app data directory path on a drive. -func AppDataDir(drivePath, stackName string) string { - return filepath.Join(drivePath, FelhomDataDir, "appdata", stackName) +// AppVolumeDumpPath returns the Docker-volume dump-tar directory for an app under a namespace root. +func AppVolumeDumpPath(nsRoot, stackName string) string { + return filepath.Join(nsRoot, "backups", "primary", stackName, "volume-dumps") +} + +// AppDataDir returns the app data directory under a felhom-data namespace root. +func AppDataDir(nsRoot, stackName string) string { + return filepath.Join(nsRoot, "appdata", stackName) } diff --git a/controller/internal/appbackup/paths_test.go b/controller/internal/appbackup/paths_test.go new file mode 100644 index 0000000..d36a5dc --- /dev/null +++ b/controller/internal/appbackup/paths_test.go @@ -0,0 +1,64 @@ +package appbackup + +import ( + "path/filepath" + "strings" + "testing" +) + +// slash normalizes OS path separators so the assertions hold on both Linux (production) and Windows +// (dev). The double-nest invariant is about path segments, not the separator byte. +func slash(p string) string { return filepath.ToSlash(p) } + +// TestNoDoubleFelhomDataForInGuestDrive asserts that for an enrolled in-guest user-data drive +// (Model A, slice 10 — the in-guest mount IS the felhom-data namespace root), none of the backup +// path helpers re-inject a felhom-data segment, so the on-host path is single-nested rather than +// the .../felhom-data/felhom-data/... double-nest that this fix removes. +func TestNoDoubleFelhomDataForInGuestDrive(t *testing.T) { + // An enrolled drive's in-guest mount — note the basename is NOT "felhom-data". + drive := filepath.FromSlash("/mnt/felhom-usb") + ns := NamespaceRoot(drive, true) + if ns != drive { + t.Fatalf("in-guest namespace root should be the drive mount as-is: got %q want %q", ns, drive) + } + + paths := map[string]string{ + "PrimaryBackupPath": PrimaryBackupPath(ns), + "AppDBDumpPath": AppDBDumpPath(ns, "nextcloud"), + "AppVolumeDumpPath": AppVolumeDumpPath(ns, "nextcloud"), + "AppDataDir": AppDataDir(ns, "nextcloud"), + } + for name, p := range paths { + sp := slash(p) + if strings.Contains(sp, FelhomDataDir+"/"+FelhomDataDir) { + t.Errorf("%s double-nests felhom-data: %q", name, sp) + } + if n := strings.Count(sp, FelhomDataDir); n > 1 { + t.Errorf("%s has %d felhom-data segments (want <=1): %q", name, n, sp) + } + if !strings.HasPrefix(sp, "/mnt/felhom-usb/") { + t.Errorf("%s not rooted at the drive mount: %q", name, sp) + } + } + + // Concrete expected single-nested DB-dump path for a drive-resident app. + if got, want := slash(AppDBDumpPath(ns, "nextcloud")), "/mnt/felhom-usb/backups/primary/nextcloud/db-dumps"; got != want { + t.Errorf("drive-resident DB-dump path: got %q want %q", got, want) + } +} + +// TestSystemFallbackNestsOnceUnderFelhomData asserts the SSD-only system-data fallback still nests +// under exactly one felhom-data segment (the bare drive root does NOT already contain the namespace). +func TestSystemFallbackNestsOnceUnderFelhomData(t *testing.T) { + ns := NamespaceRoot(filepath.FromSlash("/mnt/sys_drive"), false) + if want := filepath.Join(filepath.FromSlash("/mnt/sys_drive"), FelhomDataDir); ns != want { + t.Fatalf("system fallback namespace root: got %q want %q", ns, want) + } + got := slash(AppDBDumpPath(ns, "nextcloud")) + if want := "/mnt/sys_drive/felhom-data/backups/primary/nextcloud/db-dumps"; got != want { + t.Errorf("system fallback DB-dump path: got %q want %q", got, want) + } + if n := strings.Count(got, FelhomDataDir); n != 1 { + t.Errorf("system fallback should have exactly one felhom-data segment, got %d: %q", n, got) + } +} diff --git a/controller/internal/appexport/estimate.go b/controller/internal/appexport/estimate.go index a2128cc..c6522d5 100644 --- a/controller/internal/appexport/estimate.go +++ b/controller/internal/appexport/estimate.go @@ -152,9 +152,11 @@ func diskFree(path string) int64 { return size } -// ExportDir returns the exports directory on a drive. +// ExportDir returns the exports directory on a drive. Model A (slice 10): a registered drive's +// in-guest mount IS the felhom-data namespace root, so exports/ sits directly under it (no +// felhom-data segment — avoids the .../felhom-data/felhom-data/... double-nest). func ExportDir(drivePath string) string { - return filepath.Join(drivePath, "felhom-data", "exports") + return filepath.Join(drivePath, "exports") } // humanizeBytes converts bytes to human-readable format. diff --git a/controller/internal/backup/appbackup_bridge.go b/controller/internal/backup/appbackup_bridge.go index 6aaf796..48281ba 100644 --- a/controller/internal/backup/appbackup_bridge.go +++ b/controller/internal/backup/appbackup_bridge.go @@ -87,19 +87,26 @@ func humanizeBytes(b int64) string { } // --- function forwarders (paths) --- +// +// NOTE: the path helpers below take a felhom-data NAMESPACE ROOT, not a bare drive path. Use +// NamespaceRoot (or Manager.namespaceRoot / Manager.AppNamespaceRoot) to resolve the root first. -func PrimaryBackupPath(drivePath string) string { - return appbackup.PrimaryBackupPath(drivePath) +func NamespaceRoot(drivePath string, inGuestDrive bool) string { + return appbackup.NamespaceRoot(drivePath, inGuestDrive) } -func AppDBDumpPath(drivePath, stackName string) string { - return appbackup.AppDBDumpPath(drivePath, stackName) +func PrimaryBackupPath(nsRoot string) string { + return appbackup.PrimaryBackupPath(nsRoot) } -func AppVolumeDumpPath(drivePath, stackName string) string { - return appbackup.AppVolumeDumpPath(drivePath, stackName) +func AppDBDumpPath(nsRoot, stackName string) string { + return appbackup.AppDBDumpPath(nsRoot, stackName) } -func AppDataDir(drivePath, stackName string) string { - return appbackup.AppDataDir(drivePath, stackName) +func AppVolumeDumpPath(nsRoot, stackName string) string { + return appbackup.AppVolumeDumpPath(nsRoot, stackName) +} + +func AppDataDir(nsRoot, stackName string) string { + return appbackup.AppDataDir(nsRoot, stackName) } diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 86bb61a..5e76c25 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -93,6 +93,26 @@ func (m *Manager) GetAppDrivePath(stackName string) string { return m.systemDataPath } +// namespaceRoot maps an app's drive path to its felhom-data namespace ROOT (the dir that directly +// holds backups/ and appdata/). A drive-resident app's in-guest mount IS the namespace already +// (Model A, slice 10 — the agent binds /felhom-data onto the guest mountpoint), so it is used +// as-is; only the SSD-only system-data fallback gets the felhom-data subdir appended. This is what +// keeps a drive-resident app's backups single-nested instead of .../felhom-data/felhom-data/... . +func (m *Manager) namespaceRoot(drivePath string) string { + return NamespaceRoot(drivePath, drivePath != m.systemDataPath) +} + +// AppNamespaceRoot returns the felhom-data namespace root for a stack's keep-side backups, resolving +// HDD-vs-system provenance internally. For callers outside this package that only know the stack +// name (e.g. the API router) so they don't double-nest the felhom-data segment. +func (m *Manager) AppNamespaceRoot(stackName string) string { + drivePath := m.GetAppDrivePath(stackName) + if drivePath == "" { + return "" + } + return m.namespaceRoot(drivePath) +} + // groupStacksByDrive groups deployed stacks by their home drive path. func (m *Manager) groupStacksByDrive() map[string][]StackSummary { if m.stackProvider == nil { @@ -170,7 +190,7 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error { continue } - dumpDir := AppDBDumpPath(drivePath, db.StackName) + dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName) result := DumpOne(ctx, db, dumpDir, m.logger, m.isDebug()) results = append(results, result) @@ -239,7 +259,7 @@ func (m *Manager) DumpAppVolumes(stackName string) error { return fmt.Errorf("cannot determine drive path for %s", stackName) } - dumpDir := AppVolumeDumpPath(drivePath, stackName) + dumpDir := AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName) if err := os.MkdirAll(dumpDir, 0755); err != nil { return fmt.Errorf("creating volume dump dir: %w", err) } @@ -395,7 +415,7 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error { if drivePath == "" || !filepath.IsAbs(drivePath) { return fmt.Errorf("cannot determine absolute drive path for %s (systemDataPath not configured?)", stackName) } - dumpDir := AppDBDumpPath(drivePath, stackName) + dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), stackName) m.logger.Printf("[INFO] [backup] Running pre-backup DB dump for %s (%d database(s)) → %s", stackName, len(stackDBs), dumpDir) @@ -428,7 +448,7 @@ func (m *Manager) listAllDumpFiles() []DumpFileInfo { var allFiles []DumpFileInfo for drive, stacks := range m.groupStacksByDrive() { for _, stack := range stacks { - dumpDir := AppDBDumpPath(drive, stack.Name) + dumpDir := AppDBDumpPath(m.namespaceRoot(drive), stack.Name) if files, err := ListDumpFiles(dumpDir); err == nil { allFiles = append(allFiles, files...) } diff --git a/controller/internal/backup/restore.go b/controller/internal/backup/restore.go index 7d1c5cc..28ad564 100644 --- a/controller/internal/backup/restore.go +++ b/controller/internal/backup/restore.go @@ -83,7 +83,7 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error { // restoreDockerVolumes populates Docker volumes from tar files in the volume dump directory. func (m *Manager) restoreDockerVolumes(stackName, drivePath string) error { - dumpDir := AppVolumeDumpPath(drivePath, stackName) + dumpDir := AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName) entries, err := os.ReadDir(dumpDir) if err != nil { if os.IsNotExist(err) { diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index ce70adb..192fa16 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -60,12 +60,18 @@ func ProtectedHDDPaths(hddPath string) map[string]bool { return nil } return map[string]bool{ - hddPath: true, - filepath.Join(hddPath, felhomDataDir): true, - filepath.Join(hddPath, felhomDataDir, "appdata"): true, - filepath.Join(hddPath, felhomDataDir, "backups"): true, - filepath.Join(hddPath, "media"): true, - filepath.Join(hddPath, "Dokumentumok"): true, + // Model A: the in-guest drive mount IS the felhom-data namespace root, so backups/ and + // appdata/ sit directly under it (no felhom-data segment). + hddPath: true, + filepath.Join(hddPath, "appdata"): true, + filepath.Join(hddPath, "backups"): true, + filepath.Join(hddPath, "media"): true, + filepath.Join(hddPath, "Dokumentumok"): true, + // Legacy pre-Model-A double-nest location; kept protected so any leftover data there is + // never wiped by a removal. + filepath.Join(hddPath, felhomDataDir): true, + filepath.Join(hddPath, felhomDataDir, "appdata"): true, + filepath.Join(hddPath, felhomDataDir, "backups"): true, } } @@ -383,8 +389,9 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo } } - // Step 5: Handle backup data cleanup - backupsBase := filepath.Join(hddPath, felhomDataDir, "backups") + // Step 5: Handle backup data cleanup. Model A: backups/ sits directly under the namespace-root + // drive mount (no felhom-data segment). + backupsBase := filepath.Join(hddPath, "backups") if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: processing %d backup paths for removal (base=%s)", name, len(backupPathsToRemove), backupsBase) } @@ -453,12 +460,13 @@ func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupData return resp, nil } - // Check DB dump directory: /felhom-data/backups/primary//db-dumps - dbDumpPath := filepath.Join(drivePath, felhomDataDir, "backups", "primary", name, "db-dumps") + // Check DB dump directory. drivePath is the felhom-data namespace ROOT (Model A: the in-guest + // drive mount itself), so backups/ sits directly under it: /backups/primary//db-dumps + dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps") resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(dbDumpPath)) - // Check cross-drive rsync directory: /felhom-data/backups/secondary//rsync - rsyncPath := filepath.Join(drivePath, felhomDataDir, "backups", "secondary", name, "rsync") + // Check cross-drive rsync directory: /backups/secondary//rsync + rsyncPath := filepath.Join(drivePath, "backups", "secondary", name, "rsync") resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath)) if m.isDebug() { diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go index 2e67b3f..3aee673 100644 --- a/controller/internal/web/backup_handlers.go +++ b/controller/internal/web/backup_handlers.go @@ -31,7 +31,8 @@ type guestBackupView struct { Success bool StartedAt time.Time SizeBytes int64 - Target string // human label: "Biztonsági szerver (PBS)" / "Helyi tároló (local)" + Target string // human label: "Biztonsági szerver – külön hardver (PBS)" / "Helyi tároló (local)" + Offsite bool // the whole-guest backup landed on the PBS offsite tier (separate hardware) Archive string Mode string // snapshot | stop StopMode bool // mode == stop → full app downtime during the backup (warn) @@ -73,6 +74,7 @@ func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView { v.Mode = st.Backup.Mode v.StopMode = st.Backup.Mode == "stop" v.Target = backupTargetLabel(st.Backup) + v.Offsite = backupIsPBS(st.Backup) if t, perr := time.Parse(time.RFC3339, st.Backup.StartedAt); perr == nil { v.StartedAt = t } @@ -97,13 +99,20 @@ func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView { return v } -// backupTargetLabel maps the agent's backup target to a customer-facing Hungarian label, surfacing -// whether the backup landed on the PBS offsite tier or local host storage (from the archive volid / -// target id — "felhom-pbs"/"pbs:" ⇒ PBS, else local host storage). -func backupTargetLabel(b *agentapi.BackupRecord) string { +// backupIsPBS reports whether a whole-guest backup landed on the PBS offsite tier (separate +// hardware), inferred from the target id / archive volid ("felhom-pbs"/"pbs:" ⇒ PBS). +func backupIsPBS(b *agentapi.BackupRecord) bool { id := strings.ToLower(b.TargetID) - if strings.Contains(id, "pbs") || strings.HasPrefix(strings.ToLower(b.Archive), "felhom-pbs") || strings.Contains(strings.ToLower(b.Archive), "pbs:") { - return "Biztonsági szerver (PBS)" + arc := strings.ToLower(b.Archive) + return strings.Contains(id, "pbs") || strings.HasPrefix(arc, "felhom-pbs") || strings.Contains(arc, "pbs:") +} + +// backupTargetLabel maps the agent's backup target to a customer-facing Hungarian label. The PBS +// case calls out that the backup is on SEPARATE HARDWARE (real disaster recovery — survives a host +// disk/hardware failure), which is the whole point of re-pointing the backup offsite. +func backupTargetLabel(b *agentapi.BackupRecord) string { + if backupIsPBS(b) { + return "Biztonsági szerver – külön hardver (PBS)" } if b.TargetID != "" { return "Helyi tároló (" + b.TargetID + ")" diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index ab75e3b..0e11d12 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -324,12 +324,13 @@ func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) { } // backupCopiesOnPath lists the apps whose CROSS-DRIVE (secondary) backup copies are stored on the -// drive mounted at `where` (slice 10 P4) — the felhom-data/backups/secondary/ dirs. A wipe of -// this drive removes these copies. Best-effort filesystem scan; empty until the cross-drive backup -// ENGINE (a follow-on slice) actually writes here. Shared/aggregate dirs (restic repo, _infra) are -// not apps and are skipped. +// drive mounted at `where` (slice 10 P4) — the backups/secondary/ dirs. A wipe of this drive +// removes these copies. Best-effort filesystem scan; empty until the cross-drive backup ENGINE (a +// follow-on slice) actually writes here. Shared/aggregate dirs (restic repo, _infra) are not apps +// and are skipped. Model A: `where` is the in-guest drive mount, which IS the felhom-data namespace +// root, so backups/ sits directly under it (no felhom-data segment — avoids the double-nest). func backupCopiesOnPath(where string) []string { - secondary := filepath.Join(where, appbackup.FelhomDataDir, "backups", "secondary") + secondary := filepath.Join(appbackup.NamespaceRoot(where, true), "backups", "secondary") entries, err := os.ReadDir(secondary) if err != nil { return nil // no secondary backups here (or the path isn't readable) — nothing to warn about diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 0960fd8..1a00ce5 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -140,10 +140,17 @@ {{end}} + {{if and .GuestBackup .GuestBackup.Offsite}} +
+
+
Távoli mentés
külön hardveren (PBS)
+
+ {{else}}
Távoli mentés
nincs beállítva
+ {{end}}