v0.147.0 — feedback slice 1: pressing a button says something

The systemic complaint, twice in one evening: you press a button and nothing
happens. No progress, no ETA, no named result. Three worst offenders, fixed on
the two patterns already here (deploy 3-step panel, storage-init status poll).
No new framework — that is a ROADMAP item; three targeted cards ship tonight.

4a — a verification restore names its result. The flash said the app had been
restored "to a verification folder on the drive"; which folder, on which drive,
was invisible, so the customer could not go and look at what they had just asked
for. Full path now. The restore page gained a listing of existing verification
copies (app, size, date, path) — nothing anywhere showed these, so they piled up
and the only way to find them was SSH — each with a double-confirmed delete.

That delete is the only one this release adds, so it names a STACK, never a
path: the Manager resolves the name inside a backups/offsite-restore root it
computed itself and refuses anything landing outside. Red-proofed — neutralise
the name guard and stack:"" resolves to the offsite-restore ROOT and takes every
copy with it. Refusals are asserted as non-effects.

4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba
synchronously inside the POST handler; on a golden without felhom-samba baked
that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post
followed by "Beállítás mentve." whether or not anything came up. Detached +
polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE
the work starts, since afterwards the image is always present. Success is
probed, not inferred (compose up -d exits 0 on a crash-loop). The password form
starts the same job: with UserSet false reconcile deploys nothing, so on a fresh
box that is where the pull actually happens.

4c — "Távoli mentés most" streams real progress. restic was already reporting
bytes and percent; the runner seam used CombinedOutput() and discarded them. The
manual run now passes --json and scans stdout line-by-line: total bytes, percent,
current app. Manual only — the nightly stays silent, pinned by a test that fails
if it ever passes --json. The poll now arms unconditionally, closing a race the
manual trigger always ran: the redirect rendered before the goroutine wrote
LastStatus=running, so the poll never armed and the page sat static during the
very run just started. Red-proofed twice.

