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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" .}}
|
||||||
|
|||||||
Reference in New Issue
Block a user