v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule)

New feature: backup app data to a secondary storage drive to satisfy
the "different media" requirement of the 3-2-1 backup rule.

- settings.go: CrossDriveBackup struct, AppBackupPrefs.CrossDrive field,
  getter/setter methods, GetOrCreateCrossDrivePassword, preserves
  cross-drive config when toggling nightly backup

- crossdrive.go (new): CrossDriveRunner with rsync and restic backends.
  Validates destination (mount point, writable), prevents source/dest
  overlap, per-app concurrency lock, persists last_run/status/size.

- main.go: wire CrossDriveRunner, register cross-drive-daily (03:30)
  and cross-drive-weekly (04:30 Sundays) scheduler jobs

- router.go: 4 new API endpoints — save config, trigger run, get status,
  run-all. Router now accepts Settings and CrossDriveRunner.

- server.go: Server struct accepts CrossDriveRunner, new web route
  POST /settings/cross-backup/{name}

- handlers.go: deployHandler populates CrossDriveConfig, BackupDestPaths,
  BackupDestWarning, AppBackupEnabled. settingsCrossBackupHandler saves
  config. backupsHandler builds CrossDriveSummary, UnconfiguredApps,
  CrossDriveWarnings for backup page.

- deploy.html: "Biztonsági mentés" card with destination/method/schedule
  dropdowns, last-run status, manual trigger button, flash messages.

- backups.html: "Másolatok másik meghajtóra" section with per-app
  status rows, unconfigured app warnings, "Összes futtatása most" button.

- style.css: margin-bottom fix for .deploy-stale-data, new cross-drive
  card and list styles.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 15:45:31 +01:00
parent d4da3e6ea2
commit 1a8036d055
12 changed files with 1126 additions and 44 deletions
@@ -10,6 +10,8 @@
</div>
<div class="deploy-container">
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
<div class="deploy-info">
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
<div>
@@ -90,6 +92,115 @@
{{end}}
{{end}}
{{if .AlreadyDeployed}}
{{if .StorageInfo}}
<div class="deploy-cross-drive">
<h4>🔒 Biztonsági mentés</h4>
<div class="cross-drive-nightly">
<label class="toggle">
<input type="checkbox" id="app-backup-enabled" {{if .AppBackupEnabled}}checked{{end}} disabled>
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
</label>
<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>
</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>
{{if .BackupDestWarning}}
<div class="alert alert-warning" style="margin-bottom:1rem">⚠️ {{.BackupDestWarning}}</div>
{{end}}
{{if not .BackupDestPaths}}
<div class="alert alert-info">
Másik adattároló szükséges a másolat készítéséhez.
<a href="/settings" style="color:var(--accent-blue)">Csatlakoztass egy külső meghajtót a Beállítások oldalon.</a>
</div>
{{else}}
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
<div class="settings-grid" style="margin-bottom:1rem">
<div class="settings-row">
<span class="settings-label">Engedélyezve</span>
<label class="toggle" style="margin:0">
<input type="checkbox" name="cross_drive_enabled" id="cross-drive-enabled"
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}checked{{end}}>
<span class="toggle-label">Igen</span>
</label>
</div>
<div class="settings-row">
<span class="settings-label">Cél tárhely</span>
<select name="cross_drive_dest" class="form-control" style="max-width:20rem">
{{range .BackupDestPaths}}
<option value="{{.Path}}"
{{if and $.CrossDriveConfig (eq $.CrossDriveConfig.DestinationPath .Path)}}selected{{end}}>
{{.Label}} ({{.Path}}){{if .IsDefault}} ★{{end}}
{{if .FreeHuman}} — {{.FreeHuman}} szabad{{end}}
</option>
{{end}}
</select>
</div>
<div class="settings-row">
<span class="settings-label">Módszer</span>
<select name="cross_drive_method" class="form-control" style="max-width:20rem">
<option value="rsync" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "rsync")}}selected{{end}}>
Egyszerű másolat (rsync)
</option>
<option value="restic" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "restic")}}selected{{end}}>
Verziózott mentés (restic)
</option>
</select>
</div>
<div class="settings-row">
<span class="settings-label">Ütemezés</span>
<select name="cross_drive_schedule" class="form-control" style="max-width:20rem">
<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>
</div>
{{if .CrossDriveConfig}}
{{if .CrossDriveConfig.LastRun}}
<div class="form-hint" style="margin-bottom:.75rem">
Utolsó futás: {{.CrossDriveConfig.LastRun}}
{{if eq .CrossDriveConfig.LastStatus "ok"}}✅ Sikeres{{else if eq .CrossDriveConfig.LastStatus "error"}}❌ Hiba: {{.CrossDriveConfig.LastError}}{{else if eq .CrossDriveConfig.LastStatus "running"}}⏳ Fut...{{end}}
{{if .CrossDriveConfig.LastDuration}} ({{.CrossDriveConfig.LastDuration}}){{end}}
{{if .CrossDriveConfig.LastSizeHuman}} — {{.CrossDriveConfig.LastSizeHuman}}{{end}}
</div>
{{end}}
{{end}}
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button type="submit" class="btn btn-sm btn-primary">Beállítások mentése</button>
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}
<button type="button" class="btn btn-sm btn-outline"
onclick="triggerCrossDriveBackup('{{.Meta.Slug}}', this)">
Mentés most
</button>
{{end}}
</div>
</form>
<div class="form-hint" style="margin-top:.75rem;color:var(--text-muted)">
⚠️ A cél meghajtó legyen más fizikai eszköz, mint az alkalmazás adattárolója.
</div>
{{end}}
</div>
{{end}}
{{end}}
{{if and (not .AlreadyDeployed) .MemoryInfo}}
{{with .MemoryInfo}}
{{if .Available}}
@@ -242,6 +353,46 @@
</div>
<script>
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Mentés folyamatban...';
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 = 'Mentés most';
return;
}
btn.textContent = '⏳ Mentés folyamatban...';
// Poll status
var poll = setInterval(function() {
fetch('/api/stacks/' + stackName + '/cross-backup/status')
.then(function(r) { return r.json(); })
.then(function(s) {
if (!s.ok || !s.data) return;
if (!s.data.running) {
clearInterval(poll);
var status = s.data.last_status;
if (status === 'ok') {
btn.textContent = '✅ Mentés kész';
} else {
btn.textContent = '❌ Hiba';
alert('Hiba: ' + (s.data.last_error || 'Ismeretlen hiba'));
}
setTimeout(function() { location.reload(); }, 2000);
}
}).catch(function(){});
}, 3000);
})
.catch(function(e) {
alert('Hálózati hiba: ' + e.message);
btn.disabled = false;
btn.textContent = 'Mentés most';
});
}
function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn');