package backup import "time" // Restore op-status (Part B): a lightweight, in-memory surface for the ASYNC restore family so the // backups page can show a progress banner (running → success/failure) instead of blocking the HTTP // request until the restore completes. It is display-only and mutex-guarded on the Manager's `mu`; // it does NOT gate concurrency (that stays the restore functions' internal single-flight acquire). // In-memory only — lost on a controller restart (same precedent as notification cooldowns); a page // load mid-op after a restart simply shows no banner. // RestoreOpResult is the terminal record of the most recent restore op. type RestoreOpResult struct { Op string `json:"op"` // "restore" | "tier2-restore" | "offbox-restore" Stack string `json:"stack"` OK bool `json:"ok"` Message string `json:"message"` FinishedAt time.Time `json:"finished_at"` } // RestoreResultWindow bounds how long a finished restore still counts as "what just happened". // // ONE EXPRESSION, TWO SURFACES (R-351). The wizard's phase strip and the list page's banner both // need this bound, and until now only the wizard had one — it lived in internal/web as an // unexported constant. A second copy in the JS would be exactly the "two copies that already // differed" shape this repo keeps paying for, so the window is defined HERE, beside the status it // bounds, and both surfaces read it from the payload. const RestoreResultWindow = 10 * time.Minute // RestoreOpStatus is the shape served at GET /api/backup/restore-status. type RestoreOpStatus struct { Running bool `json:"running"` Op string `json:"op,omitempty"` Stack string `json:"stack,omitempty"` StartedAt time.Time `json:"started_at,omitempty"` Last *RestoreOpResult `json:"last,omitempty"` // LastRecent reports whether Last finished recently enough to still be worth showing to someone // who was NOT watching when it happened. // // R-351, the measured defect: the banner's JS gated the terminal result on a page-local // `sawRunning` flag, so a restore that finished before the page was opened — or in under one // poll interval — was shown to nobody. The 2026-08-21 OpenGist restore finished in 8.7s and the // operator could not tell from any screen whether it had completed; the answer existed only in // a container log. A result nobody can see is the same defect class as no result at all. LastRecent bool `json:"last_recent,omitempty"` } // BeginRestoreOp marks a restore op in flight (called by the handler just before launching the // background goroutine). Idempotent enough for display; concurrency is enforced elsewhere. func (m *Manager) BeginRestoreOp(op, stack string) { m.mu.Lock() defer m.mu.Unlock() m.opRunning = true m.opName = op m.opStack = stack m.opStartedAt = time.Now() } // EndRestoreOp records the terminal result (called from the goroutine on completion, success or // failure). Message carries the error (failure) or a human note like the scratch path (offbox). func (m *Manager) EndRestoreOp(ok bool, message string) { m.mu.Lock() defer m.mu.Unlock() m.opLast = &RestoreOpResult{ Op: m.opName, Stack: m.opStack, OK: ok, Message: message, FinishedAt: time.Now(), } m.opRunning = false } // RestoreStatus returns a deep copy of the current restore op-status for the page/API. func (m *Manager) RestoreStatus() RestoreOpStatus { m.mu.Lock() defer m.mu.Unlock() st := RestoreOpStatus{ Running: m.opRunning, Op: m.opName, Stack: m.opStack, StartedAt: m.opStartedAt, } if m.opLast != nil { cp := *m.opLast st.Last = &cp // Recency is decided here, on the clock the result was stamped with, so neither surface has // to hold its own copy of the window. A zero FinishedAt is never recent — an unstamped // result must not be drawn as "just now". if !cp.FinishedAt.IsZero() { if d := time.Since(cp.FinishedAt); d >= 0 && d < RestoreResultWindow { st.LastRecent = true } } } return st }