v0.186.0 — R-114 + R-112: tell the truth about the backup target, then show it

Two defects E-2d found on a real box, fixed in this order deliberately: the
message is corrected BEFORE it is put on screen, because switching on a banner
that lies is worse than a silent one.

R-114 — the third state. resolveBackupTargetState had two outcomes: a disk
claims the target (healthy), or nothing does (degraded, "the backup is on the
system disk"). The state "configured, and its drive is gone" had no branch, so
it fell into the second and inherited its message AND its offer. Observed live
with the target detached: degraded:true, target:"felhom-backup" plus the
system-disk copy (false -- the backup was on a drive that had vanished) plus
offer_path naming that same vanished drive as the remedy.

New BackupTargetState.TargetAbsent discriminates. Degraded keeps its meaning
("is there a problem") so the wire contract is unchanged for every consumer;
TargetAbsent answers "which problem", because the two have opposite remedies --
attach any second drive, versus reconnect THAT one. Copy routed through
degradedMessageFor so one place still decides what a customer reads. The offer
is suppressed on the branch itself, NOT left to firstOfferableDrive's
Disconnected skip: that flag is set by the agent-side gate in another repo
(R-113), and this state must be correct independently of it.

R-112 — the state finally has a consumer. The endpoint was byte-correct and
nothing in the product ever asked for it: templates fetch 18 distinct
/api/storage/* endpoints and backup-target[/assign] were the only two with zero
references. Server-rendered on /backups now, following the existing
SingleCopyWarning banner pattern -- not a 19th JS fetch, because a banner that
needs JavaScript to appear is one more thing that can silently not happen.
backupTargetView returns nil for healthy and unknown so those render nothing at
all. The offer control POSTs to the existing assign endpoint behind the standard
inline confirm, never auto-submits, and surfaces restart_required honestly
instead of adding a self-restart.

Scenario E (the seam test) drives backupsHandler over httptest and asserts the
RENDERED HTML -- handler -> view -> resolver -> template. It deliberately does
not call the resolver and assert a string, which would prove the resolver that
was never broken. Deleting the one line that sets data["BackupTarget"]
reproduces the R-112 state and fails every render assertion.

Tests 326 -> 338 (+12) in internal/web; suite green (27 packages); both template
gates pass. Three red-proofs run and reverted, files byte-identical after.

MinAgent unchanged at 0.113.0: R-114 reads BackupTarget/MountPath/GuestPath/Role,
none of which R-113 altered (it changed BoundUnderParent, which this code does
not read). demo-hp on agent 0.113.0 is not held.

The absent copy is verbatim the hub's customerMessages["backup_target_absent"]
so the banner and the email tell one story -- filed as a two-repo drift risk,
not solved.

NOT LIVE-VALIDATED. Scenario C cannot occur on a healthy box; Session C proves it.
This commit is contained in:
2026-07-29 19:21:32 +02:00
parent cdaeb36972
commit b331f18424
10 changed files with 651 additions and 77 deletions
@@ -11,6 +11,21 @@
<div class="alert alert-warning">{{.Backup.SingleCopyWarning}}</div>
{{end}}{{end}}
{{/* R-112: the whole-system backup-target state. nil = healthy or unknown = render NOTHING. */}}
{{if .BackupTarget}}
<div class="alert alert-warning" id="backup-target-alert">{{.BackupTarget.Message}}</div>
{{if .BackupTarget.OfferPath}}
<div class="alert alert-info" id="backup-target-offer">
<p>{{.BackupTarget.OfferText}}</p>
<p class="form-hint">{{.BackupTarget.OfferLabel}}</p>
<button type="button" class="btn btn-sm" id="backup-target-assign"
data-path="{{.BackupTarget.OfferPath}}"
data-confirm="Kijelölöd ezt a meghajtót a rendszermentés helyéül?">Kijelölöm</button>
<div id="backup-target-assign-result"></div>
</div>
{{end}}
{{end}}
{{if not .Backup}}
{{template "backups_empty" .}}
{{else}}
@@ -215,6 +230,43 @@ function pollGuestBackup(out, btn) {
if (tries > 180) { clearInterval(iv); btn.disabled = false; } // ~15 min cap at 5s polls
}, 5000);
}
// R-112 — ACCEPTING the offer. It is an OFFER: this runs only on an explicit click behind the
// standard inline confirm, and declining is simply never calling it. Nothing else on this page may
// write the role, and there is no auto-submit.
(function () {
var btn = document.getElementById('backup-target-assign');
if (!btn) { return; } // no offer in this state — nothing to wire
btn.addEventListener('click', function () {
var out = document.getElementById('backup-target-assign-result');
btn.disabled = true;
out.innerHTML = '<p class="form-hint">Kijelölés folyamatban…</p>';
fetch('/api/storage/backup-target/assign', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
body: JSON.stringify({ path: btn.getAttribute('data-path') })
})
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) {
out.innerHTML = '<div class="alert alert-error">A kijelölés nem sikerült: ' + (j.error || '') + '</div>';
btn.disabled = false;
return;
}
// The agent deliberately does NOT restart itself — restarting with a backup in flight
// records a spurious tier failure. So say so plainly instead of hiding it or faking it.
if (j.data && j.data.restart_required) {
out.innerHTML = '<div class="flash flash-success">A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.</div>';
} else {
out.innerHTML = '<div class="flash flash-success">A meghajtó kijelölve.</div>';
}
})
.catch(function (e) {
out.innerHTML = '<div class="alert alert-error">Hiba: ' + e.message + '</div>';
btn.disabled = false;
});
});
})();
</script>
{{template "layout_end" .}}