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:
@@ -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">🛡</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" .}}
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user