Phase 3 complete: per-app backup toggles, restore, storage overview

- Storage overview on backup page (SSD/HDD bars, repo stats)
- Restic password visibility + hub sync for disaster recovery
- App data discovery (HDD bind mounts, Docker volumes)
- Per-app backup toggle checkboxes with settings persistence
- Dynamic backup paths: enabled app HDD data included in restic snapshots
- Limited app restore from snapshots (self-service recovery)
- Snapshots API endpoint for restore dropdown
- Version bump to 0.8.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 21:29:56 +01:00
parent a3af7c6a2d
commit 7d801d1094
15 changed files with 1088 additions and 29 deletions
+261 -2
View File
@@ -6,6 +6,13 @@
<span class="domain-badge">{{.Domain}}</span>
</div>
{{if .Backup}}{{if .Backup.FlashSuccess}}
<div class="flash flash-success">{{.Backup.FlashSuccess}}</div>
{{end}}{{end}}
{{if .Backup}}{{if .Backup.FlashError}}
<div class="flash flash-error">{{.Backup.FlashError}}</div>
{{end}}{{end}}
{{if not .Backup}}
<div class="backup-empty-state">
<div class="backup-empty-icon">&#128737;</div>
@@ -15,6 +22,53 @@
</div>
{{else}}
<!-- Section 0: Storage overview -->
<div class="backup-section-card">
<h3>Tárhely áttekintés</h3>
<div class="storage-overview-grid">
<div class="storage-bars">
{{with $.SystemInfo}}
<div class="storage-item">
<div class="storage-header">
<span class="storage-label">SSD (/)</span>
<span class="storage-value">{{fmtGB .DiskUsedGB}} / {{fmtGB .DiskTotalGB}} ({{printf "%.0f" .DiskPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill {{usageColor .DiskPercent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .DiskPercent}}%"></div>
</div>
</div>
{{if .HDDConfigured}}
<div class="storage-item">
<div class="storage-header">
<span class="storage-label">Külső HDD</span>
<span class="storage-value">{{fmtGB .HDDUsedGB}} / {{fmtGB .HDDTotalGB}} ({{printf "%.0f" .HDDPercent}}%)</span>
</div>
<div class="system-bar">
<div class="system-bar-fill {{usageColor .HDDPercent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .HDDPercent}}%"></div>
</div>
</div>
{{end}}
{{end}}
</div>
<div class="storage-stats">
{{if .Backup.RepoStats}}
<div class="storage-stat-row">
<span class="storage-stat-label">Mentési tároló</span>
<span class="storage-stat-value mono">{{.Backup.RepoStats.TotalSize}}</span>
</div>
{{end}}
<div class="storage-stat-row">
<span class="storage-stat-label">DB mentések</span>
<span class="storage-stat-value mono">{{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} fájl{{else}}{{end}}</span>
</div>
<div class="storage-stat-row">
<span class="storage-stat-label">Pillanatképek</span>
<span class="storage-stat-value mono">{{if .Backup.RepoStats}}{{.Backup.RepoStats.SnapshotCount}}{{else}}0{{end}}</span>
</div>
</div>
</div>
</div>
<!-- Section 1: Status overview cards -->
<div class="stats-grid backup-page-cards">
{{if .Backup.LastBackup}}
@@ -179,7 +233,56 @@
{{end}}
</div>
<!-- Section 4: Snapshots -->
<!-- Section 4: App data backup toggles -->
{{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 biztonsági mentése.</p>
<form method="POST" action="/settings/app-backup">
<div class="app-backup-list">
{{range .Backup.AppDataInfo}}
<div class="app-backup-item">
<div class="app-backup-header">
{{if .HasHDDData}}
<label class="app-backup-toggle">
<input type="checkbox" name="backup_{{.StackName}}" value="on" {{if .BackupEnabled}}checked{{end}}>
<span class="app-backup-name">{{.DisplayName}}</span>
</label>
{{else}}
<div class="app-backup-toggle">
<span class="app-backup-disabled-icon"></span>
<span class="app-backup-name">{{.DisplayName}}</span>
</div>
{{end}}
{{if .HasHDDData}}
<span class="app-backup-size mono">{{.HDDSizeHuman}} (HDD)</span>
{{end}}
</div>
<div class="app-backup-details">
{{range .HDDPaths}}
<div class="app-backup-path mono">{{.HostPath}} {{if .Exists}}({{.SizeHuman}}){{else}}<span class="relative-time">(nem létezik)</span>{{end}}</div>
{{end}}
{{range .DockerVolumes}}
<div class="app-backup-volume">Docker kötet: {{.Name}} <span class="relative-time">(nem mentett)</span></div>
{{end}}
{{if .HasDBDump}}
<div class="app-backup-dbinfo">Adatbázis mentés naponta (DB dump)</div>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="app-backup-actions">
<button type="submit" class="btn btn-sm btn-primary">Mentés</button>
</div>
<div class="app-backup-notice">
<span class="relative-time">Docker kötetek mentése jelenleg nem támogatott. Az adatbázisokat az automatikus DB dump menti naponta.</span>
</div>
</form>
</div>
{{end}}
<!-- Section 5: Snapshots -->
<div class="backup-section-card">
<h3>Pillanatképek</h3>
{{if .Backup.SnapshotHistory}}
@@ -216,7 +319,7 @@
{{end}}
</div>
<!-- Section 5: Repository -->
<!-- Section 6: Repository -->
<div class="repo-card">
<h3>Tároló</h3>
<div class="repo-info-rows">
@@ -247,6 +350,22 @@
</span>
</div>
</div>
<!-- Encryption key -->
{{if $.ResticPassword}}
<div class="repo-encryption">
<span class="repo-label">Titkosítási kulcs:</span>
<div class="repo-encryption-row">
<input type="password" id="restic-pw" class="restic-pw-field mono" value="{{$.ResticPassword}}" readonly>
<button type="button" class="btn btn-sm" onclick="toggleResticPw()">Megjelenítés</button>
<button type="button" class="btn btn-sm" onclick="copyResticPw()">Másolás</button>
</div>
<div class="repo-encryption-warn">
Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza.
</div>
</div>
{{end}}
<div class="repo-paths">
<span class="repo-label">Mentett útvonalak:</span>
<ul class="repo-path-list">
@@ -264,6 +383,51 @@
</div>
</div>
<!-- Section 7: Restore -->
{{if .Backup.AppDataInfo}}
<div class="backup-section-card">
<h3>Visszaállítás</h3>
<div class="restore-section">
<div class="restore-form-row">
<label class="restore-label">Alkalmazás:</label>
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
<option value="">— Válasszon —</option>
{{range .Backup.AppDataInfo}}
{{if and .HasHDDData .BackupEnabled}}
<option value="{{.StackName}}" data-paths="{{range $i, $p := .HDDPaths}}{{if $i}},{{end}}{{$p.HostPath}}{{end}}">{{.DisplayName}}</option>
{{end}}
{{end}}
</select>
</div>
<div class="restore-form-row">
<label class="restore-label">Pillanatkép:</label>
<select id="restore-snapshot" class="restore-select">
<option value="">— Betöltés... —</option>
</select>
</div>
<div id="restore-paths" class="restore-paths" style="display:none;">
<span class="restore-label">Visszaállítandó útvonalak:</span>
<ul id="restore-paths-list" class="repo-path-list"></ul>
</div>
<div class="restore-warning">
<strong>FIGYELMEZTETÉS</strong><br>
A visszaállítás FELÜLÍRJA a kiválasztott alkalmazás jelenlegi adatait a mentés pillanatának állapotával.
Ez a művelet NEM vonható vissza!<br>
Javasoljuk az alkalmazás leállítását a visszaállítás előtt.
</div>
<div class="restore-confirm">
<label>
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
Megértettem, visszaállítás saját felelősségre.
</label>
</div>
<div class="restore-actions">
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">Visszaállítás indítása</button>
</div>
</div>
</div>
{{end}}
{{end}}
<script>
@@ -303,10 +467,105 @@ function startBackupPolling() {
}, 3000);
}
// Restic password toggle/copy
function toggleResticPw() {
var el = document.getElementById('restic-pw');
el.type = el.type === 'password' ? 'text' : 'password';
}
function copyResticPw() {
var el = document.getElementById('restic-pw');
navigator.clipboard.writeText(el.value).then(function() {
var btn = event.target;
btn.textContent = 'Másolva!';
setTimeout(function() { btn.textContent = 'Másolás'; }, 2000);
});
}
// Restore section
function onRestoreAppChange() {
var sel = document.getElementById('restore-app');
var opt = sel.options[sel.selectedIndex];
var pathsDiv = document.getElementById('restore-paths');
var pathsList = document.getElementById('restore-paths-list');
if (!opt.value) {
pathsDiv.style.display = 'none';
return;
}
var paths = (opt.getAttribute('data-paths') || '').split(',').filter(Boolean);
pathsList.innerHTML = '';
paths.forEach(function(p) {
var li = document.createElement('li');
li.className = 'mono';
li.textContent = p;
pathsList.appendChild(li);
});
pathsDiv.style.display = 'block';
// Load snapshots
fetch('/api/backup/snapshots')
.then(function(r) { return r.json(); })
.then(function(data) {
var snapSel = document.getElementById('restore-snapshot');
snapSel.innerHTML = '<option value="">— Válasszon —</option>';
if (data.ok && data.data) {
data.data.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
var t = new Date(s.time);
o.textContent = s.short_id + ' — ' + t.toLocaleString('hu-HU');
snapSel.appendChild(o);
});
}
});
document.getElementById('restore-confirm-cb').checked = false;
document.getElementById('restore-btn').disabled = true;
}
function onRestoreConfirmChange() {
var cb = document.getElementById('restore-confirm-cb');
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
document.getElementById('restore-btn').disabled = !(cb.checked && app && snap);
}
function submitRestore() {
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
if (!app || !snap) return;
var btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = 'Visszaállítás folyamatban...';
var form = document.createElement('form');
form.method = 'POST';
form.action = '/backup/restore';
var f1 = document.createElement('input');
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
form.appendChild(f1);
var f2 = document.createElement('input');
f2.type = 'hidden'; f2.name = 'snapshot_id'; f2.value = snap;
form.appendChild(f2);
document.body.appendChild(form);
form.submit();
}
// Auto-poll if backup is already running on page load
{{if .Backup}}{{if .Backup.Running}}
startBackupPolling();
{{end}}{{end}}
// Wire up snapshot selection change for restore confirm
document.addEventListener('DOMContentLoaded', function() {
var snapSel = document.getElementById('restore-snapshot');
if (snapSel) snapSel.addEventListener('change', onRestoreConfirmChange);
});
</script>
{{template "layout_end" .}}
+211
View File
@@ -1769,6 +1769,213 @@ a.stat-card:hover {
border-left: 3px solid var(--accent-blue);
}
/* --- Backup page: Storage overview grid --- */
.storage-overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
.storage-stats {
display: flex;
flex-direction: column;
gap: .5rem;
}
.storage-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: .3rem 0;
font-size: .85rem;
}
.storage-stat-label {
color: var(--text-secondary);
}
.storage-stat-value {
color: var(--text-primary);
}
/* --- Backup page: App backup toggles --- */
.backup-section-desc {
color: var(--text-secondary);
font-size: .85rem;
margin-bottom: 1rem;
}
.app-backup-list {
display: flex;
flex-direction: column;
gap: .5rem;
margin-bottom: 1rem;
}
.app-backup-item {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: .75rem 1rem;
}
.app-backup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: .25rem;
}
.app-backup-toggle {
display: flex;
align-items: center;
gap: .5rem;
cursor: pointer;
}
.app-backup-toggle input[type="checkbox"] {
accent-color: var(--accent-blue);
}
.app-backup-name {
font-weight: 500;
font-size: .9rem;
}
.app-backup-disabled-icon {
color: var(--text-muted);
font-size: .9rem;
width: 16px;
text-align: center;
}
.app-backup-size {
font-size: .8rem;
color: var(--text-muted);
}
.app-backup-details {
padding-left: 1.5rem;
}
.app-backup-path {
font-size: .8rem;
color: var(--text-secondary);
padding: .1rem 0;
}
.app-backup-volume {
font-size: .8rem;
color: var(--text-muted);
padding: .1rem 0;
}
.app-backup-dbinfo {
font-size: .8rem;
color: var(--text-muted);
padding: .1rem 0;
}
.app-backup-actions {
margin-top: .75rem;
}
.app-backup-notice {
margin-top: .75rem;
font-size: .8rem;
}
/* --- Backup page: Encryption key --- */
.repo-encryption {
border-top: 1px solid var(--border-color);
padding-top: .75rem;
margin-bottom: .75rem;
}
.repo-encryption-row {
display: flex;
align-items: center;
gap: .5rem;
margin-top: .5rem;
margin-bottom: .5rem;
}
.restic-pw-field {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: .4rem .6rem;
color: var(--text-primary);
font-size: .85rem;
width: 340px;
max-width: 100%;
}
.repo-encryption-warn {
font-size: .8rem;
color: var(--yellow);
line-height: 1.4;
}
/* --- Backup page: Restore section --- */
.restore-section {
display: flex;
flex-direction: column;
gap: .75rem;
}
.restore-form-row {
display: flex;
align-items: center;
gap: .75rem;
}
.restore-label {
font-size: .85rem;
color: var(--text-secondary);
min-width: 110px;
}
.restore-select {
flex: 1;
max-width: 400px;
padding: .4rem .6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: .85rem;
font-family: inherit;
}
.restore-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.restore-paths {
padding: .5rem 0;
}
.restore-warning {
background: var(--red-bg);
border: 1px solid rgba(218, 54, 51, 0.3);
border-radius: 8px;
padding: .75rem 1rem;
font-size: .85rem;
color: var(--red);
line-height: 1.5;
}
.restore-confirm {
font-size: .85rem;
color: var(--text-secondary);
}
.restore-confirm label {
display: flex;
align-items: center;
gap: .5rem;
cursor: pointer;
}
.restore-confirm input[type="checkbox"] {
accent-color: var(--red);
}
.restore-actions {
padding-top: .25rem;
}
/* --- Flash messages --- */
.flash {
padding: .75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: .85rem;
border: 1px solid;
}
.flash-success {
background: var(--green-bg);
color: var(--green);
border-color: rgba(35, 134, 54, 0.3);
}
.flash-error {
background: var(--red-bg);
color: var(--red);
border-color: rgba(218, 54, 51, 0.3);
}
/* Responsive */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
@@ -1786,4 +1993,8 @@ a.stat-card:hover {
.charts-grid { grid-template-columns: 1fr; }
.container-charts-row { flex-direction: column; }
.sysinfo-grid { grid-template-columns: 1fr; }
.storage-overview-grid { grid-template-columns: 1fr; }
.restore-form-row { flex-direction: column; align-items: stretch; }
.restore-label { min-width: auto; }
.restore-select { max-width: 100%; }
}