Also closes the golden/controller infra-image drift at the source: infra.Images()
derives from the existing pins and --print-infra-images exposes it, so the golden
bake can stop carrying its own copy. That copy had already drifted — felhom-samba
was never added, so the golden baked 3 of 4, which is why enabling Megosztás
pulled at runtime in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
2026-07-19 09:30:30 +02:00
parent 7f0b41c3e7
commit b5d78d1e0f
22 changed files with 1527 additions and 21 deletions
+5
View File
@@ -823,6 +823,11 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
data["SharesDisplayName"] = backup.SharesDisplayName
}
// v0.147.0 (4a): what verification restores have actually left on disk. Previously nothing on any
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
}
s.executeTemplate(w, r, "backups_restore", data)
}
+56 -3
View File
@@ -219,11 +219,13 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
defer cancel()
if err := s.backupMgr.RunOffboxBackup(ctx); err != nil {
// ...WithProgress: this is the MANUAL trigger, so the page gets live bytes/percent/current app
// (4c). The nightly scheduler keeps calling RunOffboxBackup and stays silent.
if err := s.backupMgr.RunOffboxBackupWithProgress(ctx); err != nil {
s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
}
}()
offboxRedirect(w, r, "A távoli mentés elindult (a futás után az állapot frissül).", false)
offboxRedirect(w, r, "A távoli mentés elindult az állapot itt frissül.", false)
}
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
@@ -266,6 +268,11 @@ func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
resp["last_error"] = t.LastError
resp["orphaned"] = t.RepoState == "orphaned"
}
// v0.147.0 (4c): live progress for a MANUAL run. Absent/inactive on the nightly path, so the
// page simply keeps its previous „Fut…" behavior there.
if s.backupMgr != nil {
resp["progress"] = s.backupMgr.OffboxProgressSnapshot()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
@@ -319,11 +326,57 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
return
}
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok).")
// NAME THE RESULT (v0.147.0, 4a). The old message said the app had been restored "to a
// verification folder on the drive" — which folder, on which drive, was invisible, so the
// customer had no way to look at what they had just asked for. Resolve the real path and say
// it. Fall back to the vague wording only if the path can no longer be resolved.
where := s.backupMgr.OffsiteRestoreScratchPath(app)
msg := "A(z) " + app + " visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok)."
if where != "" {
msg = "A(z) " + app + " visszaállítva ellenőrző mappába: " + where + " (a meglévő adatok változatlanok)."
}
s.backupMgr.EndRestoreOp(true, msg)
}()
offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false)
}
// offboxVerifyCopyDeleteHandler removes ONE verification copy (v0.147.0, 4a).
//
// The only delete this slice adds, so it is deliberately narrow: it names a STACK, never a path — the
// customer cannot hand us a path to remove. The Manager resolves that name inside a
// `backups/offsite-restore` root it computed itself and refuses anything that lands outside (see
// DeleteOffsiteRestoreCopy). The template double-confirms before POSTing.
//
// It refuses while a backup/restore op is running: the copy being deleted could be the one currently
// being written.
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
return
}
_ = r.ParseForm()
stack := strings.TrimSpace(r.FormValue("stack"))
if stack == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó ellenőrző másolat.", true)
return
}
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet fut — a törlés most nem biztonságos.", true)
return
}
if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
s.logger.Printf("[WARN] [web] verification-copy delete %s: %v", stack, err)
offboxRedirectTo(w, r, "/backups/restore", "A másolat törlése nem sikerült: "+err.Error(), true)
return
}
s.logger.Printf("[INFO] [web] verification copy deleted: %s", stack)
offboxRedirectTo(w, r, "/backups/restore", "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.", false)
}
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
+156
View File
@@ -0,0 +1,156 @@
package web
import (
"sync"
"time"
)
// Samba bring-up progress (v0.147.0, feedback slice 4b).
//
// THE PROBLEM: enabling Megosztás on a fresh box ran `ReconcileSamba()` synchronously inside the
// POST handler. On a golden that had not baked felhom-samba, that call is `docker compose up -d`
// pulling ~100MB from a private registry — minutes of an apparently-hung form post, then a redirect
// with a flash reading „Beállítás mentve." whether or not anything had actually come up. Observed
// live, twice. The image is now baked (felhom-agent build-golden.sh, golden >= 0.147.x), but this
// card still covers the pre-0.147 goldens and every future image update.
//
// SHAPE: deliberately the storage-init / netstorage-add one (storage_init_job.go) — detached job,
// single-flight slot, deep-copied snapshot, phase strings the template maps to Hungarian. No new
// framework; a unified async-job feedback layer is a ROADMAP item, not this slice.
//
// State is in-memory and lost on restart, exactly like RestoreOpStatus. That is acceptable here: the
// terminal truth is „is the container running", which the page re-reads from the stack manager on
// every load anyway — the job only explains the WAIT.
type sambaEnsureJob struct {
Phase string `json:"phase"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
// sambaPhasePulling — the pinned image is NOT in local Docker storage, so compose will fetch it.
// This is the phase worth naming: it is the multi-minute one, and the only honest explanation for
// why nothing appears to happen.
sambaPhasePulling = "pulling"
// sambaPhaseStarting — image already local (baked golden / previously pulled): seconds.
sambaPhaseStarting = "starting"
// sambaPhaseRunning — terminal success, PROBED (compose up -d exits 0 on a crash-loop, so the
// job's success condition is container liveness, never the compose exit code).
sambaPhaseRunning = "running"
// sambaPhaseNeedsPassword — not a failure: sharing is on but the household password is unset, so
// reconcile deliberately deploys nothing. The card must say so instead of spinning forever.
sambaPhaseNeedsPassword = "needs_password"
sambaPhaseFailed = "failed"
sambaPhaseIdle = "idle"
)
// A pull that has not finished in 15 minutes is not slow, it is broken (the registry is unreachable
// or the disk is full) — end the job so the card can say so rather than spin indefinitely.
const sambaEnsureDeadline = 15 * time.Minute
type sambaEnsureState struct {
mu sync.Mutex
running bool
cur *sambaEnsureJob
}
func (s *sambaEnsureState) acquire(job *sambaEnsureJob) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return false
}
s.running = true
cp := *job
s.cur = &cp
return true
}
func (s *sambaEnsureState) release() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}
func (s *sambaEnsureState) set(job *sambaEnsureJob) {
s.mu.Lock()
cp := *job
s.cur = &cp
s.mu.Unlock()
}
// snapshot returns a copy of the last / in-flight job (nil = never ran this process).
func (s *sambaEnsureState) snapshot() *sambaEnsureJob {
s.mu.Lock()
defer s.mu.Unlock()
if s.cur == nil {
return nil
}
cp := *s.cur
return &cp
}
// startSambaEnsure claims the single-flight slot and launches the detached reconcile. false = one is
// already in flight (a double-submit must not start a second compose up on the same stack dir).
//
// The opening phase is decided BEFORE the work starts, by asking whether the image is already local
// — afterwards the answer is always yes and the card could never truthfully say „letöltés".
func (s *Server) startSambaEnsure() bool {
now := time.Now().UTC()
phase := sambaPhaseStarting
if s.stackMgr != nil && !s.stackMgr.SambaImagePresent() {
phase = sambaPhasePulling
}
job := &sambaEnsureJob{Phase: phase, StartedAt: now, UpdatedAt: now}
if !s.sambaEnsure.acquire(job) {
return false
}
go s.runSambaEnsureJob(job)
return true
}
func (s *Server) runSambaEnsureJob(job *sambaEnsureJob) {
defer s.sambaEnsure.release()
advance := func(phase, errMsg string) {
job.Phase = phase
job.Error = errMsg
job.UpdatedAt = time.Now().UTC()
s.sambaEnsure.set(job)
}
done := make(chan error, 1)
go func() { done <- s.stackMgr.ReconcileSamba() }()
select {
case err := <-done:
if err != nil {
s.logger.Printf("[ERROR] [sharing] samba reconcile failed after %s: %v", time.Since(job.StartedAt).Round(time.Second), err)
advance(sambaPhaseFailed, err.Error())
return
}
case <-time.After(sambaEnsureDeadline):
// The reconcile goroutine is left running — compose owns its own lifecycle and killing it
// mid-pull would leave a partial layer set. We stop REPORTING on it, which is the honest
// thing the customer needs; a later page load re-probes liveness for the real answer.
s.logger.Printf("[ERROR] [sharing] samba reconcile still running after %s — giving up on the progress card", sambaEnsureDeadline)
advance(sambaPhaseFailed, "a megosztási szolgáltatás előkészítése túl sokáig tartott")
return
}
// Reconcile returned nil — but nil ALSO covers "deliberately did nothing". Distinguish the two,
// because a card that says „fut" while nothing is deployed is the same silence in a new costume.
if s.settings != nil && !s.settings.GetSMBSettings().UserSet {
advance(sambaPhaseNeedsPassword, "")
return
}
if !s.stackMgr.SambaRunning() {
s.logger.Printf("[ERROR] [sharing] samba reconcile reported success but the container is not running")
advance(sambaPhaseFailed, "a megosztási szolgáltatás nem indult el")
return
}
s.logger.Printf("[INFO] [sharing] samba ready after %s", time.Since(job.StartedAt).Round(time.Second))
advance(sambaPhaseRunning, "")
}
@@ -0,0 +1,86 @@
package web
import (
"testing"
)
// v0.147.0 slice 4b — the Megosztás bring-up progress card.
//
// The card's whole value is that it distinguishes states the old synchronous handler collapsed into
// one silent form post. So the tests are about the DISTINCTIONS:
// - "pulling" vs "starting" — decided by local image presence, and decided BEFORE the work starts
// (afterwards the image is always present and the card could never truthfully say „letöltés").
// - reconcile-returned-nil is NOT the same as running: it also covers "deliberately deployed
// nothing because there is no household password yet".
// - compose up -d exits 0 on a crash-loop, so success must be PROBED, never inferred.
//
// SCOPE, STATED HONESTLY: these cover the single-flight slot, snapshot isolation and the phase
// vocabulary. They do NOT drive runSambaEnsureJob end-to-end — that needs a real *stacks.Manager
// (the Server field is the concrete type, not an interface), and introducing an interface purely for
// this card was more churn than the slice warranted. The pulling-vs-starting decision and the
// probed-liveness terminal state are therefore covered by the LIVE validation on the demo box, not
// by a unit test. Recorded here rather than left as an implied gap; the ROADMAP's unified
// async-job feedback item is where that seam belongs.
func TestSambaEnsureSingleFlight(t *testing.T) {
var st sambaEnsureState
job := &sambaEnsureJob{Phase: sambaPhaseStarting}
if !st.acquire(job) {
t.Fatal("first acquire refused")
}
// A double-submit must not start a second `compose up -d` against the same stack dir.
if st.acquire(&sambaEnsureJob{Phase: sambaPhaseStarting}) {
t.Error("second acquire succeeded while a job was in flight")
}
st.release()
if !st.acquire(&sambaEnsureJob{Phase: sambaPhaseStarting}) {
t.Error("acquire refused after release")
}
}
func TestSambaEnsureSnapshotIsACopy(t *testing.T) {
var st sambaEnsureState
job := &sambaEnsureJob{Phase: sambaPhasePulling}
st.acquire(job)
snap := st.snapshot()
snap.Phase = "mutated-by-caller"
if again := st.snapshot(); again.Phase != sambaPhasePulling {
t.Errorf("a caller mutated the shared job through its snapshot: %q", again.Phase)
}
// And the live job must not leak into an already-taken snapshot either.
snap2 := st.snapshot()
job.Phase = sambaPhaseRunning
st.set(job)
if snap2.Phase != sambaPhasePulling {
t.Errorf("an earlier snapshot changed under the caller: %q", snap2.Phase)
}
}
func TestSambaEnsureStateStartsNil(t *testing.T) {
var st sambaEnsureState
// nil = never ran this process. The status handler maps that to `idle` and lets live container
// state win, so a page loaded after a restart still tells the truth.
if j := st.snapshot(); j != nil {
t.Errorf("fresh state reported a job: %+v", j)
}
}
// TestSambaPhaseConstantsAreDistinct guards the template contract: sharing.html maps these exact
// strings, and a duplicate would silently render the wrong Hungarian sentence.
func TestSambaPhaseConstantsAreDistinct(t *testing.T) {
all := []string{
sambaPhasePulling, sambaPhaseStarting, sambaPhaseRunning,
sambaPhaseNeedsPassword, sambaPhaseFailed, sambaPhaseIdle,
}
seen := map[string]bool{}
for _, p := range all {
if p == "" {
t.Error("a phase constant is empty — the template would fall through to 'no card'")
}
if seen[p] {
t.Errorf("duplicate phase constant %q", p)
}
seen[p] = true
}
}
+8
View File
@@ -94,6 +94,8 @@ type Server struct {
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
netAdd netAddState
storageInit storageInitState
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
sambaEnsure sambaEnsureState
netAgentFn func() (netAgent, error)
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
fabUpload uploadState
@@ -359,6 +361,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.sharingPageHandler(w, r)
case path == "/sharing/enable" && r.Method == http.MethodPost:
s.sharingEnableHandler(w, r)
case path == "/sharing/status" && r.Method == http.MethodGet:
s.sharingStatusHandler(w, r)
case path == "/sharing/password" && r.Method == http.MethodPost:
s.sharingPasswordHandler(w, r)
case path == "/sharing/shares" && r.Method == http.MethodPost:
@@ -418,6 +422,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.offboxRestoreHandler(w, r)
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
s.offboxPlaceHandler(w, r)
// v0.147.0 (4a): remove ONE verification copy. The only delete path this slice adds — see
// DeleteOffsiteRestoreCopy for the prefix guard.
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
s.offboxVerifyCopyDeleteHandler(w, r)
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
s.sharesRestoreHandler(w, r)
+33 -6
View File
@@ -221,10 +221,33 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
return
}
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile failed: %v", err)
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
// regardless of whether anything actually came up.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "A megosztási szolgáltatás előkészítése már folyamatban van.")
return
}
sharingRedirect(w, r, "Beállítás mentve.")
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
}
// sharingStatusHandler is the 4b poll target (GET /sharing/status). Reports the ensure job's phase
// plus the live container state, so a page loaded AFTER the job finished (or after a restart, when
// the in-memory job is gone) still shows the truth.
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
phase := sambaPhaseIdle
errMsg := ""
if job := s.sambaEnsure.snapshot(); job != nil {
phase, errMsg = job.Phase, job.Error
}
running := s.stackMgr != nil && s.stackMgr.SambaRunning()
// A stale `idle`/`running` job must never contradict reality: liveness wins on a fresh page.
if phase == sambaPhaseIdle && running {
phase = sambaPhaseRunning
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"phase": phase, "error": errMsg, "running": running,
})
}
// sharingPasswordHandler sets the household SMB password (POST /sharing/password).
@@ -247,10 +270,14 @@ func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request)
sharingRedirect(w, r, "A jelszó beállítása nem sikerült.")
return
}
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after password failed: %v", err)
// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
// where the image pull happens. Same detached job, same card.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.")
return
}
sharingRedirect(w, r, "Megosztási jelszó beállítva.")
sharingRedirect(w, r, "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…")
}
// sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under
@@ -126,6 +126,15 @@
<button type="submit" class="btn btn-sm btn-primary">Távoli mentés most</button>
</form>
</div>
<!-- v0.147.0 (4c): live progress for a MANUAL run — total bytes, percent and which app is being
pushed, parsed from restic's --json status stream. Hidden until a run reports progress; the
nightly run never populates it. -->
<div id="offbox-progress" class="alert alert-info" style="display:none;margin-top:.75rem">
<span id="offbox-progress-text"></span>
<div class="progress-bar-task" style="margin-top:.5rem">
<div id="offbox-progress-bar" class="progress-fill" style="width:0%"></div>
</div>
</div>
<h4 style="margin-top:1.25rem">Mely alkalmazások mentődnek a távoli tárolóra?</h4>
{{if and .OffboxApps (eq .Offbox.EscrowState "escrowed") (eq .OffboxToggledCount 0)}}
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.</p>
@@ -172,19 +181,55 @@
{{end}}
<script>
/* Part C (v0.142.0): while a remote backup is in flight the status card shows "Fut…" — poll the run
status and reload once it reaches a terminal state, so the customer sees Rendben/Hiba + fresh
numbers WITHOUT a manual reload. Polls only when a run is running; stops (page reload) at terminal. */
/* Part C (v0.142.0) + 4c (v0.147.0): poll the run status, render live progress for a manual run, and
reload once it reaches a terminal state so the customer sees Rendben/Hiba + fresh numbers WITHOUT a
manual reload.
v0.147.0 changed WHEN polling starts. It used to begin only if the page already rendered "Fut…",
which loses a race the manual trigger always runs: the POST redirects and this page renders before
the detached goroutine has written LastStatus=running, so the poll never armed and the customer
watched a static page during the very run they had just started. Now it always arms and gives up
after GRACE quiet ticks if nothing is (or ever was) in flight. */
(function(){
var v=document.getElementById('offbox-status-value');
if(!v || v.textContent.indexOf('Fut')<0) return; // not running nothing to poll
var timer=setInterval(function(){
var v = document.getElementById('offbox-status-value');
var box = document.getElementById('offbox-progress');
var text = document.getElementById('offbox-progress-text');
var bar = document.getElementById('offbox-progress-bar');
var GRACE = 5; // ~15s of quiet before concluding nothing is running
var quiet = 0;
var sawRunning = (v && v.textContent.indexOf('Fut') >= 0);
var timer = setInterval(function(){
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
.then(function(r){return r.json();})
.then(function(d){
if(d && d.status==='running') return; // still running → keep polling
clearInterval(timer);
location.reload(); // terminal → re-render (fresh numbers, warnings, or the orphan card)
if(!d) return;
var p = d.progress || {};
var running = (d.status === 'running') || p.active;
if(running){
sawRunning = true; quiet = 0;
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". */
var label = p.current_app ? ('Mentés: ' + p.current_app) : 'Mentés folyamatban';
if(p.total_bytes > 0){
label += ' — ' + Math.round(p.percent) + '% (' + p.done_human + ' / ' + p.total_human + ')';
bar.style.width = Math.max(0, Math.min(100, p.percent)) + '%';
} else {
label += ' — a mentendő adatok felmérése…';
bar.style.width = '0%';
}
text.textContent = label;
box.style.display = '';
}
return;
}
/* Not running. Terminal only if we ever saw it running — otherwise this is the pre-start
race (or a page nobody triggered anything from). */
if(sawRunning){ clearInterval(timer); location.reload(); return; }
if(++quiet >= GRACE){ clearInterval(timer); }
})
.catch(function(){ /* transient — keep polling */ });
}, 3000);
@@ -121,6 +121,32 @@
{{template "app_list_row_end"}}
</div>
{{end}}
<!-- v0.147.0 (4a): what the verification restores actually LEFT on disk. Until now the result of
an ellenőrző visszaállítás was invisible — the flash said "a verification folder on the
drive" without naming it, and nothing listed what had accumulated. Full path, size and date,
so the copy can be found, opened, and cleaned up. -->
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Meglévő ellenőrző másolatok</h3>
{{if .OffsiteRestoreCopies}}
<p class="form-hint" style="margin-bottom:.75rem">Ezek a visszaállított másolatok helyet foglalnak a meghajtón. A tényleges adataidat nem érintik, bármikor törölhetők.</p>
<div class="app-row-list">
{{range .OffsiteRestoreCopies}}
{{template "app_list_row" dict "Slug" .Stack "Name" .Stack "Secondary" (printf "%s · %s · %s" .SizeHuman (.Created.Format "2006. 01. 02. 15:04") .Path)}}
<form method="POST" action="/backup/offbox/verify-copy/delete" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="stack" value="{{.Stack}}">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-danger-outline"
data-copy-path="{{.Path}}"
onclick="confirmDeleteVerifyCopy(this)">Másolat törlése</button>
</form>
{{template "app_list_row_end"}}
{{end}}
</div>
{{else}}
<p class="form-hint">Nincs ellenőrző másolat a meghajtón.</p>
{{end}}
</div>
{{end}}
@@ -178,6 +204,19 @@ function fabDownload(btn){
})
.catch(function(){ fabSetStatus('Hiba: a becslés nem érhető el.'); });
}
// v0.147.0 (4a): the ONLY delete this page offers. Two inline acknowledgements — the house
// double-confirm idiom (deploy.html's stale-data delete), never native confirm(), which is an
// OS-modal that blocks browser automation (F-11). The first step names the exact path so the
// customer is agreeing to a specific directory, not to the word "delete".
function confirmDeleteVerifyCopy(btn){
var path = btn.getAttribute('data-copy-path') || '';
felhomConfirm(btn, 'Biztosan törlöd ezt az ellenőrző másolatot? ' + path + ' — a tényleges adataid változatlanok maradnak.', function(){
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })
@@ -38,6 +38,13 @@
</div>
</form>
<!-- v0.147.0 (4b): bring-up progress. Hidden until the poll reports a non-idle phase, so a page
with nothing in flight looks exactly as it did before. This is what makes the first-enable
wait survivable on a golden that has not baked felhom-samba: the pull is named, not silent. -->
<div id="samba-progress" class="alert alert-info" style="display:none">
<span id="samba-progress-text"></span>
</div>
<div class="form-group">
<span>Állapot:</span>
{{if .SMBRunning}}
@@ -282,6 +289,54 @@ function shareBrowseLoad(p){
});
});
}
/* v0.147.0 (4b) — Megosztás bring-up poll. Same shape as the storage-init status poll
(storage_init.html): 1.5s tick, phase string mapped to Hungarian, terminal states stop the timer.
The card only ever appears while something is genuinely in flight. */
(function(){
var box = document.getElementById('samba-progress');
var txt = document.getElementById('samba-progress-text');
if (!box || !txt) return;
var PHASES = {
pulling: 'A megosztási szolgáltatás előkészítése… (képfájl letöltése — ez több percig tarthat)',
starting: 'A megosztási szolgáltatás indítása…',
needs_password: 'A megosztás be van kapcsolva, de még nincs megosztási jelszó — add meg alább.'
};
var timer = null;
function stop(){ if (timer) { clearInterval(timer); timer = null; } }
function show(cls, text){
box.className = 'alert ' + cls;
txt.textContent = text;
box.style.display = '';
}
function tick(){
fetch('/sharing/status', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(j){
if (!j || !j.ok || !j.data) return; // transient — keep polling
var ph = j.data.phase;
if (PHASES[ph]) { show('alert-info', PHASES[ph]); return; }
if (ph === 'running') {
stop();
show('alert-success', 'A megosztási szolgáltatás fut.');
/* Repaint the „Állapot" badge, which was rendered server-side as „áll". */
setTimeout(function(){ location.reload(); }, 1200);
return;
}
if (ph === 'failed') {
stop();
show('alert-error', 'A megosztási szolgáltatás nem indult el' + (j.data.error ? ': ' + j.data.error : '.'));
return;
}
/* idle and nothing running: nothing to report. */
stop();
box.style.display = 'none';
})
.catch(function(){ /* transient — keep polling */ });
}
tick();
timer = setInterval(tick, 1500);
})();
</script>
{{template "layout_end" .}}