v0.12.8: complete cross-drive backup + per-tier UI
- Cross-drive now copies DB dumps (_db/) and config (_config/) alongside user data - restic cross-drive includes config dir + full DB dump dir - UI: per-tier rows (1. mentés / 2. mentés) instead of per-layer (DB/Konfig/Data) - UI: BackupContents label shows what each tier protects (DB + Konfig + Adatok) - UI: rsync backups show browsable indicator (📁) - Cleanup: removed unused filterSnapshotsByPaths + pathCovers from router.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,28 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### What was just completed (2026-02-18 session 45)
|
||||||
|
- **v0.12.8 — Complete Cross-Drive Backup + Per-Tier UI:**
|
||||||
|
|
||||||
|
**Fix 1: Cross-drive backup now includes DB dumps + app config (`crossdrive.go`, `main.go`)**
|
||||||
|
- `CrossDriveRunner` gets `dbDumpDir` field + `SetDBDumpDir(dir string)` setter
|
||||||
|
- `copyStackDBDumps()` helper copies `<stackName>_*.sql` files to `_db/` subfolder in rsync dest
|
||||||
|
- `runRsyncBackup()`: after HDD mount rsync loop, copies DB dumps to `_db/` and rsyncs config dir to `_config/` — both non-fatal on error
|
||||||
|
- `runResticBackup()`: appends config dir and full DB dump dir to restic paths (restic deduplicates)
|
||||||
|
- rsync destination layout: `backups/rsync/<app>/_db/` (dumps) + `_config/` (compose+yaml) + user data
|
||||||
|
- `main.go`: `crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)` wired after runner init
|
||||||
|
|
||||||
|
**Fix 2: UI restructured from per-layer to per-tier (`handlers.go`, `backups.html`, `style.css`)**
|
||||||
|
- `AppBackupRow` struct rebuilt: dropped old `DBLastRun/Status`, `VolumeLastRun/Status`, `HasUserData`, `UserDataConfigured/Method/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge` fields
|
||||||
|
- New fields: `BackupContents` (e.g., "DB + Konfig + Adatok"), `Tier1LastRun/LastStatus/DBStatus`, `Tier2Configured/Method/MethodLabel/Dest/Schedule/LastRun/LastStatus/LastError/StatusBadge/SizeHuman/Browsable`
|
||||||
|
- `buildAppBackupRows()` rewritten: destination health now via `s.crossDriveRunner.ValidateDestination()` instead of `system.CheckBackupDestination()`
|
||||||
|
- `backups.html`: two tier rows (1. mentés / 2. mentés) replace the old three layer rows (DB / Konfig / Userdata)
|
||||||
|
- `style.css`: added `.tier-label`, `.tier-location`, `.tier-contents`, `.tier-size`, `.tier-browsable` classes
|
||||||
|
|
||||||
|
**Fix 3: Cleanup (`router.go`)**
|
||||||
|
- `filterSnapshotsByPaths()` and `pathCovers()` deleted (were unused since v0.12.7a)
|
||||||
|
|
||||||
|
**Files modified (6):** `internal/backup/crossdrive.go`, `cmd/controller/main.go`, `internal/web/handlers.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css`, `internal/api/router.go`
|
||||||
|
|
||||||
### What was just completed (2026-02-18 session 44)
|
### What was just completed (2026-02-18 session 44)
|
||||||
- **v0.12.7a — Post-deploy fixes:**
|
- **v0.12.7a — Post-deploy fixes:**
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ func main() {
|
|||||||
if backupMgr != nil {
|
if backupMgr != nil {
|
||||||
crossDriveRunner.SetDBDumper(backupMgr)
|
crossDriveRunner.SetDBDumper(backupMgr)
|
||||||
}
|
}
|
||||||
|
crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)
|
||||||
|
|
||||||
// --- Initialize alert manager ---
|
// --- Initialize alert manager ---
|
||||||
alertMgr := web.NewAlertManager(logger)
|
alertMgr := web.NewAlertManager(logger)
|
||||||
|
|||||||
@@ -469,33 +469,6 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterSnapshotsByPaths returns only snapshots whose Paths overlap with requiredPaths.
|
|
||||||
// A snapshot matches if any of its paths is a prefix of (or prefixed by) any required path.
|
|
||||||
// M10: Uses separator-aware prefix check to prevent /mnt/hdd_1 matching /mnt/hdd_10/data.
|
|
||||||
func filterSnapshotsByPaths(snapshots []backup.SnapshotInfo, requiredPaths []string) []backup.SnapshotInfo {
|
|
||||||
var filtered []backup.SnapshotInfo
|
|
||||||
outer:
|
|
||||||
for _, snap := range snapshots {
|
|
||||||
for _, required := range requiredPaths {
|
|
||||||
for _, sp := range snap.Paths {
|
|
||||||
if pathCovers(required, sp) || pathCovers(sp, required) {
|
|
||||||
filtered = append(filtered, snap)
|
|
||||||
continue outer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathCovers returns true if base is equal to or a directory-prefix of target.
|
|
||||||
func pathCovers(base, target string) bool {
|
|
||||||
if base == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(target, strings.TrimRight(base, "/")+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Metrics handlers ---
|
// --- Metrics handlers ---
|
||||||
|
|
||||||
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type CrossDriveRunner struct {
|
|||||||
sett *settings.Settings
|
sett *settings.Settings
|
||||||
stackProvider StackDataProvider
|
stackProvider StackDataProvider
|
||||||
dbDumper DBDumper
|
dbDumper DBDumper
|
||||||
|
dbDumpDir string // path to DB dump directory (e.g., /srv/backups/db-dumps)
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running map[string]bool // per-app running state
|
running map[string]bool // per-app running state
|
||||||
@@ -46,6 +47,11 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) {
|
|||||||
r.dbDumper = d
|
r.dbDumper = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDBDumpDir sets the path to the DB dump directory for cross-drive backups.
|
||||||
|
func (r *CrossDriveRunner) SetDBDumpDir(dir string) {
|
||||||
|
r.dbDumpDir = dir
|
||||||
|
}
|
||||||
|
|
||||||
// RunAppBackup runs cross-drive backup for a single app.
|
// RunAppBackup runs cross-drive backup for a single app.
|
||||||
func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error {
|
func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error {
|
||||||
cfg := r.sett.GetCrossDriveConfig(stackName)
|
cfg := r.sett.GetCrossDriveConfig(stackName)
|
||||||
@@ -258,6 +264,34 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
|
|||||||
return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, strings.TrimSpace(string(out)))
|
return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Copy DB dumps for this stack ---
|
||||||
|
dbDestDir := filepath.Join(destDir, "_db")
|
||||||
|
if err := os.MkdirAll(dbDestDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating DB dump dest dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.copyStackDBDumps(stackName, dbDestDir); err != nil {
|
||||||
|
r.logger.Printf("[WARN] Cross-drive DB dump copy failed for %s: %v", stackName, err)
|
||||||
|
// Non-fatal: user data is the primary concern
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rsync app config (compose dir) ---
|
||||||
|
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
|
||||||
|
configSrcDir := filepath.Dir(composePath)
|
||||||
|
configDestDir := filepath.Join(destDir, "_config")
|
||||||
|
if err := os.MkdirAll(configDestDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating config dest dir: %w", err)
|
||||||
|
}
|
||||||
|
src := strings.TrimRight(configSrcDir, "/") + "/"
|
||||||
|
dst := strings.TrimRight(configDestDir, "/") + "/"
|
||||||
|
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst)
|
||||||
|
r.logger.Printf("[DEBUG] rsync config: %s → %s", src, dst)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
r.logger.Printf("[WARN] Cross-drive config rsync failed for %s: %v (%s)", stackName, err, strings.TrimSpace(string(out)))
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +332,18 @@ func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destB
|
|||||||
"--tag", stackName,
|
"--tag", stackName,
|
||||||
"--tag", "cross-drive",
|
"--tag", "cross-drive",
|
||||||
}
|
}
|
||||||
|
// Include user data (HDD mounts)
|
||||||
args = append(args, mounts...)
|
args = append(args, mounts...)
|
||||||
|
// Include app config dir (compose + app.yaml + .felhom.yml)
|
||||||
|
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
|
||||||
|
args = append(args, filepath.Dir(composePath))
|
||||||
|
}
|
||||||
|
// Include DB dump dir (all stacks' dumps — restic deduplicates)
|
||||||
|
if r.dbDumpDir != "" {
|
||||||
|
if _, err := os.Stat(r.dbDumpDir); err == nil {
|
||||||
|
args = append(args, r.dbDumpDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "restic", args...)
|
cmd := exec.CommandContext(ctx, "restic", args...)
|
||||||
r.logger.Printf("[DEBUG] restic backup: %v", args)
|
r.logger.Printf("[DEBUG] restic backup: %v", args)
|
||||||
@@ -346,6 +391,43 @@ func (r *CrossDriveRunner) ensureResticRepo(ctx context.Context, repoPath, pwFil
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyStackDBDumps copies DB dump files for the given stack to destDir.
|
||||||
|
// DB dump files are named <stackName>_<dbtype>.sql (e.g., immich_postgres.sql).
|
||||||
|
// Small files — uses plain file copy, not rsync.
|
||||||
|
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
|
||||||
|
if r.dbDumpDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(r.dbDumpDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("reading DB dump dir: %w", err)
|
||||||
|
}
|
||||||
|
prefix := stackName + "_"
|
||||||
|
copied := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := filepath.Join(r.dbDumpDir, e.Name())
|
||||||
|
dst := filepath.Join(destDir, e.Name())
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
copied++
|
||||||
|
}
|
||||||
|
if copied > 0 {
|
||||||
|
r.logger.Printf("[DEBUG] Copied %d DB dump file(s) to %s", copied, destDir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, duration time.Duration, sizeHuman string) {
|
func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, duration time.Duration, sizeHuman string) {
|
||||||
|
|||||||
@@ -497,42 +497,46 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "backups", data)
|
s.render(w, "backups", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppBackupRow holds all backup information for one app, used by the backup page template.
|
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||||||
type AppBackupRow struct {
|
type AppBackupRow struct {
|
||||||
StackName string
|
StackName string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Status string // "green", "yellow", "red", "auto"
|
Status string // "green", "yellow", "red", "auto"
|
||||||
StatusText string // short Hungarian tooltip
|
StatusText string // short Hungarian tooltip
|
||||||
|
|
||||||
// Storage info (HDD apps only)
|
// App characteristics
|
||||||
HasHDDData bool
|
HasHDDData bool
|
||||||
|
HasDB bool
|
||||||
StorageLabel string
|
StorageLabel string
|
||||||
HDDSizeHuman string
|
HDDSizeHuman string
|
||||||
|
|
||||||
// Layer details (nil = layer not applicable)
|
// What this app's backup contains (for display)
|
||||||
HasDB bool
|
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
|
||||||
DBLastRun string // formatted time
|
BackupContents string
|
||||||
DBLastStatus string // "ok", "error", ""
|
|
||||||
|
|
||||||
VolumeLastRun string
|
// Tier 1: Nightly backup (always exists)
|
||||||
VolumeLastStatus string
|
Tier1LastRun string // formatted time of last restic snapshot
|
||||||
|
Tier1LastStatus string // "ok", "error", ""
|
||||||
|
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
|
||||||
|
|
||||||
// Cross-drive / user data
|
// Tier 2: Cross-drive backup (only for apps with HDD data)
|
||||||
HasUserData bool
|
Tier2Configured bool
|
||||||
UserDataConfigured bool
|
Tier2Method string // "rsync", "restic"
|
||||||
UserDataMethod string // "rsync", "restic"
|
Tier2MethodLabel string // "rsync", "restic"
|
||||||
UserDataDest string // destination label
|
Tier2Dest string // destination label
|
||||||
UserDataSchedule string // "Naponta", "Hetente"
|
Tier2Schedule string // "Naponta", "Hetente"
|
||||||
UserDataLastRun string
|
Tier2LastRun string
|
||||||
UserDataLastStatus string // "ok", "error", "running", ""
|
Tier2LastStatus string // "ok", "error", "running", ""
|
||||||
UserDataLastError string
|
Tier2LastError string
|
||||||
UserDataStatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||||||
|
Tier2SizeHuman string
|
||||||
|
Tier2Browsable bool // true for rsync (plain files), false for restic
|
||||||
|
|
||||||
// Warnings accumulated for this app
|
// Warnings accumulated for this app
|
||||||
Warnings []string
|
Warnings []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAppBackupRows constructs one AppBackupRow per deployed app for the unified backup page.
|
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
|
||||||
func (s *Server) buildAppBackupRows(
|
func (s *Server) buildAppBackupRows(
|
||||||
status *backup.FullBackupStatus,
|
status *backup.FullBackupStatus,
|
||||||
crossConfigs map[string]*settings.CrossDriveBackup,
|
crossConfigs map[string]*settings.CrossDriveBackup,
|
||||||
@@ -540,138 +544,140 @@ func (s *Server) buildAppBackupRows(
|
|||||||
) []AppBackupRow {
|
) []AppBackupRow {
|
||||||
loc := getTimezone()
|
loc := getTimezone()
|
||||||
|
|
||||||
// Build a quick lookup: which stacks have a DB dump?
|
// Build DB stack lookup
|
||||||
dbStacks := make(map[string]bool)
|
dbStacks := make(map[string]bool)
|
||||||
for _, db := range status.DiscoveredDBs {
|
for _, db := range status.DiscoveredDBs {
|
||||||
dbStacks[db.StackName] = true
|
dbStacks[db.StackName] = true
|
||||||
}
|
}
|
||||||
// Also check dump files if no live discovered DBs
|
|
||||||
for _, f := range status.DumpFiles {
|
for _, f := range status.DumpFiles {
|
||||||
dbStacks[f.StackName] = true
|
dbStacks[f.StackName] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine last restic run time for volume backup display
|
// Tier 1 timestamps (shared across all apps — single nightly job)
|
||||||
volumeLastRun := ""
|
tier1LastRun := ""
|
||||||
volumeLastStatus := ""
|
tier1LastStatus := ""
|
||||||
if status.LastBackup != nil {
|
if status.LastBackup != nil {
|
||||||
volumeLastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
|
tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
|
||||||
if status.LastBackup.Success {
|
if status.LastBackup.Success {
|
||||||
volumeLastStatus = "ok"
|
tier1LastStatus = "ok"
|
||||||
} else {
|
} else {
|
||||||
volumeLastStatus = "error"
|
tier1LastStatus = "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tier1DBStatus := ""
|
||||||
// DB dump last run
|
|
||||||
dbLastRun := ""
|
|
||||||
dbLastStatus := ""
|
|
||||||
if status.LastDBDump != nil {
|
if status.LastDBDump != nil {
|
||||||
dbLastRun = status.LastDBDump.LastRun.In(loc).Format("01-02 15:04")
|
|
||||||
if status.LastDBDump.Success {
|
if status.LastDBDump.Success {
|
||||||
dbLastStatus = "ok"
|
tier1DBStatus = "ok"
|
||||||
} else {
|
} else {
|
||||||
dbLastStatus = "error"
|
tier1DBStatus = "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []AppBackupRow
|
var rows []AppBackupRow
|
||||||
for _, app := range status.AppDataInfo {
|
for _, app := range status.AppDataInfo {
|
||||||
|
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
||||||
|
|
||||||
|
// Build backup contents label
|
||||||
|
var parts []string
|
||||||
|
if hasDB {
|
||||||
|
parts = append(parts, "DB")
|
||||||
|
}
|
||||||
|
parts = append(parts, "Konfig")
|
||||||
|
if app.HasHDDData {
|
||||||
|
parts = append(parts, "Adatok")
|
||||||
|
}
|
||||||
|
contents := strings.Join(parts, " + ")
|
||||||
|
|
||||||
row := AppBackupRow{
|
row := AppBackupRow{
|
||||||
StackName: app.StackName,
|
StackName: app.StackName,
|
||||||
DisplayName: app.DisplayName,
|
DisplayName: app.DisplayName,
|
||||||
HasHDDData: app.HasHDDData,
|
HasHDDData: app.HasHDDData,
|
||||||
|
HasDB: hasDB,
|
||||||
StorageLabel: app.StorageLabel,
|
StorageLabel: app.StorageLabel,
|
||||||
HDDSizeHuman: app.HDDSizeHuman,
|
HDDSizeHuman: app.HDDSizeHuman,
|
||||||
HasDB: dbStacks[app.StackName] || app.HasDBDump,
|
BackupContents: contents,
|
||||||
DBLastRun: dbLastRun,
|
|
||||||
DBLastStatus: dbLastStatus,
|
Tier1LastRun: tier1LastRun,
|
||||||
VolumeLastRun: volumeLastRun,
|
Tier1LastStatus: tier1LastStatus,
|
||||||
VolumeLastStatus: volumeLastStatus,
|
Tier1DBStatus: tier1DBStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default status = green/auto
|
// Default status = auto (no user data, just config)
|
||||||
row.Status = "auto"
|
row.Status = "auto"
|
||||||
row.StatusText = "Automatikus mentés"
|
row.StatusText = "Automatikus mentés"
|
||||||
|
|
||||||
if app.HasHDDData {
|
if app.HasHDDData {
|
||||||
row.HasUserData = true
|
|
||||||
cfg, hasCfg := crossConfigs[app.StackName]
|
cfg, hasCfg := crossConfigs[app.StackName]
|
||||||
|
|
||||||
if !hasCfg || cfg == nil || !cfg.Enabled {
|
if !hasCfg || cfg == nil || !cfg.Enabled {
|
||||||
// HDD data backed up via nightly restic (mandatory), but no second copy
|
// HDD data backed up via nightly restic (mandatory), but no second copy
|
||||||
row.UserDataConfigured = false
|
row.Tier2Configured = false
|
||||||
row.Status = "yellow"
|
row.Status = "yellow"
|
||||||
row.StatusText = "Nincs második másolat (csak helyi mentés)"
|
row.StatusText = "Nincs második másolat (csak helyi mentés)"
|
||||||
} else {
|
} else {
|
||||||
row.UserDataConfigured = true
|
row.Tier2Configured = true
|
||||||
row.UserDataMethod = cfg.Method
|
row.Tier2Method = cfg.Method
|
||||||
row.UserDataDest = destLabels[cfg.DestinationPath]
|
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
|
||||||
if row.UserDataDest == "" {
|
row.Tier2Browsable = cfg.Method == "rsync"
|
||||||
row.UserDataDest = cfg.DestinationPath
|
row.Tier2Dest = destLabels[cfg.DestinationPath]
|
||||||
|
if row.Tier2Dest == "" {
|
||||||
|
row.Tier2Dest = cfg.DestinationPath
|
||||||
}
|
}
|
||||||
switch cfg.Schedule {
|
switch cfg.Schedule {
|
||||||
case "daily":
|
case "daily":
|
||||||
row.UserDataSchedule = "Naponta"
|
row.Tier2Schedule = "Naponta"
|
||||||
case "weekly":
|
case "weekly":
|
||||||
row.UserDataSchedule = "Hetente (vasárnap)"
|
row.Tier2Schedule = "Hetente"
|
||||||
default:
|
default:
|
||||||
row.UserDataSchedule = cfg.Schedule
|
row.Tier2Schedule = cfg.Schedule
|
||||||
}
|
}
|
||||||
if cfg.LastRun != "" {
|
if cfg.LastRun != "" {
|
||||||
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
||||||
row.UserDataLastRun = t.In(loc).Format("01-02 15:04")
|
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.UserDataLastStatus = cfg.LastStatus
|
row.Tier2LastStatus = cfg.LastStatus
|
||||||
row.UserDataLastError = cfg.LastError
|
row.Tier2LastError = cfg.LastError
|
||||||
|
row.Tier2SizeHuman = cfg.LastSizeHuman
|
||||||
switch cfg.LastStatus {
|
switch cfg.LastStatus {
|
||||||
case "ok":
|
case "ok":
|
||||||
row.UserDataStatusBadge = "Sikeres"
|
row.Tier2StatusBadge = "Sikeres"
|
||||||
case "error":
|
case "error":
|
||||||
row.UserDataStatusBadge = "Hiba"
|
row.Tier2StatusBadge = "Hiba"
|
||||||
case "running":
|
|
||||||
row.UserDataStatusBadge = "Fut..."
|
|
||||||
default:
|
|
||||||
row.UserDataStatusBadge = "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check destination health for status determination
|
|
||||||
health := system.CheckBackupDestination(cfg.DestinationPath)
|
|
||||||
if health.Blocked {
|
|
||||||
row.Status = "red"
|
|
||||||
row.StatusText = "Mentési cél nem elérhető"
|
|
||||||
row.Warnings = append(row.Warnings, health.Warning)
|
|
||||||
} else if health.Warning != "" {
|
|
||||||
row.Status = "yellow"
|
|
||||||
row.StatusText = "Figyelmeztetés"
|
|
||||||
row.Warnings = append(row.Warnings, health.Warning)
|
|
||||||
} else if cfg.LastStatus == "error" {
|
|
||||||
row.Status = "yellow"
|
row.Status = "yellow"
|
||||||
row.StatusText = "Utolsó mentés sikertelen"
|
row.StatusText = "Utolsó mentés sikertelen"
|
||||||
} else {
|
case "running":
|
||||||
row.Status = "green"
|
row.Tier2StatusBadge = "Fut..."
|
||||||
row.StatusText = "Mentés rendben"
|
default:
|
||||||
}
|
row.Tier2StatusBadge = "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destination health check
|
||||||
|
if cfg.Enabled && cfg.DestinationPath != "" {
|
||||||
|
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
|
||||||
|
row.Status = "red"
|
||||||
|
row.StatusText = "Mentési cél nem elérhető"
|
||||||
} else {
|
} else {
|
||||||
// No HDD data — everything backed up automatically via restic
|
|
||||||
if volumeLastStatus == "ok" {
|
|
||||||
row.Status = "green"
|
|
||||||
row.StatusText = "Mentés rendben"
|
|
||||||
} else if volumeLastStatus == "error" {
|
|
||||||
row.Status = "yellow"
|
row.Status = "yellow"
|
||||||
row.StatusText = "Kötetek mentése sikertelen"
|
row.StatusText = "Figyelmeztetés"
|
||||||
} else {
|
}
|
||||||
row.Status = "auto"
|
row.Warnings = append(row.Warnings, err.Error())
|
||||||
row.StatusText = "Automatikus mentés"
|
} else if row.Status != "yellow" {
|
||||||
|
row.Status = "green"
|
||||||
|
row.StatusText = "Mentés rendben"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If DB dump failed for this app, degrade to yellow (if not already red)
|
// DB dump failure warning (affects Tier 1 quality)
|
||||||
if row.HasDB && dbLastStatus == "error" && row.Status != "red" {
|
if hasDB && tier1DBStatus == "error" {
|
||||||
|
if row.Status != "red" {
|
||||||
row.Status = "yellow"
|
row.Status = "yellow"
|
||||||
row.StatusText = "Adatbázis mentés sikertelen"
|
row.StatusText = "Adatbázis mentés sikertelen"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows = append(rows, row)
|
rows = append(rows, row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,47 +264,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="app-backup-row-detail" style="display:none">
|
<div class="app-backup-row-detail" style="display:none">
|
||||||
<div class="backup-layers">
|
<div class="backup-layers">
|
||||||
<!-- DB layer -->
|
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
|
||||||
<div class="backup-layer-row">
|
<div class="backup-layer-row">
|
||||||
<span class="layer-label">Adatbázis mentés</span>
|
<span class="tier-label">1. mentés</span>
|
||||||
{{if .HasDB}}
|
|
||||||
<span class="layer-badge">Auto</span>
|
<span class="layer-badge">Auto</span>
|
||||||
{{if .DBLastRun}}
|
<span class="tier-location">helyi</span>
|
||||||
<span class="layer-last">Utolsó: {{.DBLastRun}}
|
{{if .Tier1LastRun}}
|
||||||
{{if eq .DBLastStatus "ok"}}<span class="text-ok">✓</span>
|
<span class="layer-last">Utolsó: {{.Tier1LastRun}}
|
||||||
{{else if eq .DBLastStatus "error"}}<span class="text-error">✗</span>{{end}}
|
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok">✓</span>
|
||||||
|
{{else if eq .Tier1LastStatus "error"}}<span class="text-error">✗</span>{{end}}
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
<span class="tier-contents">{{.BackupContents}}</span>
|
||||||
<span class="layer-na">— (nincs adatbázis)</span>
|
{{if and .HasDB (eq .Tier1DBStatus "error")}}
|
||||||
|
<span class="text-error" style="font-size:.8rem">⚠ DB dump hiba</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<!-- Volume layer -->
|
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
|
||||||
|
{{if .HasHDDData}}
|
||||||
<div class="backup-layer-row">
|
<div class="backup-layer-row">
|
||||||
<span class="layer-label">Konfiguráció</span>
|
<span class="tier-label">2. mentés</span>
|
||||||
<span class="layer-badge">Auto</span>
|
{{if .Tier2Configured}}
|
||||||
{{if .VolumeLastRun}}
|
<span class="layer-method">{{.Tier2MethodLabel}}</span>
|
||||||
<span class="layer-last">Utolsó: {{.VolumeLastRun}}
|
<span class="layer-dest">→ {{.Tier2Dest}}</span>
|
||||||
{{if eq .VolumeLastStatus "ok"}}<span class="text-ok">✓</span>
|
<span class="layer-schedule">{{.Tier2Schedule}}</span>
|
||||||
{{else if eq .VolumeLastStatus "error"}}<span class="text-error">✗</span>{{end}}
|
{{if .Tier2LastRun}}
|
||||||
</span>
|
<span class="layer-last">Utolsó: {{.Tier2LastRun}}
|
||||||
{{end}}
|
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
|
||||||
</div>
|
{{.Tier2StatusBadge}}
|
||||||
<!-- User data layer -->
|
|
||||||
<div class="backup-layer-row{{if not .HasHDDData}} layer-row-na{{end}}">
|
|
||||||
<span class="layer-label">Felhasználói adatok</span>
|
|
||||||
{{if .HasUserData}}
|
|
||||||
{{if .UserDataConfigured}}
|
|
||||||
<span class="layer-method">{{.UserDataMethod}}</span>
|
|
||||||
<span class="layer-dest">→ {{.UserDataDest}}</span>
|
|
||||||
<span class="layer-schedule">{{.UserDataSchedule}}</span>
|
|
||||||
{{if .UserDataLastRun}}
|
|
||||||
<span class="layer-last">Utolsó: {{.UserDataLastRun}}
|
|
||||||
<span class="{{if eq .UserDataLastStatus "ok"}}text-ok{{else if eq .UserDataLastStatus "error"}}text-error{{else if eq .UserDataLastStatus "running"}}text-muted{{end}}">
|
|
||||||
{{.UserDataStatusBadge}}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
|
||||||
|
<span class="tier-contents">{{.BackupContents}}</span>
|
||||||
|
{{if .Tier2Browsable}}<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>{{end}}
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
|
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
|
||||||
<button class="btn btn-xs btn-outline"
|
<button class="btn btn-xs btn-outline"
|
||||||
@@ -312,14 +305,12 @@
|
|||||||
Futtatás most</button>
|
Futtatás most</button>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="layer-auto-ok">✓ Helyi mentés auto</span>
|
<span class="layer-auto-ok">✓ 1. mentés auto</span>
|
||||||
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
|
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
|
||||||
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
|
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
|
||||||
<span class="layer-na">— (nincs HDD adat)</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .Warnings}}
|
{{if .Warnings}}
|
||||||
<div class="layer-warnings">
|
<div class="layer-warnings">
|
||||||
|
|||||||
@@ -2594,3 +2594,28 @@ a.stat-card:hover {
|
|||||||
.text-ok { color: var(--green); }
|
.text-ok { color: var(--green); }
|
||||||
.text-error { color: var(--red); }
|
.text-error { color: var(--red); }
|
||||||
.text-muted { color: var(--text-muted); }
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.tier-label {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 5rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tier-location {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
.tier-contents {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .8rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: .25rem;
|
||||||
|
}
|
||||||
|
.tier-size {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .8rem;
|
||||||
|
margin-left: .25rem;
|
||||||
|
}
|
||||||
|
.tier-browsable {
|
||||||
|
font-size: .75rem;
|
||||||
|
margin-left: .15rem;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user