v0.12.0 — Backup page overhaul: unified app rows, bug fixes, sequential chaining

Bug fixes:
- GetFullStatus() returns deep copy; CrossDriveSummary/UnconfiguredApps/CrossDriveWarnings
  are always nil in the copy so the handler builds them fresh (fixes duplicate-apps bug)
- Replace binary IsMountPoint check with tiered CheckBackupDestination() — path-not-exist,
  not-writable, system-drive (warning), disk >90-95% full; shown as warning vs critical
- Remove dead settingsAppBackupHandler / POST /settings/app-backup route (toggle wrote
  to settings.json but nothing consumed the flag)

Architecture:
- Unified per-app backup rows: new AppBackupRow struct + buildAppBackupRows() replaces
  the two old sections with expandable rows showing all 3 layers per app
- Sequential backup chaining: cross-drive runs immediately after restic (removed
  independent cross-drive-daily/cross-drive-weekly scheduler jobs)
- Deploy page: remove "Csak kézi indítás" schedule option; add weekly consistency note

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 17:56:28 +01:00
parent e002d712cf
commit 1de244646b
10 changed files with 637 additions and 154 deletions
+30 -16
View File
@@ -585,29 +585,43 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
// GetFullStatus returns the cached backup status for page rendering.
// Returns instantly — no subprocess calls.
// Returns a deep copy so callers can safely append to slice fields without
// polluting the cache (which would cause duplicate entries on repeated calls).
func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus {
m.mu.Lock()
defer m.mu.Unlock()
if m.cachedStatus != nil {
// Deep copy — callers (backupsHandler) append to CrossDriveSummary,
// UnconfiguredApps, and CrossDriveWarnings. If we returned the cache
// pointer directly, every page load would accumulate more entries.
status := *m.cachedStatus
status.AppDataInfo = make([]AppBackupInfo, len(m.cachedStatus.AppDataInfo))
copy(status.AppDataInfo, m.cachedStatus.AppDataInfo)
// These three slices are assembled by the handler from AppDataInfo + settings;
// they must always start empty so the handler builds them fresh.
status.CrossDriveSummary = nil
status.UnconfiguredApps = nil
status.CrossDriveWarnings = nil
// Update dynamic fields that don't need subprocess calls
m.cachedStatus.Running = m.running
m.cachedStatus.NextDBDump = nextDBDump
m.cachedStatus.NextBackup = nextBackup
m.cachedStatus.LastDBDump = m.lastDBDump
m.cachedStatus.LastBackup = m.lastBackup
status.Running = m.running
status.NextDBDump = nextDBDump
status.NextBackup = nextBackup
status.LastDBDump = m.lastDBDump
status.LastBackup = m.lastBackup
// Update snapshot history
m.cachedStatus.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
copy(m.cachedStatus.SnapshotHistory, m.snapshotHistory)
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
copy(status.SnapshotHistory, m.snapshotHistory)
// Reverse so newest first
for i, j := 0, len(m.cachedStatus.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
m.cachedStatus.SnapshotHistory[i], m.cachedStatus.SnapshotHistory[j] = m.cachedStatus.SnapshotHistory[j], m.cachedStatus.SnapshotHistory[i]
for i, j := 0, len(status.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
status.SnapshotHistory[i], status.SnapshotHistory[j] = status.SnapshotHistory[j], status.SnapshotHistory[i]
}
// Synthesize LastBackup from snapshot history if not in memory (e.g., after restart)
if m.cachedStatus.LastBackup == nil && len(m.cachedStatus.SnapshotHistory) > 0 {
latest := m.cachedStatus.SnapshotHistory[0] // already reversed, newest first
m.cachedStatus.LastBackup = &BackupStatus{
if status.LastBackup == nil && len(status.SnapshotHistory) > 0 {
latest := status.SnapshotHistory[0] // already reversed, newest first
status.LastBackup = &BackupStatus{
LastRun: latest.Time,
Success: latest.Success,
Snapshot: &SnapshotResult{
@@ -617,10 +631,10 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
}
// Synthesize LastDBDump from DumpFiles on disk if not in memory
if m.cachedStatus.LastDBDump == nil && len(m.cachedStatus.DumpFiles) > 0 {
if status.LastDBDump == nil && len(status.DumpFiles) > 0 {
var results []DumpResult
var latestTime time.Time
for _, f := range m.cachedStatus.DumpFiles {
for _, f := range status.DumpFiles {
results = append(results, DumpResult{
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName,
@@ -630,14 +644,14 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
latestTime = f.ModTime
}
}
m.cachedStatus.LastDBDump = &DBDumpStatus{
status.LastDBDump = &DBDumpStatus{
LastRun: latestTime,
Results: results,
Success: true,
}
}
return m.cachedStatus
return &status
}
// No cache yet — return a minimal status (first page load before cache is populated)
@@ -118,6 +118,85 @@ func GetFSInfo(path string) *FSInfo {
return info
}
// DestinationHealth holds the result of a tiered backup destination check.
type DestinationHealth struct {
Exists bool
Writable bool
MountPoint bool // true if path is on a different device from its parent
SystemDrive bool // true if path is on the same device as /
UsedPercent float64 // disk usage percentage (0 if unknown)
FreeGB float64
Warning string // human-readable warning message in Hungarian (empty = ok)
Blocked bool // if true, backup must not run
Severity string // "ok", "warning", "critical"
}
// CheckBackupDestination performs tiered validation of a cross-drive backup destination.
// Returns a DestinationHealth describing any issues found.
func CheckBackupDestination(path string) DestinationHealth {
h := DestinationHealth{Severity: "ok"}
// Tier 1: path must exist
if _, err := os.Stat(path); os.IsNotExist(err) {
h.Warning = "A cél tárhely (" + path + ") nem létezik!"
h.Blocked = true
h.Severity = "critical"
return h
}
h.Exists = true
// Tier 2: path must be writable
if !IsWritable(path) {
h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
h.Blocked = true
h.Severity = "critical"
return h
}
h.Writable = true
// Tier 3: detect if source and destination are on the same block device
// (stronger than IsMountPoint — catches e.g. bind mounts within same device)
if isSameBlockDevice(path, "/") {
h.SystemDrive = true
// This is a warning, not a block — user data still protected against software errors
h.Warning = "A cél tárhely (" + path + ") a rendszermeghajtón van. " +
"Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
"Külső meghajtó használata javasolt."
h.Severity = "warning"
// Don't return early — also check disk usage
} else {
h.MountPoint = true
}
// Tier 4: disk usage checks
if di := GetDiskUsage(path); di != nil {
h.UsedPercent = di.UsedPercent
h.FreeGB = di.AvailGB
if di.UsedPercent >= 95 {
h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent)
h.Blocked = true
h.Severity = "critical"
} else if di.UsedPercent >= 90 && h.Severity == "ok" {
h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent)
h.Severity = "warning"
}
}
return h
}
// isSameBlockDevice returns true if pathA and pathB are on the same block device.
func isSameBlockDevice(pathA, pathB string) bool {
var statA, statB syscall.Stat_t
if err := syscall.Stat(pathA, &statA); err != nil {
return false
}
if err := syscall.Stat(pathB, &statB); err != nil {
return false
}
return statA.Dev == statB.Dev
}
// diskModel reads the disk model from /sys/block/<dev>/device/model.
func diskModel(device string) string {
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
@@ -57,3 +57,26 @@ type FSInfo struct {
// GetFSInfo returns nil on non-Linux.
func GetFSInfo(_ string) *FSInfo { return nil }
// DestinationHealth holds the result of a tiered backup destination check.
type DestinationHealth struct {
Exists bool
Writable bool
MountPoint bool
SystemDrive bool
UsedPercent float64
FreeGB float64
Warning string
Blocked bool
Severity string
}
// CheckBackupDestination always returns ok on non-Linux (assume healthy for dev/testing).
func CheckBackupDestination(path string) DestinationHealth {
return DestinationHealth{
Exists: true,
Writable: true,
MountPoint: true,
Severity: "ok",
}
}
+193 -36
View File
@@ -232,19 +232,14 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
data["BackupDestPaths"] = destPaths
// Destination health warning
// Destination health warning (tiered validation)
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
data["BackupDestWarning"] = fmt.Sprintf(
"A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
crossCfg.DestinationPath,
)
health := system.CheckBackupDestination(crossCfg.DestinationPath)
if health.Warning != "" {
data["BackupDestWarning"] = health.Warning
data["BackupDestWarningSeverity"] = health.Severity
}
}
// Nightly backup toggle state
appBackupEnabled := s.settings.IsAppBackupEnabled(name)
data["AppBackupEnabled"] = appBackupEnabled
}
// Memory info for deploy page (only for non-deployed apps)
@@ -457,15 +452,38 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
}
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
// Destination health warning
// Destination health warning (tiered validation)
if cfg.Enabled && cfg.DestinationPath != "" {
if !system.IsMountPoint(cfg.DestinationPath) || !system.IsWritable(cfg.DestinationPath) {
health := system.CheckBackupDestination(cfg.DestinationPath)
if health.Warning != "" {
prefix := "⚠️"
if health.Severity == "critical" {
prefix = "🔴"
}
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
fmt.Sprintf("⚠️ %s mentési célja (%s) nem elérhető!", app.DisplayName, cfg.DestinationPath))
fmt.Sprintf("%s %s: %s", prefix, app.DisplayName, health.Warning))
}
}
}
// Build unified per-app backup rows for the new UI
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, crossConfigs, destLabels)
// Top-level warning: no user data backed up at all
hasAnyCrossDrive := false
hasAnyHDDApp := false
for _, app := range fullStatus.AppDataInfo {
if app.HasHDDData {
hasAnyHDDApp = true
if cfg, ok := crossConfigs[app.StackName]; ok && cfg != nil && cfg.Enabled {
hasAnyCrossDrive = true
}
}
}
if hasAnyHDDApp && !hasAnyCrossDrive {
data["NoUserDataBackupWarning"] = true
}
data["Backup"] = fullStatus
// Restic password for display
@@ -479,38 +497,177 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
s.render(w, "backups", data)
}
func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
// AppBackupRow holds all backup information for one app, used by the backup page template.
type AppBackupRow struct {
StackName string
DisplayName string
Status string // "green", "yellow", "red", "auto"
StatusText string // short Hungarian tooltip
if s.backupMgr == nil {
http.Redirect(w, r, "/backups", http.StatusFound)
return
// Storage info (HDD apps only)
HasHDDData bool
StorageLabel string
HDDSizeHuman string
// Layer details (nil = layer not applicable)
HasDB bool
DBLastRun string // formatted time
DBLastStatus string // "ok", "error", ""
VolumeLastRun string
VolumeLastStatus string
// 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...", "—"
// Warnings accumulated for this app
Warnings []string
}
// buildAppBackupRows constructs one AppBackupRow per deployed app for the unified backup page.
func (s *Server) buildAppBackupRows(
status *backup.FullBackupStatus,
crossConfigs map[string]*settings.CrossDriveBackup,
destLabels map[string]string,
) []AppBackupRow {
loc, _ := time.LoadLocation("Europe/Budapest")
// Build a quick lookup: which stacks have a DB dump?
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
}
// Get current app data info to know which stacks have HDD data
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
prefs := make(map[string]bool)
for _, app := range fullStatus.AppDataInfo {
if app.HasHDDData {
prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on"
// Determine last restic run time for volume backup display
volumeLastRun := ""
volumeLastStatus := ""
if status.LastBackup != nil {
volumeLastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
if status.LastBackup.Success {
volumeLastStatus = "ok"
} else {
volumeLastStatus = "error"
}
}
if err := s.settings.SetAppBackupBulk(prefs); err != nil {
s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err)
http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound)
return
// DB dump last run
dbLastRun := ""
dbLastStatus := ""
if status.LastDBDump != nil {
dbLastRun = status.LastDBDump.LastRun.In(loc).Format("01-02 15:04")
if status.LastDBDump.Success {
dbLastStatus = "ok"
} else {
dbLastStatus = "error"
}
}
s.logger.Printf("[INFO] App backup preferences updated: %v", prefs)
var rows []AppBackupRow
for _, app := range status.AppDataInfo {
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,
}
// Trigger cache refresh so the page shows updated data
go s.backupMgr.RefreshCache(nextDBDump, nextBackup)
// Default status = green/auto
row.Status = "auto"
row.StatusText = "Automatikus mentés"
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
if app.HasHDDData {
row.HasUserData = true
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil || !cfg.Enabled {
// HDD data but no cross-drive configured → RED
row.UserDataConfigured = false
row.Status = "red"
row.StatusText = "Felhasználói adatokról nincs mentés"
} else {
row.UserDataConfigured = true
row.UserDataMethod = cfg.Method
row.UserDataDest = destLabels[cfg.DestinationPath]
if row.UserDataDest == "" {
row.UserDataDest = cfg.DestinationPath
}
switch cfg.Schedule {
case "daily":
row.UserDataSchedule = "Naponta"
case "weekly":
row.UserDataSchedule = "Hetente (vasárnap)"
default:
row.UserDataSchedule = 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.UserDataLastStatus = cfg.LastStatus
row.UserDataLastError = cfg.LastError
switch cfg.LastStatus {
case "ok":
row.UserDataStatusBadge = "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.Status = "yellow"
row.StatusText = "Utolsó mentés sikertelen"
} else {
row.Status = "green"
row.StatusText = "Mentés rendben"
}
}
} else {
// No HDD data — fully automatic
row.Status = "auto"
row.StatusText = "Automatikus mentés (nincs felhasználói adat)"
}
// 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"
}
rows = append(rows, row)
}
return rows
}
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
@@ -532,7 +689,7 @@ func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Reque
if method != "rsync" && method != "restic" {
method = "rsync"
}
if schedule != "daily" && schedule != "weekly" && schedule != "manual" {
if schedule != "daily" && schedule != "weekly" {
schedule = "daily"
}
} else if existing != nil {
-2
View File
@@ -110,8 +110,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.settingsStorageSchedulableHandler(w, r)
case path == "/settings/storage/label" && r.Method == http.MethodPost:
s.settingsStorageLabelHandler(w, r)
case path == "/settings/app-backup" && r.Method == http.MethodPost:
s.settingsAppBackupHandler(w, r)
case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
name := strings.TrimPrefix(path, "/settings/cross-backup/")
s.settingsCrossBackupHandler(w, r, name)
+123 -64
View File
@@ -233,84 +233,109 @@
{{end}}
</div>
<!-- Section 4: App data backup status (read-only) -->
<!-- Section 4: Unified per-app backup status -->
{{if .Backup.AppDataInfo}}
<div class="backup-section-card">
<h3>Alkalmazás adatok</h3>
<p class="backup-section-desc">Az alkalmazások felhasználói adatainak mentési állapota. Beállítás az alkalmazás oldalán.</p>
<div class="app-backup-list">
{{range .Backup.AppDataInfo}}
<div class="app-backup-item">
<div class="app-backup-header">
<a href="/stacks/{{.StackName}}/deploy" class="app-backup-name-link">{{.DisplayName}}</a>
<div class="app-backup-status-row">
{{if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
{{if .BackupEnabled}}
<span class="app-backup-size mono">{{.HDDSizeHuman}}</span>
<span class="app-backup-status app-backup-active">Aktív</span>
{{else}}
<span class="app-backup-status app-backup-inactive">Inaktív</span>
<h3>Alkalmazások mentési állapota</h3>
{{if .NoUserDataBackupWarning}}
<div class="alert alert-error" style="margin-bottom:1.5rem">
<strong>Felhasználói adatokról nincs biztonsági mentés.</strong><br>
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.
<a href="/settings" style="color:inherit;text-decoration:underline">Meghajtó beállítása →</a>
</div>
{{end}}
{{range .AppBackupRows}}
<div class="app-backup-row" data-status="{{.Status}}">
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
<span class="app-backup-row-name">{{.DisplayName}}</span>
<div class="app-backup-row-meta">
{{if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
{{else}}
<span class="meta-badge">Auto</span>
{{end}}
</div>
<span class="expand-icon"></span>
</div>
<div class="app-backup-row-detail" style="display:none">
<div class="backup-layers">
<!-- DB layer -->
<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="app-backup-status app-backup-na">N/A</span>
<span class="layer-na">— (nincs adatbázis)</span>
{{end}}
</div>
<!-- Volume layer -->
<div class="backup-layer-row">
<span class="layer-label">Docker kötetek</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>
{{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-unconfigured">⚠ Nincs beállítva</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>
{{end}}
</div>
</div>
{{if .Warnings}}
<div class="layer-warnings">
{{range .Warnings}}
<div class="backup-layer-warning">{{.}}</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
<!-- Section 4b: Cross-drive backups -->
{{if or .Backup.CrossDriveSummary .Backup.UnconfiguredApps}}
<div class="backup-section-card">
<h3>Másolatok másik meghajtóra</h3>
<p class="backup-section-desc">Alkalmazás adatok biztonsági másolata külső meghajtóra (3-2-1 szabály).</p>
{{if .Backup.CrossDriveWarnings}}
<div style="margin-bottom:1rem">
{{range .Backup.CrossDriveWarnings}}
<div class="alert alert-warning" style="margin-bottom:.5rem">{{.}}</div>
{{end}}
</div>
{{end}}
{{if .Backup.CrossDriveSummary}}
<div class="cross-drive-list" style="margin-bottom:1rem">
{{range .Backup.CrossDriveSummary}}
<div class="cross-drive-item">
<div class="cross-drive-header">
<a href="/stacks/{{.StackName}}/deploy" class="cross-drive-name">{{.DisplayName}}</a>
<div class="cross-drive-meta">
<span class="meta-badge">{{.MethodLabel}}</span>
{{if .DestLabel}}<span class="meta-badge meta-badge-storage">→ {{.DestLabel}}</span>
{{else if .DestPath}}<span class="meta-badge meta-badge-storage">→ {{.DestPath}}</span>{{end}}
{{if eq .LastStatus "ok"}}<span class="meta-badge meta-badge-ok">{{.LastRunShort}}</span>
{{else if eq .LastStatus "error"}}<span class="meta-badge meta-badge-fail">Hiba</span>
{{else if eq .LastStatus "running"}}<span class="meta-badge">Fut...</span>
{{else}}<span class="meta-badge" style="color:var(--text-muted)">{{.ScheduleLabel}}</span>{{end}}
{{if .SizeHuman}}<span class="mono" style="font-size:.8rem;color:var(--text-muted)">{{.SizeHuman}}</span>{{end}}
</div>
</div>
</div>
{{end}}
<div class="cross-drive-actions" style="margin-top:1rem">
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes HDD mentés futtatása most</button>
</div>
{{end}}
{{if .Backup.UnconfiguredApps}}
<div style="font-size:.85rem;color:var(--yellow);margin-bottom:1rem">
{{len .Backup.UnconfiguredApps}} alkalmazáshoz nincs beállítva:
{{range .Backup.UnconfiguredApps}}
<a href="/stacks/{{.StackName}}/deploy" style="color:var(--accent-blue)">{{.DisplayName}}</a>
{{end}}
</div>
{{end}}
<div class="cross-drive-actions">
<button class="btn btn-sm btn-primary" onclick="triggerAllCrossDrive(this)">Összes futtatása most</button>
</div>
</div>
{{end}}
@@ -463,6 +488,40 @@
{{end}}
<script>
function toggleBackupDetail(header) {
var detail = header.nextElementSibling;
var icon = header.querySelector('.expand-icon');
if (detail.style.display === 'none') {
detail.style.display = 'block';
icon.textContent = '▼';
} else {
detail.style.display = 'none';
icon.textContent = '▶';
}
}
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Fut...';
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
alert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Futtatás most';
return;
}
btn.textContent = 'Fut...';
setTimeout(function() { location.reload(); }, 5000);
})
.catch(function(e) {
alert('Hálózati hiba: ' + e.message);
btn.disabled = false;
btn.textContent = 'Futtatás most';
});
}
function triggerAllCrossDrive(btn) {
btn.disabled = true;
btn.textContent = 'Indítás...';
+29 -24
View File
@@ -98,26 +98,18 @@
<h4>Biztonsági mentés</h4>
<div class="cross-drive-nightly">
<div class="cross-drive-nightly-status">
{{if .AppBackupEnabled}}
<span class="nightly-status-indicator nightly-enabled"></span>
{{else}}
<span class="nightly-status-indicator nightly-disabled"></span>
{{end}}
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
</div>
<span class="form-hint" style="display:block;margin-top:.25rem">
Az alkalmazás adatai bekerülnek az éjszakai biztonsági mentésbe.
<a href="/backups" style="color:var(--accent-blue)">Beállítás a mentési oldalon</a>
Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
<a href="/backups" style="color:var(--accent-blue)">Mentési állapot →</a>
</span>
</div>
<hr style="border-color:var(--border);margin:1rem 0">
<p style="font-weight:500;margin-bottom:1rem">Másolat másik meghajtóra:</p>
<p style="font-weight:500;margin-bottom:1rem">Másolat másik meghajtóra (felhasználói adatok):</p>
{{if .BackupDestWarning}}
<div class="alert alert-warning" style="margin-bottom:1rem">{{.BackupDestWarning}}</div>
<div class="alert {{if eq .BackupDestWarningSeverity "critical"}}alert-error{{else}}alert-warning{{end}}" style="margin-bottom:1rem">{{.BackupDestWarning}}</div>
{{end}}
{{if not .BackupDestPaths}}
@@ -177,18 +169,23 @@
</div>
<div class="settings-row">
<span class="settings-label">Ütemezés</span>
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
Naponta (03:30)
</option>
<option value="weekly" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")}}selected{{end}}>
Hetente (vasárnap 04:30)
</option>
<option value="manual" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual")}}selected{{end}}>
Csak kézi indítás
</option>
</select>
<div>
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}
onchange="onScheduleChange()">
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
Naponta (az éjszakai mentés után)
</option>
<option value="weekly" {{if or (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")) (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual"))}}selected{{end}}>
Hetente, vasárnap (az éjszakai mentés után)
</option>
</select>
<div id="weekly-note" class="form-hint" style="margin-top:.5rem;display:{{if and .CrossDriveConfig (or (eq .CrossDriveConfig.Schedule "weekly") (eq .CrossDriveConfig.Schedule "manual"))}}block{{else}}none{{end}}">
Heti mentés esetén visszaállításkor az adatbázis is a mentés napjára áll vissza
a konzisztencia érdekében. A mentés napja és a visszaállítás között keletkezett
adatbázis-változások elvesznek (max. 7 nap).
</div>
</div>
</div>
</div>
@@ -382,6 +379,14 @@ function toggleCrossDriveFields() {
}
}
function onScheduleChange() {
var sel = document.getElementById('cd-schedule');
var note = document.getElementById('weekly-note');
if (sel && note) {
note.style.display = sel.value === 'weekly' ? 'block' : 'none';
}
}
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Mentés folyamatban...';
+137
View File
@@ -2436,3 +2436,140 @@ a.stat-card:hover {
background: var(--red-bg);
color: var(--red);
}
/* ─── Unified App Backup Rows ──────────────────────────────────────── */
.app-backup-row {
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: .5rem;
background: var(--bg-secondary);
overflow: hidden;
}
.app-backup-row[data-status="red"] { border-left: 3px solid var(--red); }
.app-backup-row[data-status="yellow"] { border-left: 3px solid var(--yellow); }
.app-backup-row[data-status="green"] { border-left: 3px solid var(--green); }
.app-backup-row[data-status="auto"] { border-left: 3px solid var(--text-muted); }
.app-backup-row-header {
display: flex;
align-items: center;
gap: .75rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
}
.app-backup-row-header:hover {
background: var(--bg-hover, rgba(255,255,255,0.03));
}
.app-backup-row-name {
font-weight: 500;
font-size: .9rem;
flex: 1;
}
.app-backup-row-meta {
display: flex;
align-items: center;
gap: .5rem;
}
.expand-icon {
color: var(--text-muted);
font-size: .75rem;
flex-shrink: 0;
}
/* Status dot */
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.status-dot.status-green { background: var(--green); box-shadow: 0 0 0 2px rgba(76,175,80,.2); }
.status-dot.status-yellow { background: var(--yellow); box-shadow: 0 0 0 2px rgba(255,193,7,.2); }
.status-dot.status-red { background: var(--red); box-shadow: 0 0 0 2px rgba(244,67,54,.2); }
.status-dot.status-auto { background: var(--text-muted); opacity: .6; }
/* Expanded detail */
.app-backup-row-detail {
border-top: 1px solid var(--border-color);
padding: .75rem 1rem;
}
.backup-layers {
display: flex;
flex-direction: column;
gap: .4rem;
}
.backup-layer-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .4rem;
font-size: .85rem;
padding: .2rem 0;
}
.backup-layer-row.layer-row-na {
opacity: .55;
}
.layer-label {
font-weight: 500;
min-width: 14rem;
color: var(--text-secondary);
}
.layer-badge {
background: rgba(0,136,204,.1);
color: var(--accent-light);
padding: .1rem .45rem;
border-radius: 3px;
font-size: .75rem;
}
.layer-na {
color: var(--text-muted);
font-style: italic;
font-size: .82rem;
}
.layer-method {
color: var(--text-secondary);
font-size: .82rem;
}
.layer-dest {
color: var(--text-secondary);
font-size: .82rem;
}
.layer-schedule {
color: var(--text-muted);
font-size: .8rem;
}
.layer-last {
color: var(--text-muted);
font-size: .8rem;
margin-left: .25rem;
}
.layer-unconfigured {
color: var(--yellow);
font-weight: 500;
font-size: .85rem;
}
.layer-actions {
display: flex;
gap: .35rem;
margin-left: auto;
}
.btn-xs {
padding: .15rem .5rem;
font-size: .75rem;
border-radius: 3px;
}
.layer-warnings {
margin-top: .5rem;
padding-top: .5rem;
border-top: 1px solid var(--border-color);
}
.backup-layer-warning {
font-size: .82rem;
color: var(--yellow);
padding: .2rem 0;
}
.text-ok { color: var(--green); }
.text-error { color: var(--red); }
.text-muted { color: var(--text-muted); }