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
@@ -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" .}}