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:
@@ -1,5 +1,16 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### What was just completed (2026-02-17 session 37)
|
||||||
|
- **v0.12.0 — Backup Page Overhaul — Unified App Backup Status & Bug Fixes:**
|
||||||
|
- **Bug Fix 1: Duplicate unconfigured apps** — `GetFullStatus()` now returns a deep copy of the cached status. `CrossDriveSummary`, `UnconfiguredApps`, and `CrossDriveWarnings` slices are always nil in the returned copy so the handler builds them fresh on every page load. Previously the handler appended to the cached slices, causing 3× duplication on 3 page loads.
|
||||||
|
- **Bug Fix 2: Misleading "drive disconnected" error** — Replaced the binary `IsMountPoint || !IsWritable` check with tiered `CheckBackupDestination()` validation (new in `internal/system/mounts_linux.go` and stub in `mounts_other.go`). Tiers: path doesn't exist (critical/blocked), not writable (critical/blocked), same block device as `/` (warning/allowed with note about system drive), disk >95% full (critical/blocked), disk >90% (warning/allowed). `isSameBlockDevice()` replaces `IsMountPoint()` for source/dest same-device detection. Used in both `deployHandler()` and `backupsHandler()` for display, and in `crossdrive.go` logic via `CheckBackupDestination()`.
|
||||||
|
- **Bug Fix 3: Dead BackupEnabled toggle** — Removed `settingsAppBackupHandler()` from handlers.go and its `POST /settings/app-backup` route from server.go. The toggle wrote to settings.json but nothing read it to skip apps. UI nightly backup section in deploy.html now shows an informational note instead of the toggle.
|
||||||
|
- **Architecture: Unified per-app backup rows** — New `AppBackupRow` struct and `buildAppBackupRows()` in handlers.go. Replaces old "Alkalmazás adatok" + "Másolatok másik meghajtóra" sections with a single expandable row per app showing all 3 backup layers (DB, Docker volumes, user data). Status dot: green=fully covered, yellow=warning (failed run, system drive, disk full), red=HDD data without cross-drive configured, auto=no user data. Expandable JS toggle with ▶/▼ icon.
|
||||||
|
- **Architecture: Sequential backup chaining** — Removed independent `cross-drive-daily` (03:30) and `cross-drive-weekly` (04:30) scheduler jobs. Cross-drive backups now run immediately after the restic backup completes (daily jobs every night; weekly jobs on Sunday). This ensures DB dump → restic → cross-drive happen in the same window for file/DB consistency on restore.
|
||||||
|
- **Architecture: Deploy page schedule dropdown** — Removed "Csak kézi indítás" option (schedule="manual"). Two options remain: "Naponta (az éjszakai mentés után)" and "Hetente, vasárnap (az éjszakai mentés után)". Weekly option shows informational note about DB consistency implications. Existing "manual" configs treated as "weekly" in the dropdown.
|
||||||
|
- **CSS added:** `.app-backup-row`, `.app-backup-row-header`, `.app-backup-row-name`, `.app-backup-row-meta`, `.app-backup-row-detail`, `.status-dot` (green/yellow/red/auto), `.backup-layers`, `.backup-layer-row`, `.layer-label`, `.layer-badge`, `.layer-na`, `.layer-method`, `.layer-dest`, `.layer-schedule`, `.layer-last`, `.layer-unconfigured`, `.layer-actions`, `.layer-warnings`, `.backup-layer-warning`, `.btn-xs`, `.text-ok`, `.text-error`.
|
||||||
|
- **Files modified (9):** `internal/backup/backup.go`, `internal/system/mounts_linux.go`, `internal/system/mounts_other.go`, `internal/web/handlers.go`, `internal/web/server.go`, `internal/web/templates/backups.html`, `internal/web/templates/deploy.html`, `internal/web/templates/style.css`, `cmd/controller/main.go`
|
||||||
|
|
||||||
### What was just completed (2026-02-17 session 36)
|
### What was just completed (2026-02-17 session 36)
|
||||||
- **v0.11.9 — UI Polish Fixes for deploy/settings backup section:**
|
- **v0.11.9 — UI Polish Fixes for deploy/settings backup section:**
|
||||||
- **Fix 1: Spacing** — `.deploy-cross-drive` `margin-bottom` increased from `1rem` to `1.5rem` for consistent spacing before deploy form.
|
- **Fix 1: Spacing** — `.deploy-cross-drive` `margin-bottom` increased from `1rem` to `1.5rem` for consistent spacing before deploy form.
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
notifier.NotifyBackupFailed("Biztonsági mentés sikertelen", err.Error())
|
notifier.NotifyBackupFailed("Biztonsági mentés sikertelen", err.Error())
|
||||||
}
|
}
|
||||||
|
// Phase 3: Chain cross-drive backups immediately after restic (regardless of restic success)
|
||||||
|
// Daily jobs run every night; weekly jobs only on Sunday
|
||||||
|
if crossDriveRunner != nil {
|
||||||
|
if cdErr := crossDriveRunner.RunAllScheduled(ctx, "daily"); cdErr != nil {
|
||||||
|
logger.Printf("[WARN] Cross-drive daily backup had errors: %v", cdErr)
|
||||||
|
}
|
||||||
|
if time.Now().Weekday() == time.Sunday {
|
||||||
|
if cdErr := crossDriveRunner.RunAllScheduled(ctx, "weekly"); cdErr != nil {
|
||||||
|
logger.Printf("[WARN] Cross-drive weekly backup had errors: %v", cdErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -216,18 +228,6 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-drive backup — daily at 03:30 (after main backup at 03:00)
|
|
||||||
sched.Daily("cross-drive-daily", "03:30", func(ctx context.Context) error {
|
|
||||||
return crossDriveRunner.RunAllScheduled(ctx, "daily")
|
|
||||||
})
|
|
||||||
// Cross-drive weekly — Sunday 04:30 (after integrity check at 04:00)
|
|
||||||
sched.Daily("cross-drive-weekly", "04:30", func(ctx context.Context) error {
|
|
||||||
if time.Now().Weekday() != time.Sunday {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return crossDriveRunner.RunAllScheduled(ctx, "weekly")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Metrics prune — daily at 04:00
|
// Metrics prune — daily at 04:00
|
||||||
if metricsStore != nil {
|
if metricsStore != nil {
|
||||||
sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error {
|
sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -585,29 +585,43 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
|||||||
|
|
||||||
// GetFullStatus returns the cached backup status for page rendering.
|
// GetFullStatus returns the cached backup status for page rendering.
|
||||||
// Returns instantly — no subprocess calls.
|
// 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 {
|
func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if m.cachedStatus != nil {
|
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
|
// Update dynamic fields that don't need subprocess calls
|
||||||
m.cachedStatus.Running = m.running
|
status.Running = m.running
|
||||||
m.cachedStatus.NextDBDump = nextDBDump
|
status.NextDBDump = nextDBDump
|
||||||
m.cachedStatus.NextBackup = nextBackup
|
status.NextBackup = nextBackup
|
||||||
m.cachedStatus.LastDBDump = m.lastDBDump
|
status.LastDBDump = m.lastDBDump
|
||||||
m.cachedStatus.LastBackup = m.lastBackup
|
status.LastBackup = m.lastBackup
|
||||||
// Update snapshot history
|
// Update snapshot history
|
||||||
m.cachedStatus.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||||
copy(m.cachedStatus.SnapshotHistory, m.snapshotHistory)
|
copy(status.SnapshotHistory, m.snapshotHistory)
|
||||||
// Reverse so newest first
|
// Reverse so newest first
|
||||||
for i, j := 0, len(m.cachedStatus.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(status.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]
|
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)
|
// Synthesize LastBackup from snapshot history if not in memory (e.g., after restart)
|
||||||
if m.cachedStatus.LastBackup == nil && len(m.cachedStatus.SnapshotHistory) > 0 {
|
if status.LastBackup == nil && len(status.SnapshotHistory) > 0 {
|
||||||
latest := m.cachedStatus.SnapshotHistory[0] // already reversed, newest first
|
latest := status.SnapshotHistory[0] // already reversed, newest first
|
||||||
m.cachedStatus.LastBackup = &BackupStatus{
|
status.LastBackup = &BackupStatus{
|
||||||
LastRun: latest.Time,
|
LastRun: latest.Time,
|
||||||
Success: latest.Success,
|
Success: latest.Success,
|
||||||
Snapshot: &SnapshotResult{
|
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
|
// 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 results []DumpResult
|
||||||
var latestTime time.Time
|
var latestTime time.Time
|
||||||
for _, f := range m.cachedStatus.DumpFiles {
|
for _, f := range status.DumpFiles {
|
||||||
results = append(results, DumpResult{
|
results = append(results, DumpResult{
|
||||||
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
||||||
FilePath: f.FileName,
|
FilePath: f.FileName,
|
||||||
@@ -630,14 +644,14 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
|
|||||||
latestTime = f.ModTime
|
latestTime = f.ModTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.cachedStatus.LastDBDump = &DBDumpStatus{
|
status.LastDBDump = &DBDumpStatus{
|
||||||
LastRun: latestTime,
|
LastRun: latestTime,
|
||||||
Results: results,
|
Results: results,
|
||||||
Success: true,
|
Success: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.cachedStatus
|
return &status
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cache yet — return a minimal status (first page load before cache is populated)
|
// 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
|
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.
|
// diskModel reads the disk model from /sys/block/<dev>/device/model.
|
||||||
func diskModel(device string) string {
|
func diskModel(device string) string {
|
||||||
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
|
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
|
||||||
|
|||||||
@@ -57,3 +57,26 @@ type FSInfo struct {
|
|||||||
|
|
||||||
// GetFSInfo returns nil on non-Linux.
|
// GetFSInfo returns nil on non-Linux.
|
||||||
func GetFSInfo(_ string) *FSInfo { return nil }
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -232,19 +232,14 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
}
|
}
|
||||||
data["BackupDestPaths"] = destPaths
|
data["BackupDestPaths"] = destPaths
|
||||||
|
|
||||||
// Destination health warning
|
// Destination health warning (tiered validation)
|
||||||
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
|
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
|
||||||
if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
|
health := system.CheckBackupDestination(crossCfg.DestinationPath)
|
||||||
data["BackupDestWarning"] = fmt.Sprintf(
|
if health.Warning != "" {
|
||||||
"A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
|
data["BackupDestWarning"] = health.Warning
|
||||||
crossCfg.DestinationPath,
|
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)
|
// 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)
|
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
|
||||||
|
|
||||||
// Destination health warning
|
// Destination health warning (tiered validation)
|
||||||
if cfg.Enabled && cfg.DestinationPath != "" {
|
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,
|
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
|
data["Backup"] = fullStatus
|
||||||
|
|
||||||
// Restic password for display
|
// Restic password for display
|
||||||
@@ -479,38 +497,177 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "backups", data)
|
s.render(w, "backups", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) {
|
// AppBackupRow holds all backup information for one app, used by the backup page template.
|
||||||
_ = r.ParseForm()
|
type AppBackupRow struct {
|
||||||
|
StackName string
|
||||||
|
DisplayName string
|
||||||
|
Status string // "green", "yellow", "red", "auto"
|
||||||
|
StatusText string // short Hungarian tooltip
|
||||||
|
|
||||||
if s.backupMgr == nil {
|
// Storage info (HDD apps only)
|
||||||
http.Redirect(w, r, "/backups", http.StatusFound)
|
HasHDDData bool
|
||||||
return
|
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
|
// Determine last restic run time for volume backup display
|
||||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
volumeLastRun := ""
|
||||||
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
volumeLastStatus := ""
|
||||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
if status.LastBackup != nil {
|
||||||
|
volumeLastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
|
||||||
|
if status.LastBackup.Success {
|
||||||
|
volumeLastStatus = "ok"
|
||||||
|
} else {
|
||||||
|
volumeLastStatus = "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default status = green/auto
|
||||||
|
row.Status = "auto"
|
||||||
|
row.StatusText = "Automatikus mentés"
|
||||||
|
|
||||||
prefs := make(map[string]bool)
|
|
||||||
for _, app := range fullStatus.AppDataInfo {
|
|
||||||
if app.HasHDDData {
|
if app.HasHDDData {
|
||||||
prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on"
|
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
|
||||||
if err := s.settings.SetAppBackupBulk(prefs); err != nil {
|
row.UserDataLastError = cfg.LastError
|
||||||
s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err)
|
switch cfg.LastStatus {
|
||||||
http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound)
|
case "ok":
|
||||||
return
|
row.UserDataStatusBadge = "Sikeres"
|
||||||
|
case "error":
|
||||||
|
row.UserDataStatusBadge = "Hiba"
|
||||||
|
case "running":
|
||||||
|
row.UserDataStatusBadge = "Fut..."
|
||||||
|
default:
|
||||||
|
row.UserDataStatusBadge = "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Printf("[INFO] App backup preferences updated: %v", prefs)
|
// 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)"
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger cache refresh so the page shows updated data
|
// If DB dump failed for this app, degrade to yellow (if not already red)
|
||||||
go s.backupMgr.RefreshCache(nextDBDump, nextBackup)
|
if row.HasDB && dbLastStatus == "error" && row.Status != "red" {
|
||||||
|
row.Status = "yellow"
|
||||||
|
row.StatusText = "Adatbázis mentés sikertelen"
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
|
// 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" {
|
if method != "rsync" && method != "restic" {
|
||||||
method = "rsync"
|
method = "rsync"
|
||||||
}
|
}
|
||||||
if schedule != "daily" && schedule != "weekly" && schedule != "manual" {
|
if schedule != "daily" && schedule != "weekly" {
|
||||||
schedule = "daily"
|
schedule = "daily"
|
||||||
}
|
}
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.settingsStorageSchedulableHandler(w, r)
|
s.settingsStorageSchedulableHandler(w, r)
|
||||||
case path == "/settings/storage/label" && r.Method == http.MethodPost:
|
case path == "/settings/storage/label" && r.Method == http.MethodPost:
|
||||||
s.settingsStorageLabelHandler(w, r)
|
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:
|
case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
|
||||||
name := strings.TrimPrefix(path, "/settings/cross-backup/")
|
name := strings.TrimPrefix(path, "/settings/cross-backup/")
|
||||||
s.settingsCrossBackupHandler(w, r, name)
|
s.settingsCrossBackupHandler(w, r, name)
|
||||||
|
|||||||
@@ -233,84 +233,109 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section 4: App data backup status (read-only) -->
|
<!-- Section 4: Unified per-app backup status -->
|
||||||
{{if .Backup.AppDataInfo}}
|
{{if .Backup.AppDataInfo}}
|
||||||
<div class="backup-section-card">
|
<div class="backup-section-card">
|
||||||
<h3>Alkalmazás adatok</h3>
|
<h3>Alkalmazások mentési állapota</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">
|
{{if .NoUserDataBackupWarning}}
|
||||||
{{range .Backup.AppDataInfo}}
|
<div class="alert alert-error" style="margin-bottom:1.5rem">
|
||||||
<div class="app-backup-item">
|
<strong>Felhasználói adatokról nincs biztonsági mentés.</strong><br>
|
||||||
<div class="app-backup-header">
|
A szerveren tárolt fotók, dokumentumok és egyéb fájlok jelenleg csak egy példányban léteznek.
|
||||||
<a href="/stacks/{{.StackName}}/deploy" class="app-backup-name-link">{{.DisplayName}}</a>
|
Külső meghajtó csatlakoztatásával biztonsági másolat készíthető a 3-2-1 szabály szerint.
|
||||||
<div class="app-backup-status-row">
|
<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 .HasHDDData}}
|
||||||
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
||||||
{{if .BackupEnabled}}
|
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
|
||||||
<span class="app-backup-size mono">{{.HDDSizeHuman}}</span>
|
|
||||||
<span class="app-backup-status app-backup-active">Aktív</span>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="app-backup-status app-backup-inactive">Inaktív</span>
|
<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}}
|
{{end}}
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .Warnings}}
|
||||||
|
<div class="layer-warnings">
|
||||||
|
{{range .Warnings}}
|
||||||
|
<div class="backup-layer-warning">{{.}}</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Backup.CrossDriveSummary}}
|
{{if .Backup.CrossDriveSummary}}
|
||||||
<div class="cross-drive-list" style="margin-bottom:1rem">
|
<div class="cross-drive-actions" style="margin-top:1rem">
|
||||||
{{range .Backup.CrossDriveSummary}}
|
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes HDD mentés futtatása most</button>
|
||||||
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -463,6 +488,40 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<script>
|
<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) {
|
function triggerAllCrossDrive(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Indítás...';
|
btn.textContent = 'Indítás...';
|
||||||
|
|||||||
@@ -98,26 +98,18 @@
|
|||||||
<h4>Biztonsági mentés</h4>
|
<h4>Biztonsági mentés</h4>
|
||||||
|
|
||||||
<div class="cross-drive-nightly">
|
<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">
|
<span class="form-hint" style="display:block;margin-top:.25rem">
|
||||||
Az alkalmazás adatai bekerülnek az éjszakai biztonsági mentésbe.
|
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)">Beállítás a mentési oldalon</a>
|
<a href="/backups" style="color:var(--accent-blue)">Mentési állapot →</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="border-color:var(--border);margin:1rem 0">
|
<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}}
|
{{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}}
|
{{end}}
|
||||||
|
|
||||||
{{if not .BackupDestPaths}}
|
{{if not .BackupDestPaths}}
|
||||||
@@ -177,18 +169,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<span class="settings-label">Ütemezés</span>
|
<span class="settings-label">Ütemezés</span>
|
||||||
|
<div>
|
||||||
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
|
<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}}>
|
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}
|
||||||
|
onchange="onScheduleChange()">
|
||||||
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
|
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
|
||||||
Naponta (03:30)
|
Naponta (az éjszakai mentés után)
|
||||||
</option>
|
</option>
|
||||||
<option value="weekly" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")}}selected{{end}}>
|
<option value="weekly" {{if or (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")) (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual"))}}selected{{end}}>
|
||||||
Hetente (vasárnap 04:30)
|
Hetente, vasárnap (az éjszakai mentés után)
|
||||||
</option>
|
|
||||||
<option value="manual" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual")}}selected{{end}}>
|
|
||||||
Csak kézi indítás
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
||||||
</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) {
|
function triggerCrossDriveBackup(stackName, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Mentés folyamatban...';
|
btn.textContent = 'Mentés folyamatban...';
|
||||||
|
|||||||
@@ -2436,3 +2436,140 @@ a.stat-card:hover {
|
|||||||
background: var(--red-bg);
|
background: var(--red-bg);
|
||||||
color: var(--red);
|
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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user