diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e46154..d7ff672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## Changelog +### v0.147.3 — 4c follow-up 3: the run does not end with the last app (2026-07-19) + +Third real run, third thing only a live run could show. The per-app legs finished in ~15 seconds; +the remaining **40 of the 57-second run** was the shares leg and `forget --prune` — during which the +card sat frozen on „calibre-web — 8 / 8 fájl". The same frozen-looking silence 4c exists to remove, +relocated to the end of the run. + +Progress now carries a **phase**. The post-app stages announce themselves („Megosztott mappák +mentése…", „Karbantartás: régi mentések rendezése a távoli tárolón…") and the app-scoped counters are +cleared when a phase starts, so the last app's finished numbers are never shown against work that is +no longer about that app. Starting the next app clears the phase again. Pinned by a test. + ### v0.147.2 — 4c follow-up 2: when NO counter can move, say what is being worked on (2026-07-19) The v0.147.1 file-count fallback fixed the incremental case but not the one the demo box actually diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 7a40bfa..30fbdf9 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -55,7 +55,9 @@ func (m *Manager) SetOffboxNotify(fn func(dur time.Duration, snapshots int, err } // SetOffboxOrphanEvent wires the offsite-repo continuity event push (main.go → notifier). -func (m *Manager) SetOffboxOrphanEvent(fn func(eventType, renamedTo string)) { m.offboxOrphanEvent = fn } +func (m *Manager) SetOffboxOrphanEvent(fn func(eventType, renamedTo string)) { + m.offboxOrphanEvent = fn +} // SetOffboxSSH overrides the raw-ssh exec used for the orphaned-repo move-aside (tests). func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)) { @@ -217,7 +219,9 @@ func (m *Manager) SetOffboxSizer(fn func(path string) int64) { m.offboxSizer = f func (m *Manager) SetOffboxEnlargeBlockedNotifier(fn func(stack string, estBytes int64, usedGB, quotaGB int)) { m.offboxEnlargeBlockedNotify = fn } -func (m *Manager) SetOffboxPlaceCopier(fn func(src, dst string) (int, error)) { m.offboxPlaceCopier = fn } +func (m *Manager) SetOffboxPlaceCopier(fn func(src, dst string) (int, error)) { + m.offboxPlaceCopier = fn +} // offboxSize returns the mandatory-set byte estimator (nil seam → the real du -sb dirSizeBytes). func (m *Manager) offboxSize() func(string) int64 { @@ -923,6 +927,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin // R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by // host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on // purpose: share protection must not be dropped because some unrelated app failed to push. + m.offboxProgress.setPhase(OffboxPhaseShares) sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t) m.recordSharesOffsiteStatus(sharesRes) res.warns = append(res.warns, sharesRes.warns...) @@ -942,6 +947,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin // unit-only-shape snapshots share a group with its NEW enlarged shape (same tag) and age // out naturally — the default host,paths grouping would strand old-shape snapshots in their own // permanently-retained group. prune takes an EXCLUSIVE lock (the C2 stale-lock step) → resticStep. + m.offboxProgress.setPhase(OffboxPhaseRetention) fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout) defer cancel() if out, ferr := m.resticStep(fctx, env, base, "prune", "forget", "--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune"); ferr != nil { diff --git a/controller/internal/backup/offbox_progress.go b/controller/internal/backup/offbox_progress.go index 3eec686..d414224 100644 --- a/controller/internal/backup/offbox_progress.go +++ b/controller/internal/backup/offbox_progress.go @@ -52,6 +52,11 @@ type OffboxProgress struct { // long it has been going — "working on X, 42s" is a completely different message from "0%". CurrentFile string `json:"current_file"` ElapsedSec int64 `json:"elapsed_sec"` + // Phase names the part of the run in progress. A run is NOT just the per-app loop: after the last + // app come the shares leg and `forget --prune`, which on the demo box took 40 of a 57-second run. + // Without this the card froze on the last app's finished counters for that whole tail — the same + // silence the slice exists to remove, just relocated. "" = per-app backup. + Phase string `json:"phase"` } // resticStatusLine is the subset of restic's `--json` status object we consume. restic emits several @@ -150,6 +155,7 @@ func (p *offboxProgressState) setApp(app string) { // A new app resets the byte counters: restic's percentages are per-invocation, and carrying // the previous app's 100% into the next app's start would show a bar that jumps backwards. p.cur.CurrentApp = app + p.cur.Phase = "" p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0 p.cur.FilesDone, p.cur.TotalFiles = 0, 0 p.cur.CurrentFile, p.cur.ElapsedSec = "", 0 @@ -158,6 +164,27 @@ func (p *offboxProgressState) setApp(app string) { p.mu.Unlock() } +// setPhase marks a non-per-app stage of the run and clears the app-scoped counters, so the card +// stops showing the last app's finished numbers against work that is no longer about that app. +func (p *offboxProgressState) setPhase(phase string) { + p.mu.Lock() + if p.live { + p.cur.Phase = phase + p.cur.CurrentApp = "" + p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0 + p.cur.FilesDone, p.cur.TotalFiles = 0, 0 + p.cur.CurrentFile, p.cur.ElapsedSec = "", 0 + p.cur.DoneHuman, p.cur.TotalHuman = "", "" + } + p.mu.Unlock() +} + +// OffboxPhaseShares / OffboxPhaseRetention are the post-app-loop stages. +const ( + OffboxPhaseShares = "shares" + OffboxPhaseRetention = "retention" +) + func (p *offboxProgressState) update(r resticProgress) { p.mu.Lock() if p.live { diff --git a/controller/internal/backup/offbox_progress_test.go b/controller/internal/backup/offbox_progress_test.go index f8836b6..6561d29 100644 --- a/controller/internal/backup/offbox_progress_test.go +++ b/controller/internal/backup/offbox_progress_test.go @@ -274,6 +274,32 @@ func TestProgressKeepsLastKnownCurrentFile(t *testing.T) { } } +// TestSetPhaseClearsAppScopedCounters — a run is not only the per-app loop. The shares leg and +// forget --prune follow it and took 40 of a 57-second run on the demo box. Without a phase the card +// kept showing the last app's finished counters ("calibre-web 8 / 8") for that whole tail, which is +// the same frozen-looking silence 4c exists to remove, just relocated to the end of the run. +func TestSetPhaseClearsAppScopedCounters(t *testing.T) { + var st offboxProgressState + st.begin() + st.setApp("calibre-web") + st.update(resticProgress{Percent: 100, BytesDone: 850000, TotalBytes: 850000, FilesDone: 8, TotalFiles: 8, CurrentFile: "/data/x"}) + + st.setPhase(OffboxPhaseRetention) + got := st.snapshot() + if got.Phase != OffboxPhaseRetention { + t.Errorf("phase = %q, want %q", got.Phase, OffboxPhaseRetention) + } + if got.CurrentApp != "" || got.FilesDone != 0 || got.TotalFiles != 0 || got.BytesDone != 0 || got.CurrentFile != "" { + t.Errorf("app-scoped counters survived the phase switch: %+v — the card would show the last "+ + "app's finished numbers against retention work", got) + } + // Starting another app must clear the phase again, or the card would stay on „Karbantartás". + st.setApp("immich") + if p := st.snapshot(); p.Phase != "" || p.CurrentApp != "immich" { + t.Errorf("after setApp: phase=%q app=%q, want phase cleared and app set", p.Phase, p.CurrentApp) + } +} + // TestParseResticStatusClampsPercent — restic has been seen to report percent_done slightly above 1 // near completion. A bar wider than its track is a visible bug. func TestParseResticStatusClampsPercent(t *testing.T) { diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 8aaec35..24144a3 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -212,6 +212,21 @@ if(p.active && box){ /* Only claim a percentage once restic has told us a total — before the scan finishes, percent is 0 of 0, and a bar pinned at 0% reads as "stuck" rather than "measuring". */ + /* A run is not only the per-app loop: the shares leg and the retention/prune step follow + it and can dominate the wall clock (40 of 57 seconds, measured). Name them, or the + card freezes on the last app's finished counters for that whole tail. */ + if(p.phase === 'retention'){ + text.textContent = 'Karbantartás: régi mentések rendezése a távoli tárolón…'; + bar.style.width = '100%'; + box.className = 'alert alert-info'; box.style.display = ''; + return; + } + if(p.phase === 'shares'){ + text.textContent = 'Megosztott mappák mentése…'; + bar.style.width = '100%'; + box.className = 'alert alert-info'; box.style.display = ''; + return; + } var label = p.current_app ? ('Mentés: ' + p.current_app) : 'Mentés folyamatban'; if(p.bytes_done > 0){ /* Bytes are genuinely moving — the informative case. */