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:
@@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/setup/scan", s.handleScan)
|
||||
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
||||
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
||||
mux.HandleFunc("/setup/hub-restore/select", s.handleHubVersionSelect)
|
||||
mux.HandleFunc("/setup/restore", s.handleRestore)
|
||||
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
||||
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
||||
@@ -233,7 +234,8 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
switch source {
|
||||
case "local":
|
||||
drivePath := r.FormValue("drive_path")
|
||||
go s.executeLocalRestore(drivePath)
|
||||
historyFile := r.FormValue("history_file")
|
||||
go s.executeLocalRestore(drivePath, historyFile)
|
||||
case "hub":
|
||||
go s.executeHubRestore()
|
||||
default:
|
||||
@@ -372,10 +374,31 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
|
||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
||||
}
|
||||
|
||||
// Store recovery data in state for restore execution
|
||||
// If multiple versions available, show picker instead of auto-restoring
|
||||
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
||||
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
||||
// Store config for later use after version selection
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
s.state.Save()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Versions": recovery.BackupVersions,
|
||||
}
|
||||
s.render(w, "setup_hub_versions", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Single version or no versions — proceed directly
|
||||
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||
}
|
||||
|
||||
// storeRecoveryAndRestore stores recovery data in state and starts the restore goroutine.
|
||||
func (s *Server) storeRecoveryAndRestore(w http.ResponseWriter, r *http.Request, recovery *report.RecoveryResponse, customerID string) {
|
||||
s.state.SelectedBackup = &SelectedBackup{
|
||||
Source: "hub",
|
||||
CustomerID: customerID,
|
||||
@@ -389,9 +412,8 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
|
||||
s.state.SetStep("restore-exec")
|
||||
s.state.Save()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
||||
s.logger.Printf("[INFO] Setup: hub recovery stored (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
||||
|
||||
// Start the restore goroutine, then render the progress page
|
||||
go s.executeHubRestore()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
@@ -476,34 +498,69 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
|
||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
||||
}
|
||||
|
||||
// Store recovery data in state for restore execution
|
||||
s.state.SelectedBackup = &SelectedBackup{
|
||||
Source: "hub",
|
||||
CustomerID: customerID,
|
||||
}
|
||||
s.state.SetFormField("retrieval_password", password)
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
||||
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
||||
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
||||
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
||||
|
||||
// If multiple versions available, show picker
|
||||
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
||||
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
s.state.Save()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Versions": recovery.BackupVersions,
|
||||
}
|
||||
s.render(w, "setup_hub_versions", data)
|
||||
return
|
||||
}
|
||||
s.state.SetStep("restore-exec")
|
||||
s.state.Save()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
||||
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||
}
|
||||
|
||||
// Start the restore goroutine, then render the progress page
|
||||
go s.executeHubRestore()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
// handleHubVersionSelect processes the user's version selection from the Hub version picker.
|
||||
func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.render(w, "setup_restore_exec", data)
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
versionStr := r.FormValue("version_id")
|
||||
customerID := s.state.GetFormField("customer_id")
|
||||
password := s.state.GetFormField("retrieval_password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var versionID int64
|
||||
fmt.Sscanf(versionStr, "%d", &versionID)
|
||||
|
||||
s.logger.Printf("[INFO] Setup: user selected backup version %d for %s", versionID, customerID)
|
||||
|
||||
// Fetch the specific version
|
||||
recovery, err := report.PullRecoveryVersion(hubURL, customerID, password, versionID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Setup: failed to fetch version %d: %v", versionID, err)
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Error": fmt.Sprintf("Hiba a verzió letöltésekor: %v", err),
|
||||
}
|
||||
s.render(w, "setup_hub_versions", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||
}
|
||||
|
||||
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -618,7 +675,7 @@ func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- Restore Execution ---
|
||||
|
||||
func (s *Server) executeLocalRestore(drivePath string) {
|
||||
func (s *Server) executeLocalRestore(drivePath, historyFile string) {
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = true
|
||||
s.restoreDone = false
|
||||
@@ -630,8 +687,14 @@ func (s *Server) executeLocalRestore(drivePath string) {
|
||||
}
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
// Step 1: Read backup
|
||||
backupData, _, err := backup.ReadLocalInfraBackup(drivePath)
|
||||
// Step 1: Read backup (current or historical version)
|
||||
var backupData []byte
|
||||
var err error
|
||||
if historyFile != "" {
|
||||
backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile)
|
||||
} else {
|
||||
backupData, _, err = backup.ReadLocalInfraBackup(drivePath)
|
||||
}
|
||||
if err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
||||
return
|
||||
|
||||
@@ -17,15 +17,20 @@ import (
|
||||
|
||||
// DriveBackup represents a found infra backup on a drive.
|
||||
type DriveBackup struct {
|
||||
Device string `json:"device"`
|
||||
Label string `json:"label"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CtrlVersion string `json:"controller_version"`
|
||||
IntegrityOK bool `json:"integrity_ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
WasTempMounted bool `json:"-"`
|
||||
Device string `json:"device"`
|
||||
Label string `json:"label"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CtrlVersion string `json:"controller_version"`
|
||||
IntegrityOK bool `json:"integrity_ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StackCount int `json:"stack_count"`
|
||||
StackNames []string `json:"stack_names,omitempty"`
|
||||
DiskCount int `json:"disk_count"`
|
||||
IsHistory bool `json:"is_history"`
|
||||
HistoryFile string `json:"history_file,omitempty"`
|
||||
WasTempMounted bool `json:"-"`
|
||||
}
|
||||
|
||||
// lsblkOutput represents the JSON output of lsblk.
|
||||
@@ -114,10 +119,8 @@ func ScanDrivesForInfraBackups(logger *log.Logger, debug bool) ([]DriveBackup, e
|
||||
continue
|
||||
}
|
||||
|
||||
result := scanPartition(part, mountedFS, logger)
|
||||
if result != nil {
|
||||
results = append(results, *result)
|
||||
}
|
||||
partResults := scanPartition(part, mountedFS, logger)
|
||||
results = append(results, partResults...)
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results))
|
||||
@@ -137,7 +140,7 @@ func CleanupTempMounts(results []DriveBackup, logger *log.Logger) {
|
||||
}
|
||||
}
|
||||
|
||||
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) *DriveBackup {
|
||||
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) []DriveBackup {
|
||||
label := ""
|
||||
if part.Label != nil {
|
||||
label = *part.Label
|
||||
@@ -187,10 +190,12 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo
|
||||
return nil
|
||||
}
|
||||
|
||||
// Found backup — read and validate
|
||||
_, meta, err := backup.ReadLocalInfraBackup(mountPoint)
|
||||
var results []DriveBackup
|
||||
|
||||
result := &DriveBackup{
|
||||
// Read current backup
|
||||
backupData, meta, err := backup.ReadLocalInfraBackup(mountPoint)
|
||||
|
||||
current := DriveBackup{
|
||||
Device: part.Path,
|
||||
Label: label,
|
||||
MountPoint: mountPoint,
|
||||
@@ -198,24 +203,52 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.IntegrityOK = false
|
||||
result.Error = err.Error()
|
||||
current.IntegrityOK = false
|
||||
current.Error = err.Error()
|
||||
if meta != nil {
|
||||
result.CustomerID = meta.CustomerID
|
||||
result.Timestamp = meta.Timestamp
|
||||
result.CtrlVersion = meta.ControllerVersion
|
||||
current.CustomerID = meta.CustomerID
|
||||
current.Timestamp = meta.Timestamp
|
||||
current.CtrlVersion = meta.ControllerVersion
|
||||
}
|
||||
} else {
|
||||
result.IntegrityOK = true
|
||||
result.CustomerID = meta.CustomerID
|
||||
result.Timestamp = meta.Timestamp
|
||||
result.CtrlVersion = meta.ControllerVersion
|
||||
current.IntegrityOK = true
|
||||
current.CustomerID = meta.CustomerID
|
||||
current.Timestamp = meta.Timestamp
|
||||
current.CtrlVersion = meta.ControllerVersion
|
||||
backup.ParseBackupCounts(backupData, ¤t.StackCount, ¤t.StackNames, ¤t.DiskCount)
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
|
||||
part.Path, label, result.CustomerID, result.IntegrityOK)
|
||||
results = append(results, current)
|
||||
|
||||
return result
|
||||
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
|
||||
part.Path, label, current.CustomerID, current.IntegrityOK)
|
||||
|
||||
// Also scan history directory for older versions
|
||||
history := backup.ReadLocalInfraHistory(mountPoint)
|
||||
for _, hv := range history {
|
||||
hResult := DriveBackup{
|
||||
Device: part.Path,
|
||||
Label: label,
|
||||
MountPoint: mountPoint,
|
||||
CustomerID: hv.CustomerID,
|
||||
Timestamp: hv.Timestamp,
|
||||
CtrlVersion: hv.ControllerVersion,
|
||||
IntegrityOK: hv.IntegrityOK,
|
||||
Error: hv.Error,
|
||||
StackCount: hv.StackCount,
|
||||
StackNames: hv.StackNames,
|
||||
DiskCount: hv.DiskCount,
|
||||
IsHistory: true,
|
||||
HistoryFile: hv.HistoryFile,
|
||||
}
|
||||
results = append(results, hResult)
|
||||
}
|
||||
|
||||
if len(history) > 0 {
|
||||
logger.Printf("[INFO] Setup: found %d historical backup version(s) on %s", len(history), part.Path)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func readMountedFilesystems() map[string]string {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user