feat: infra backup retention + version picker

Hub: GFS retention (7d/4w/3m, ~14 versions) in new infra_backup_versions
table. Recovery endpoint supports ?version=ID. New /versions API endpoint.
Dashboard shows backup history.

Controller: local drive backups rotated into history/ (last 5 versions).
Setup wizard shows version picker for Hub restores when multiple versions
exist. Scan results enriched with app names, disk count, history badge.
Local restore supports historical versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 14:47:40 +01:00
parent 8f49bcc4cc
commit c0cdd95e56
9 changed files with 540 additions and 80 deletions
@@ -0,0 +1,56 @@
{{define "setup_hub_versions"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mentés kiválasztása — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás a Hub-ról</h1>
<p style="color: var(--text-secondary, #8b949e);">Válasszon a mentés-verziók közül.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/hub-restore/select" id="version-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="width: 2rem;"></th>
<th style="text-align: left; padding: 0.5rem;">Dátum</th>
<th style="text-align: left; padding: 0.5rem;">Alkalmazások</th>
<th style="text-align: right; padding: 0.5rem;">Lemezek</th>
</tr>
</thead>
<tbody>
{{range $i, $v := .Versions}}
<tr style="border-top: 1px solid var(--border, #30363d);">
<td style="padding: 0.5rem;">
<input type="radio" name="version_id" value="{{$v.ID}}" {{if eq $i 0}}checked{{end}}>
</td>
<td style="padding: 0.5rem;">{{$v.CreatedAt}}{{if eq $i 0}} <span class="badge badge-ok" style="font-size: 0.75em;">legújabb</span>{{end}}</td>
<td style="padding: 0.5rem;">
{{$v.StackCount}}{{if $v.StackNames}}: {{range $j, $n := $v.StackNames}}{{if $j}}, {{end}}{{$n}}{{end}}{{end}}
</td>
<td style="text-align: right; padding: 0.5rem;">{{$v.DiskCount}}</td>
</tr>
{{end}}
</tbody>
</table>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Visszaállítás</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}
@@ -33,6 +33,8 @@
<th>Ügyfél</th>
<th>Dátum</th>
<th>Verzió</th>
<th>Alkalmazások</th>
<th>Lemezek</th>
<th>Állapot</th>
</tr>
</thead>
@@ -44,6 +46,7 @@
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="source" value="local">
<input type="hidden" name="drive_path" id="selected-drive" value="">
<input type="hidden" name="history_file" id="selected-history" value="">
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
</form>
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
@@ -69,7 +72,6 @@
<script>
(function() {
var selectedDrive = '';
function poll() {
fetch('/setup/scan/status')
.then(function(r) { return r.json(); })
@@ -95,13 +97,31 @@
var validCount = 0;
data.results.forEach(function(r, i) {
var tr = document.createElement('tr');
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + r.mount_point + '" onclick="selectDrive(this)">' : '';
var driveVal = r.mount_point + '|' + (r.history_file || '');
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + driveVal + '" onclick="selectDrive(this)">' : '';
var apps = '-';
if (r.stack_count > 0) {
apps = r.stack_count.toString();
if (r.stack_names && r.stack_names.length > 0) {
var names = r.stack_names.slice(0, 3).join(', ');
if (r.stack_names.length > 3) names += ', ...';
apps += ': ' + names;
}
}
var disks = r.disk_count > 0 ? r.disk_count.toString() : '-';
var dateBadge = '';
if (r.is_history) dateBadge = ' <span class="badge" style="font-size:0.7em;background:#6e4000;color:#ffd080;">korábbi</span>';
var statusCol = r.integrity_ok
? '<span class="badge badge-ok">OK</span>'
: '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>';
tr.innerHTML = '<td>' + radio + '</td>' +
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
'<td>' + (r.customer_id || '-') + '</td>' +
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + '</td>' +
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + dateBadge + '</td>' +
'<td>' + (r.controller_version || '-') + '</td>' +
'<td>' + (r.integrity_ok ? '<span class="badge badge-ok">OK</span>' : '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>') + '</td>';
'<td>' + apps + '</td>' +
'<td>' + disks + '</td>' +
'<td>' + statusCol + '</td>';
tbody.appendChild(tr);
if (r.integrity_ok) validCount++;
});
@@ -112,7 +132,9 @@
});
}
window.selectDrive = function(el) {
document.getElementById('selected-drive').value = el.value;
var parts = el.value.split('|');
document.getElementById('selected-drive').value = parts[0];
document.getElementById('selected-history').value = parts[1] || '';
document.getElementById('restore-btn').disabled = false;
};
poll();