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:
2026-02-18 11:37:48 +01:00
parent b65ab612f0
commit 4a9aea647b
7 changed files with 274 additions and 173 deletions
+102 -96
View File
@@ -497,42 +497,46 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
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 {
StackName string
DisplayName string
Status string // "green", "yellow", "red", "auto"
StatusText string // short Hungarian tooltip
// Storage info (HDD apps only)
// App characteristics
HasHDDData bool
HasDB bool
StorageLabel string
HDDSizeHuman string
// Layer details (nil = layer not applicable)
HasDB bool
DBLastRun string // formatted time
DBLastStatus string // "ok", "error", ""
// What this app's backup contains (for display)
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
BackupContents string
VolumeLastRun string
VolumeLastStatus string
// Tier 1: Nightly backup (always exists)
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
HasUserData bool
UserDataConfigured bool
UserDataMethod string // "rsync", "restic"
UserDataDest string // destination label
UserDataSchedule string // "Naponta", "Hetente"
UserDataLastRun string
UserDataLastStatus string // "ok", "error", "running", ""
UserDataLastError string
UserDataStatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
// Tier 2: Cross-drive backup (only for apps with HDD data)
Tier2Configured bool
Tier2Method string // "rsync", "restic"
Tier2MethodLabel string // "rsync", "restic"
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
Tier2LastError string
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
Tier2Browsable bool // true for rsync (plain files), false for restic
// Warnings accumulated for this app
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(
status *backup.FullBackupStatus,
crossConfigs map[string]*settings.CrossDriveBackup,
@@ -540,137 +544,139 @@ func (s *Server) buildAppBackupRows(
) []AppBackupRow {
loc := getTimezone()
// Build a quick lookup: which stacks have a DB dump?
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
dbStacks[db.StackName] = true
}
// Also check dump files if no live discovered DBs
for _, f := range status.DumpFiles {
dbStacks[f.StackName] = true
}
// Determine last restic run time for volume backup display
volumeLastRun := ""
volumeLastStatus := ""
// Tier 1 timestamps (shared across all apps — single nightly job)
tier1LastRun := ""
tier1LastStatus := ""
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 {
volumeLastStatus = "ok"
tier1LastStatus = "ok"
} else {
volumeLastStatus = "error"
tier1LastStatus = "error"
}
}
// DB dump last run
dbLastRun := ""
dbLastStatus := ""
tier1DBStatus := ""
if status.LastDBDump != nil {
dbLastRun = status.LastDBDump.LastRun.In(loc).Format("01-02 15:04")
if status.LastDBDump.Success {
dbLastStatus = "ok"
tier1DBStatus = "ok"
} else {
dbLastStatus = "error"
tier1DBStatus = "error"
}
}
var rows []AppBackupRow
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{
StackName: app.StackName,
DisplayName: app.DisplayName,
HasHDDData: app.HasHDDData,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
HasDB: dbStacks[app.StackName] || app.HasDBDump,
DBLastRun: dbLastRun,
DBLastStatus: dbLastStatus,
VolumeLastRun: volumeLastRun,
VolumeLastStatus: volumeLastStatus,
StackName: app.StackName,
DisplayName: app.DisplayName,
HasHDDData: app.HasHDDData,
HasDB: hasDB,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
BackupContents: contents,
Tier1LastRun: tier1LastRun,
Tier1LastStatus: tier1LastStatus,
Tier1DBStatus: tier1DBStatus,
}
// Default status = green/auto
// Default status = auto (no user data, just config)
row.Status = "auto"
row.StatusText = "Automatikus mentés"
if app.HasHDDData {
row.HasUserData = true
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil || !cfg.Enabled {
// HDD data backed up via nightly restic (mandatory), but no second copy
row.UserDataConfigured = false
row.Tier2Configured = false
row.Status = "yellow"
row.StatusText = "Nincs második másolat (csak helyi mentés)"
} else {
row.UserDataConfigured = true
row.UserDataMethod = cfg.Method
row.UserDataDest = destLabels[cfg.DestinationPath]
if row.UserDataDest == "" {
row.UserDataDest = cfg.DestinationPath
row.Tier2Configured = true
row.Tier2Method = cfg.Method
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
row.Tier2Browsable = cfg.Method == "rsync"
row.Tier2Dest = destLabels[cfg.DestinationPath]
if row.Tier2Dest == "" {
row.Tier2Dest = cfg.DestinationPath
}
switch cfg.Schedule {
case "daily":
row.UserDataSchedule = "Naponta"
row.Tier2Schedule = "Naponta"
case "weekly":
row.UserDataSchedule = "Hetente (vasárnap)"
row.Tier2Schedule = "Hetente"
default:
row.UserDataSchedule = cfg.Schedule
row.Tier2Schedule = cfg.Schedule
}
if cfg.LastRun != "" {
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.UserDataLastError = cfg.LastError
row.Tier2LastStatus = cfg.LastStatus
row.Tier2LastError = cfg.LastError
row.Tier2SizeHuman = cfg.LastSizeHuman
switch cfg.LastStatus {
case "ok":
row.UserDataStatusBadge = "Sikeres"
row.Tier2StatusBadge = "Sikeres"
case "error":
row.UserDataStatusBadge = "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.Tier2StatusBadge = "Hiba"
row.Status = "yellow"
row.StatusText = "Utolsó mentés sikertelen"
} else {
row.Status = "green"
row.StatusText = "Mentés rendben"
case "running":
row.Tier2StatusBadge = "Fut..."
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 {
row.Status = "yellow"
row.StatusText = "Figyelmeztetés"
}
row.Warnings = append(row.Warnings, err.Error())
} else if row.Status != "yellow" {
row.Status = "green"
row.StatusText = "Mentés rendben"
}
}
}
} 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.StatusText = "Kötetek mentése sikertelen"
} else {
row.Status = "auto"
row.StatusText = "Automatikus mentés"
}
}
// If DB dump failed for this app, degrade to yellow (if not already red)
if row.HasDB && dbLastStatus == "error" && row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
// DB dump failure warning (affects Tier 1 quality)
if hasDB && tier1DBStatus == "error" {
if row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
}
}
rows = append(rows, row)
+41 -50
View File
@@ -264,62 +264,53 @@
</div>
<div class="app-backup-row-detail" style="display:none">
<div class="backup-layers">
<!-- DB layer -->
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
<div class="backup-layer-row">
<span class="layer-label">Adatbázis mentés</span>
{{if .HasDB}}
<span class="layer-badge">Auto</span>
{{if .DBLastRun}}
<span class="layer-last">Utolsó: {{.DBLastRun}}
{{if eq .DBLastStatus "ok"}}<span class="text-ok"></span>
{{else if eq .DBLastStatus "error"}}<span class="text-error"></span>{{end}}
</span>
{{end}}
{{else}}
<span class="layer-na">— (nincs adatbázis)</span>
{{end}}
</div>
<!-- Volume layer -->
<div class="backup-layer-row">
<span class="layer-label">Konfiguráció</span>
<span class="tier-label">1. mentés</span>
<span class="layer-badge">Auto</span>
{{if .VolumeLastRun}}
<span class="layer-last">Utolsó: {{.VolumeLastRun}}
{{if eq .VolumeLastStatus "ok"}}<span class="text-ok"></span>
{{else if eq .VolumeLastStatus "error"}}<span class="text-error"></span>{{end}}
<span class="tier-location">helyi</span>
{{if .Tier1LastRun}}
<span class="layer-last">Utolsó: {{.Tier1LastRun}}
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok"></span>
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"></span>{{end}}
</span>
{{end}}
</div>
<!-- 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>
{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<button class="btn btn-xs btn-outline"
onclick="triggerCrossDriveBackup('{{.StackName}}', this)">
Futtatás most</button>
</div>
{{else}}
<span class="layer-auto-ok">✓ Helyi mentés auto</span>
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
{{end}}
{{else}}
<span class="layer-na">— (nincs HDD adat)</span>
<span class="tier-contents">{{.BackupContents}}</span>
{{if and .HasDB (eq .Tier1DBStatus "error")}}
<span class="text-error" style="font-size:.8rem">⚠ DB dump hiba</span>
{{end}}
</div>
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
{{if .HasHDDData}}
<div class="backup-layer-row">
<span class="tier-label">2. mentés</span>
{{if .Tier2Configured}}
<span class="layer-method">{{.Tier2MethodLabel}}</span>
<span class="layer-dest">→ {{.Tier2Dest}}</span>
<span class="layer-schedule">{{.Tier2Schedule}}</span>
{{if .Tier2LastRun}}
<span class="layer-last">Utolsó: {{.Tier2LastRun}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{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">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<button class="btn btn-xs btn-outline"
onclick="triggerCrossDriveBackup('{{.StackName}}', this)">
Futtatás most</button>
</div>
{{else}}
<span class="layer-auto-ok">✓ 1. mentés auto</span>
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
{{end}}
</div>
{{end}}
</div>
{{if .Warnings}}
<div class="layer-warnings">
@@ -2594,3 +2594,28 @@ a.stat-card:hover {
.text-ok { color: var(--green); }
.text-error { color: var(--red); }
.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;
}