v0.12.2: restore section simplification — snapshot filtering, auto-stop/restart, UI cleanup

- StackDataProvider interface extended with StopStack/StartStack
- backup.Manager.GetStackHDDMounts() delegates to stackProvider
- RestoreApp() auto-stops app before restic restore, restarts after (even on failure)
- stackAdapter in main.go wires StopStack/StartStack through to stacks.Manager
- GET /api/backup/snapshots?stack={name} filters snapshots by app HDD paths via filterSnapshotsByPaths()
- Restore section simplified: no path list, per-app filtered snapshots, human-friendly timestamp format, single calm warning, empty-result inline message

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 19:19:23 +01:00
parent fde8e84d82
commit 62992e0e04
8 changed files with 115 additions and 53 deletions
+8
View File
@@ -1,5 +1,13 @@
## Changelog ## Changelog
### What was just completed (2026-02-17 session 38)
- **v0.12.2 — Restore Section Simplification (Bug 4 from v0.12.1 TASK.md):**
- **Feature: Snapshot filtering by app** — `GET /api/backup/snapshots?stack={name}` now filters snapshots to those whose `Paths` overlap with the app's HDD mount paths. Uses prefix matching (snapshot path is prefix of required, or vice versa). New `filterSnapshotsByPaths()` helper in `internal/api/router.go`. Manager gains `GetStackHDDMounts()` method to expose stackProvider's mount resolution.
- **Feature: Auto-stop/restart on restore** — `RestoreApp()` now stops the app's containers before running `restic restore` and restarts them after (even on failure). Avoids data corruption from live writes during restore. Eliminates the "Javasoljuk az alkalmazás leállítását" advisory from the UI.
- **Interface extension: StackDataProvider** — Added `StopStack(name string) error` and `StartStack(name string) error` to the `backup.StackDataProvider` interface in `internal/backup/appdata.go`. `stackAdapter` in `cmd/controller/main.go` wires these through to `stacks.Manager`.
- **UI simplification: Restore section** — Removed confusing "Visszaállítandó útvonalak" path list (technical detail not needed by customer). Snapshot dropdown now populated per-app (filtered) with human-friendly format: `2026-02-17 hétfő 03:00 (a3f2b1)`. Single calm warning replacing the triple-exclamation block. Empty filtered result shows inline message instead of empty dropdown. `data-paths` attribute removed from app dropdown options.
- **Files modified (6):** `internal/backup/appdata.go`, `internal/backup/backup.go`, `internal/backup/restore.go`, `internal/api/router.go`, `internal/web/templates/backups.html`, `cmd/controller/main.go`
### What was just completed (2026-02-17 session 37) ### What was just completed (2026-02-17 session 37)
- **v0.12.0 — Backup Page Overhaul — Unified App Backup Status & Bug Fixes:** - **v0.12.0 — Backup Page Overhaul — Unified App Backup Status & Bug Fixes:**
- **Bug Fix 1: Duplicate unconfigured apps** — `GetFullStatus()` now returns a deep copy of the cached status. `CrossDriveSummary`, `UnconfiguredApps`, and `CrossDriveWarnings` slices are always nil in the returned copy so the handler builds them fresh on every page load. Previously the handler appended to the cached slices, causing 3× duplication on 3 page loads. - **Bug Fix 1: Duplicate unconfigured apps** — `GetFullStatus()` now returns a deep copy of the cached status. `CrossDriveSummary`, `UnconfiguredApps`, and `CrossDriveWarnings` slices are always nil in the returned copy so the handler builds them fresh on every page load. Previously the handler appended to the cached slices, causing 3× duplication on 3 page loads.
+1 -1
View File
@@ -24,7 +24,7 @@ controller generates secrets, saves app.yaml, runs `docker compose up -d`, and t
with Traefik routing and health checks. The dashboard correctly shows real-time container states with Traefik routing and health checks. The dashboard correctly shows real-time container states
including health substatus (starting → healthy → running). including health substatus (starting → healthy → running).
Current version: **v0.10.0** Current version: **v0.12.2**
### What works ### What works
- Dashboard with live container state (green/orange/yellow/red) - Dashboard with live container state (green/orange/yellow/red)
+8
View File
@@ -407,6 +407,14 @@ func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary {
return result return result
} }
func (a *stackAdapter) StopStack(name string) error {
return a.mgr.StopStack(name)
}
func (a *stackAdapter) StartStack(name string) error {
return a.mgr.StartStack(name)
}
func (a *stackAdapter) GetStackHDDMounts(name string) []string { func (a *stackAdapter) GetStackHDDMounts(name string) []string {
s, ok := a.mgr.GetStack(name) s, ok := a.mgr.GetStack(name)
if !ok { if !ok {
+28 -1
View File
@@ -445,7 +445,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"}) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
} }
func (r *Router) backupSnapshots(w http.ResponseWriter, _ *http.Request) { func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
if r.backupMgr == nil { if r.backupMgr == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}}) writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return return
@@ -456,12 +456,39 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return return
} }
// Filter by stack if requested — only return snapshots that include the app's HDD paths.
if stackName := req.URL.Query().Get("stack"); stackName != "" {
mounts := r.backupMgr.GetStackHDDMounts(stackName)
if len(mounts) > 0 {
snapshots = filterSnapshotsByPaths(snapshots, mounts)
}
}
if snapshots == nil { if snapshots == nil {
snapshots = []backup.SnapshotInfo{} snapshots = []backup.SnapshotInfo{}
} }
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots}) writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
} }
// filterSnapshotsByPaths returns only snapshots whose Paths overlap with requiredPaths.
// A snapshot matches if any of its paths is a prefix of (or prefixed by) any required path.
func filterSnapshotsByPaths(snapshots []backup.SnapshotInfo, requiredPaths []string) []backup.SnapshotInfo {
var filtered []backup.SnapshotInfo
outer:
for _, snap := range snapshots {
for _, required := range requiredPaths {
for _, sp := range snap.Paths {
if strings.HasPrefix(required, sp) || strings.HasPrefix(sp, required) {
filtered = append(filtered, snap)
continue outer
}
}
}
}
return filtered
}
// --- Metrics handlers --- // --- Metrics handlers ---
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
+2
View File
@@ -14,6 +14,8 @@ type StackDataProvider interface {
GetStackComposePath(name string) (composePath string, ok bool) GetStackComposePath(name string) (composePath string, ok bool)
ListDeployedStacks() []StackSummary ListDeployedStacks() []StackSummary
GetStackHDDMounts(name string) []string GetStackHDDMounts(name string) []string
StopStack(name string) error
StartStack(name string) error
} }
// StackSummary holds minimal stack info needed for app data discovery. // StackSummary holds minimal stack info needed for app data discovery.
+8
View File
@@ -414,6 +414,14 @@ func (m *Manager) SetStackProvider(provider StackDataProvider) {
m.stackProvider = provider m.stackProvider = provider
} }
// GetStackHDDMounts returns HDD mount paths for the named stack via the stack provider.
func (m *Manager) GetStackHDDMounts(name string) []string {
if m.stackProvider == nil {
return nil
}
return m.stackProvider.GetStackHDDMounts(name)
}
// resolveAppBackupPaths returns HDD paths for all enabled app backups. // resolveAppBackupPaths returns HDD paths for all enabled app backups.
func (m *Manager) resolveAppBackupPaths() []string { func (m *Manager) resolveAppBackupPaths() []string {
if m.stackProvider == nil || m.settings == nil { if m.stackProvider == nil || m.settings == nil {
+14
View File
@@ -51,12 +51,26 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, paths=%v", stackName, snapshotID, hddMounts) m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, paths=%v", stackName, snapshotID, hddMounts)
// Stop the app before restore to avoid data corruption
if err := m.stackProvider.StopStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not stop %s before restore: %v (proceeding anyway)", stackName, err)
}
// Execute restore // Execute restore
if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil { if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil {
m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err) m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err)
// Try to restart the app even on failure
if startErr := m.stackProvider.StartStack(stackName); startErr != nil {
m.logger.Printf("[WARN] RESTORE could not restart %s after failed restore: %v", stackName, startErr)
}
return err return err
} }
// Restart the app after successful restore
if err := m.stackProvider.StartStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not restart %s after restore: %v", stackName, err)
}
m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s", stackName, snapshotID) m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s", stackName, snapshotID)
return nil return nil
} }
+46 -51
View File
@@ -451,31 +451,28 @@
<option value="">— Válasszon —</option> <option value="">— Válasszon —</option>
{{range .Backup.AppDataInfo}} {{range .Backup.AppDataInfo}}
{{if and .HasHDDData .BackupEnabled}} {{if and .HasHDDData .BackupEnabled}}
<option value="{{.StackName}}" data-paths="{{range $i, $p := .HDDPaths}}{{if $i}},{{end}}{{$p.HostPath}}{{end}}">{{.DisplayName}}</option> <option value="{{.StackName}}">{{.DisplayName}}</option>
{{end}} {{end}}
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="restore-form-row"> <div class="restore-form-row">
<label class="restore-label">Pillanatkép:</label> <label class="restore-label">Pillanatkép:</label>
<select id="restore-snapshot" class="restore-select"> <select id="restore-snapshot" class="restore-select" onchange="onRestoreConfirmChange()">
<option value="">Betöltés...</option> <option value="">Válasszon alkalmazást</option>
</select> </select>
</div> </div>
<div id="restore-paths" class="restore-paths" style="display:none;"> <div id="restore-no-snapshots" class="restore-warning" style="display:none;">
<span class="restore-label">Visszaállítandó útvonalak:</span> Még nincs mentés felhasználói adattal.
<ul id="restore-paths-list" class="repo-path-list"></ul>
</div> </div>
<div class="restore-warning"> <div class="restore-warning">
<strong>FIGYELMEZTETÉS</strong><br> ⚠ A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával.
A visszaállítás FELÜLÍRJA a kiválasztott alkalmazás jelenlegi adatait a mentés pillanatának állapotával. Az alkalmazás a folyamat során automatikusan leáll és újraindul.
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>
<div class="restore-confirm"> <div class="restore-confirm">
<label> <label>
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()"> <input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
Megértettem, visszaállítás saját felelősségre. Megértettem, visszaállítás indítása.
</label> </label>
</div> </div>
<div class="restore-actions"> <div class="restore-actions">
@@ -595,46 +592,48 @@ function copyResticPw() {
} }
// Restore section // Restore section
var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat'];
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
}
function onRestoreAppChange() { function onRestoreAppChange() {
var sel = document.getElementById('restore-app'); var sel = document.getElementById('restore-app');
var opt = sel.options[sel.selectedIndex]; var appName = sel.value;
var pathsDiv = document.getElementById('restore-paths'); var snapSel = document.getElementById('restore-snapshot');
var pathsList = document.getElementById('restore-paths-list'); var noSnaps = document.getElementById('restore-no-snapshots');
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-confirm-cb').checked = false;
document.getElementById('restore-btn').disabled = true; document.getElementById('restore-btn').disabled = true;
noSnaps.style.display = 'none';
if (!appName) {
snapSel.innerHTML = '<option value="">— Válasszon alkalmazást —</option>';
return;
}
snapSel.innerHTML = '<option value="">— Betöltés... —</option>';
fetch('/api/backup/snapshots?stack=' + encodeURIComponent(appName))
.then(function(r) { return r.json(); })
.then(function(data) {
snapSel.innerHTML = '<option value="">— Válasszon —</option>';
if (data.ok && data.data && data.data.length > 0) {
data.data.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
snapSel.appendChild(o);
});
} else {
snapSel.innerHTML = '<option value="">— Nincs elérhető mentés —</option>';
noSnaps.style.display = 'block';
}
});
} }
function onRestoreConfirmChange() { function onRestoreConfirmChange() {
@@ -674,11 +673,7 @@ function submitRestore() {
startBackupPolling(); startBackupPolling();
{{end}}{{end}} {{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> </script>
{{template "layout_end" .}} {{template "layout_end" .}}