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
+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); }