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
@@ -0,0 +1,193 @@
package web
import (
"context"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-114 — the third state: CONFIGURED, and its drive is GONE.
//
// THE BUG THESE PIN, measured live in E-2d (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3). With
// the assigned target's drive detached, the endpoint returned:
//
// {"degraded":true,"target":"felhom-backup",
// "message":"A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer …",
// "offer_path":"/mnt/felhom-drives/mentes2","offer_label":"Mentés meghajtó"}
//
// Two falsehoods in one payload. The backup was NOT on the system disk — it was on a drive that had
// vanished. And the remedy offered was THE DRIVE THAT JUST DISAPPEARED. resolveBackupTargetState had
// only two outcomes (a disk claims the target, or nothing does) so the third state fell into the
// second and inherited its message and its offer.
//
// The fixtures below are the observed shapes, not invented ones: the drive is still listed with a
// MountPath (the registry keeps the configured path), Role user-data, but BackupTarget is false —
// which is exactly why the healthy branch missed it and the offer branch matched it.
// absentHarness wires a Server whose agent reports `primary` as the tier target and `disks` as the
// drive list, with one registered storage path so firstOfferableDrive has something to find. Both
// halves come through the production seams (tiersFn / disksFn), so the resolver under test is the
// real one.
func absentHarness(t *testing.T, primary string, disks []agentapi.DiskInfo, registerPath string) *Server {
t.Helper()
s := testServer(t)
if registerPath != "" {
if err := s.settings.AddStoragePath(settings.StoragePath{Path: registerPath, Label: "Mentés meghajtó"}); err != nil {
t.Fatalf("register storage path: %v", err)
}
}
s.tiersFn = func(context.Context) (agentapi.TiersResponse, error) {
return agentapi.TiersResponse{Tiers: []agentapi.BackupTierInfo{{Target: primary, Primary: true}}}, nil
}
s.disksFn = func(context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{Disks: disks}, nil
}
return s
}
// theVanishedDrive is the E-2d shape: registered, still carrying its configured MountPath, user-data,
// but no longer claiming the backup target because its device is gone.
var theVanishedDrive = agentapi.DiskInfo{
Name: "mentes2",
MountPath: "/mnt/mentes2",
GuestPath: "/mnt/felhom-drives/mentes2",
Role: "user-data",
BackupTarget: false,
}
// ── Scenario C — configured, drive absent ───────────────────────────────────────────────────────
// RED-PROOF: delete the `if targetIsConfiguredDrive(primary)` branch from resolveBackupTargetState
// and this fails twice — the message becomes the system-disk copy and OfferPath becomes non-empty.
func TestConfiguredButAbsentTargetDoesNotClaimTheSystemDisk(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
st := s.resolveBackupTargetState(context.Background())
if !st.Known {
t.Fatal("state is unknown — the fixture answers both agent calls")
}
if !st.TargetAbsent {
t.Fatalf("TargetAbsent is false for target %q with no disk claiming it — the configured-but-gone "+
"state fell back into never-configured, which is the R-114 bug", st.TargetID)
}
msg := degradedMessageFor(st)
if strings.Contains(msg, "ugyanazon a lemezen") {
t.Errorf("the customer is told the backup is on the SYSTEM DISK, which is false — the target is "+
"%q, a drive that has vanished. Got: %s", st.TargetID, msg)
}
if !strings.Contains(msg, "nem érhető el") {
t.Errorf("the absent-drive copy is missing; got: %s", msg)
}
}
// The remedy must be "reconnect THAT drive", never "pick a different one" — and above all never the
// drive that just disappeared, which is what E-2d actually observed being offered.
func TestConfiguredButAbsentTargetOffersNothing(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
st := s.resolveBackupTargetState(context.Background())
if st.OfferPath != "" {
t.Errorf("offered %q while the configured target is absent — E-2d observed this exact payload "+
"offering the drive that had just vanished", st.OfferPath)
}
if st.OfferLabel != "" {
t.Errorf("offer label %q leaked with no offer path", st.OfferLabel)
}
}
// The suppression must be the branch's own doing, NOT a side effect of the drive being flagged
// Disconnected — that flag is set by the agent-side drive gate in another repo (R-113), and this
// state has to be correct before, during and independently of that landing.
func TestAbsentTargetSuppressesOfferWithoutRelyingOnTheDisconnectedFlag(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
for _, sp := range s.settings.GetStoragePaths() {
if sp.Disconnected {
t.Fatalf("fixture invalid: %s is already marked Disconnected, so this test would pass "+
"for the wrong reason", sp.Path)
}
}
if st := s.resolveBackupTargetState(context.Background()); st.OfferPath != "" {
t.Errorf("offer %q survived with Disconnected UNSET — the suppression is leaning on the "+
"other repo's flag instead of on this state", st.OfferPath)
}
}
// ── Scenario A — never configured (must be unchanged) ───────────────────────────────────────────
func TestNeverConfiguredStillSaysSystemDiskAndStillOffers(t *testing.T) {
s := absentHarness(t, builtinLocalTarget, []agentapi.DiskInfo{{
Name: "hdd1", MountPath: "/mnt/hdd1", GuestPath: "/mnt/felhom-drives/hdd1", Role: "user-data",
}}, "/mnt/felhom-drives/hdd1")
st := s.resolveBackupTargetState(context.Background())
if st.TargetAbsent {
t.Fatal("target `local` marked absent — the builtin root-fs storage is the NEVER-CONFIGURED " +
"state, not a drive that went missing")
}
if !st.Degraded {
t.Fatal("a backup on the system disk must still read degraded")
}
if msg := degradedMessageFor(st); !strings.Contains(msg, "ugyanazon a lemezen") {
t.Errorf("the never-configured copy changed; got: %s", msg)
}
if st.OfferPath == "" {
t.Error("no offer in the never-configured state — this is exactly where an offer belongs")
}
}
// An unset tier is the same customer situation as `local`: nothing chosen yet, so it must not be
// reported as a drive that vanished.
func TestUnsetTierIsNeverConfiguredNotAbsent(t *testing.T) {
if targetIsConfiguredDrive("") {
t.Error("an empty target id was treated as a configured drive")
}
if targetIsConfiguredDrive(builtinLocalTarget) {
t.Error("the builtin `local` was treated as a configured drive")
}
if !targetIsConfiguredDrive("felhom-backup") {
t.Error("a real storage id was not treated as a configured drive")
}
}
// ── Scenario B — healthy still renders nothing ──────────────────────────────────────────────────
func TestHealthyTargetIsNeitherDegradedNorAbsent(t *testing.T) {
s := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{{
Name: "mentes2", MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2",
Role: "user-data", BackupTarget: true,
}}, "/mnt/felhom-drives/mentes2")
st := s.resolveBackupTargetState(context.Background())
if st.Degraded || st.TargetAbsent {
t.Fatalf("a healthy target reported degraded=%v absent=%v", st.Degraded, st.TargetAbsent)
}
if msg := degradedMessageFor(st); msg != "" {
t.Errorf("healthy produced copy %q — a working box must look normal", msg)
}
}
// ── the copy contract ───────────────────────────────────────────────────────────────────────────
// The absent copy is VERBATIM the hub's customerMessages["backup_target_absent"]. If either side is
// reworded this fails, which is the only thing currently binding the two repos' strings together.
func TestAbsentCopyMatchesTheHubEmailWordForWord(t *testing.T) {
const hubCopy = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
if backupTargetAbsentText != hubCopy {
t.Errorf("the banner and the email now tell the customer different stories about one drive.\n"+
" banner: %s\n email : %s", backupTargetAbsentText, hubCopy)
}
msg := degradedMessageFor(BackupTargetState{Known: true, Degraded: true, TargetAbsent: true})
for _, want := range []struct{ frag, why string }{
{"nem érhető el", "the FACT — the drive cannot be reached"},
{"nem készül el", "the CONSEQUENCE — the full system backup does not happen"},
{"vissza nem csatlakoztatod", "the REMEDY — reconnect it"},
} {
if !strings.Contains(msg, want.frag) {
t.Errorf("absent copy is missing %s (%q); got: %s", want.why, want.frag, msg)
}
}
}
+110 -12
View File
@@ -21,9 +21,22 @@ type BackupTargetState struct {
// Known is false when the agent could not be asked. Everything below is then meaningless, and the
// UI must render NOTHING rather than guess — an unreachable agent is not evidence of degradation.
Known bool
// Degraded is true when the whole-guest backup lands on the SYSTEM drive, so it protects against
// corruption but not against drive loss.
// Degraded is true when the whole-guest backup is NOT protected against drive loss. It answers
// "is there a problem", not "which problem" — TargetAbsent below is the discriminator. Both
// problem states set it, so the wire's `degraded` flag keeps its meaning for every consumer.
Degraded bool
// TargetAbsent (R-114) separates the two problem states, which have OPPOSITE remedies:
//
// Degraded && !TargetAbsent — never configured. The backup is on the system drive. Remedy:
// attach a second drive and assign it. An offer belongs here.
// Degraded && TargetAbsent — configured, and its drive is GONE. The backup is not on the
// system drive at all. Remedy: reconnect THAT drive. No offer —
// suggesting a different drive is the wrong instruction.
//
// Before R-114 the second case fell into the first, so a customer whose backup drive had vanished
// was told the backup was on the system disk (false) and offered the drive that just disappeared
// (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3).
TargetAbsent bool
// TargetID is the agent's primary tier storage id (e.g. "felhom-backup" / "local").
TargetID string
// Label is the customer-facing drive name when the target is a real drive.
@@ -43,11 +56,7 @@ type BackupTargetState struct {
// a different question. This state answers only "is there a LOCAL copy that survives a disk failure",
// which is matrix row 4.
func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState {
agent, err := s.agentClient()
if err != nil {
return BackupTargetState{}
}
tiers, err := agent.BackupTiers(ctx)
tiers, err := s.fetchBackupTiers(ctx)
if err != nil {
// Includes ErrTiersUnsupported (a pre-R-82 agent). Unknown, never "degraded": claiming
// degradation because we could not ask would put a permanent warning on a healthy box.
@@ -62,7 +71,7 @@ func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState
}
st := BackupTargetState{Known: true, TargetID: primary}
disks, derr := agent.Disks(ctx)
disks, derr := s.fetchDisks(ctx)
if derr != nil {
return BackupTargetState{} // could not classify → say nothing
}
@@ -73,13 +82,48 @@ func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState
return st
}
}
// No disk claims the target ⇒ it is the builtin `local` on the system drive, or the tier is
// unset. Either way the local backup does not survive drive loss.
// No disk claims the target. Two DIFFERENT states land here and R-114 separates them, because
// before it they shared one message and one remedy — and for the second the message was false.
st.Degraded = true
if targetIsConfiguredDrive(primary) {
// A real storage id is configured, yet no disk claims it ⇒ its drive is GONE. Saying "the
// backup is on the system disk" here is simply untrue, and offering another drive answers a
// question the customer did not ask. The remedy is to reconnect THAT drive.
st.TargetAbsent = true
// Offer suppressed unconditionally — NOT left to firstOfferableDrive's Disconnected skip.
// That skip only works once the agent's drive-gate has marked the path (R-113, another repo);
// this state must be correct on its own. Belt here, braces there.
return st
}
st.OfferPath, st.OfferLabel = s.firstOfferableDrive(disks.Disks)
return st
}
// fetchBackupTiers reads the agent's tier view through the tiersFn test seam (nil → the real client),
// mirroring fetchDisks so both halves of this state come from seams a test can drive.
func (s *Server) fetchBackupTiers(ctx context.Context) (agentapi.TiersResponse, error) {
if s.tiersFn != nil {
return s.tiersFn(ctx)
}
client, err := s.agentClient()
if err != nil {
return agentapi.TiersResponse{}, err
}
return client.BackupTiers(ctx)
}
// builtinLocalTarget is the PVE builtin storage on the root filesystem — the "no separate drive"
// target. Anything else is a deliberately configured storage.
const builtinLocalTarget = "local"
// targetIsConfiguredDrive reports whether the primary tier names a real storage rather than the
// builtin root-fs one. Empty means the tier is unset (never configured), which is the same customer
// situation as `local`: nothing has been chosen yet.
func targetIsConfiguredDrive(targetID string) bool {
t := strings.TrimSpace(targetID)
return t != "" && t != builtinLocalTarget
}
// firstOfferableDrive picks a registered, connected, non-network drive that could hold the backup.
// It is a SUGGESTION for the offer — the customer still has to choose (E-2 §3). Nothing here assigns
// anything.
@@ -120,6 +164,15 @@ const (
"így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez."
backupTargetOfferText = "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba " +
"után is vissza tudod állítani a rendszert."
// backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's
// customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the
// banner a customer reads on the page and the email they receive say exactly the same thing — a
// customer who is told two different stories about one drive trusts neither.
//
// DRIFT RISK, filed not fixed: this string now lives in two repos with nothing binding them. If
// one is reworded the other silently disagrees.
backupTargetAbsentText = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
)
// degradedMessageFor is the single decision point for "does the customer see anything?" — extracted
@@ -133,11 +186,56 @@ func degradedMessageFor(st BackupTargetState) string {
if !st.Known || !st.Degraded {
return ""
}
if st.TargetAbsent {
// R-114: configured, drive gone. A different fact with a different remedy, so a different
// sentence — routed through here so there is still exactly one place that decides copy.
return backupTargetAbsentText
}
return backupTargetDegradedText
}
// handleBackupTargetState serves GET /api/storage/backup-target — the dashboard's source for the degraded
// banner and the offer.
// ---- the render (R-112) ------------------------------------------------------------------------
// BackupTargetView is the template-facing shape of this state. It exists so the template stays a
// dumb renderer: every "does the customer see anything?" decision is already made by the time it
// arrives, in degradedMessageFor, which remains the single decision point.
//
// R-112: until v0.186.0 this state had NO consumer at all. The endpoint was byte-correct and no
// template, handler or script ever asked for it — the controller's templates fetch 18 distinct
// /api/storage/* endpoints and backup-target was one of the only two with zero references, so a
// customer whose backup was unprotected was never told (felhom.eu
// audits/E2D-fresh-vm-2026-07-29.md §5.1). Server-rendered here rather than a 19th fetch: the state
// is already resolved when the page is built, and a banner that needs JavaScript to appear is one
// more thing that can silently not happen.
type BackupTargetView struct {
// Message is the customer copy. Empty is impossible here — a nil *BackupTargetView means
// "render nothing", so the template never has to decide.
Message string
// OfferPath is empty in every state except never-configured-with-an-eligible-drive.
OfferPath string
OfferLabel string
OfferText string
}
// backupTargetView resolves the state and reduces it to what the page renders, or nil for the two
// states that render NOTHING — healthy and unknown. Returning nil rather than an empty struct means
// a template typo cannot accidentally decorate a working box.
func (s *Server) backupTargetView(ctx context.Context) *BackupTargetView {
st := s.resolveBackupTargetState(ctx)
msg := degradedMessageFor(st)
if msg == "" {
return nil // healthy or unknown — a working configuration must look normal
}
v := &BackupTargetView{Message: msg}
if st.OfferPath != "" {
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, backupTargetOfferText
}
return v
}
// handleBackupTargetState serves GET /api/storage/backup-target — the JSON view of the same state the
// backups page renders server-side (see backupTargetView). Kept because `assign` needs a POST partner
// and the payload is a stable contract.
func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) {
st := s.resolveBackupTargetState(r.Context())
out := map[string]any{"known": st.Known}
@@ -0,0 +1,145 @@
package web
import (
"context"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-112 — SCENARIO E: THE SEAM. This is the test whose absence let E-2 ship.
//
// The endpoint was byte-correct the whole time. `resolveBackupTargetState` produced the right state,
// `degradedMessageFor` produced the right Hungarian, and v0.185.1 even added a test proving the route
// dispatched. None of that was ever reachable by a customer, because NOTHING fetched or rendered it:
// the controller's templates fetch 18 distinct /api/storage/* endpoints and backup-target was one of
// only two with zero references (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.1).
//
// So these tests deliberately do NOT call the resolver and assert its string — that proves the
// resolver, which was never broken. They drive `backupsHandler` over httptest and assert the RENDERED
// HTML, travelling handler → backupTargetView → resolveBackupTargetState → degradedMessageFor →
// the production "backups" template. Delete the one line in backupsHandler that sets
// data["BackupTarget"] and every assertion below fails, which is precisely the class of regression
// that shipped five times in this project.
// renderBackupsPage drives the REAL page handler and returns the HTML a browser would receive.
func renderBackupsPage(t *testing.T, primary string, disks []agentapi.DiskInfo, registerPath string) string {
t.Helper()
s := absentHarness(t, primary, disks, registerPath)
s.loadTemplates()
rr := httptest.NewRecorder()
s.backupsHandler(rr, httptest.NewRequest("GET", "/backups", nil))
if rr.Code != 200 {
t.Fatalf("backups page returned %d, want 200", rr.Code)
}
return rr.Body.String()
}
// C — configured, drive absent: the truthful copy reaches the page, and no offer does.
func TestBackupsPageRendersTheAbsentDriveCopy(t *testing.T) {
html := renderBackupsPage(t, "felhom-backup", []agentapi.DiskInfo{theVanishedDrive}, "/mnt/felhom-drives/mentes2")
if !strings.Contains(html, backupTargetAbsentText) {
t.Error("the absent-drive copy never reached the page — the customer is told nothing while " +
"their backup drive is missing (R-112: the state had no consumer at all)")
}
if strings.Contains(html, backupTargetDegradedText) {
t.Error("the page shows the SYSTEM-DISK copy for an absent configured drive (R-114)")
}
// Assert the CONTROL's markup, not the bare id: the page script always contains
// getElementById('backup-target-assign'), so a substring match on the id alone matches the
// script and reports a control that is not there. (This test caught exactly that on itself.)
if strings.Contains(html, `id="backup-target-assign"`) {
t.Error("an offer control rendered while the configured target is absent — the remedy is to " +
"reconnect that drive, not to pick another")
}
if strings.Contains(html, `id="backup-target-offer"`) {
t.Error("the offer block rendered while the configured target is absent")
}
}
// A — never configured: the copy AND the offer control both render, and the control carries the
// drive's path so accepting it can only ever assign that one.
func TestBackupsPageRendersTheOfferWhenNeverConfigured(t *testing.T) {
html := renderBackupsPage(t, builtinLocalTarget, []agentapi.DiskInfo{{
Name: "hdd1", MountPath: "/mnt/hdd1", GuestPath: "/mnt/felhom-drives/hdd1", Role: "user-data",
}}, "/mnt/felhom-drives/hdd1")
if !strings.Contains(html, backupTargetDegradedText) {
t.Error("the degraded copy never reached the page")
}
if !strings.Contains(html, backupTargetOfferText) {
t.Error("the offer copy never reached the page")
}
if !strings.Contains(html, `id="backup-target-assign"`) {
t.Error("no offer control rendered — the customer is told to attach a drive but given no way " +
"to assign the one they already have")
}
if !strings.Contains(html, `data-path="/mnt/felhom-drives/hdd1"`) {
t.Error("the offer control does not carry the offered drive's path")
}
// It is an OFFER: it must not submit itself.
if strings.Contains(html, "backup-target-assign.click()") || strings.Contains(html, "autosubmit") {
t.Error("the offer control auto-submits — declining must be possible by doing nothing")
}
}
// B — healthy renders NOTHING. Assert the absence of all three strings, not the absence of an error.
func TestBackupsPageRendersNothingWhenHealthy(t *testing.T) {
html := renderBackupsPage(t, "felhom-backup", []agentapi.DiskInfo{{
Name: "mentes2", MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2",
Role: "user-data", BackupTarget: true,
}}, "/mnt/felhom-drives/mentes2")
assertNoBackupTargetCopy(t, html, "a HEALTHY box grew a banner — a working configuration must "+
"look normal, or every dashboard carries a permanent notice and warnings stop being read")
}
// D — unknown renders NOTHING. Not being able to ask the agent is not evidence of degradation.
func TestBackupsPageRendersNothingWhenAgentUnreachable(t *testing.T) {
s := testServer(t)
if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/hdd1"}); err != nil {
t.Fatalf("register: %v", err)
}
// No tiersFn/disksFn seams and no configured endpoint → agentClient() fails → Known:false.
s.loadTemplates()
rr := httptest.NewRecorder()
s.backupsHandler(rr, httptest.NewRequest("GET", "/backups", nil))
assertNoBackupTargetCopy(t, rr.Body.String(), "an UNREACHABLE agent produced a customer warning — "+
"absence of an answer is not evidence of degradation (R-88 Part 2's mistake)")
}
func assertNoBackupTargetCopy(t *testing.T, html, why string) {
t.Helper()
for _, s := range []struct{ frag, name string }{
{backupTargetDegradedText, "the system-disk copy"},
{backupTargetAbsentText, "the absent-drive copy"},
{backupTargetOfferText, "the offer copy"},
} {
if strings.Contains(html, s.frag) {
t.Errorf("%s rendered: %s", s.name, why)
}
}
if strings.Contains(html, `id="backup-target-alert"`) {
t.Errorf("the banner element rendered: %s", why)
}
}
// The view is nil — not an empty struct — in the two silent states, so a template typo cannot
// accidentally decorate a working box with an empty alert box.
func TestBackupTargetViewIsNilWhenNothingShouldRender(t *testing.T) {
healthy := absentHarness(t, "felhom-backup", []agentapi.DiskInfo{{
MountPath: "/mnt/mentes2", GuestPath: "/mnt/felhom-drives/mentes2", Role: "user-data", BackupTarget: true,
}}, "")
if v := healthy.backupTargetView(context.Background()); v != nil {
t.Errorf("healthy returned a non-nil view %+v — nil is what makes the template render nothing", v)
}
unknown := testServer(t)
if v := unknown.backupTargetView(context.Background()); v != nil {
t.Errorf("unknown returned a non-nil view %+v", v)
}
}
+5
View File
@@ -840,6 +840,11 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
data["GuestBackup"] = s.loadGuestBackup(r.Context())
// R-112: the backup-target banner finally has a consumer. nil in the healthy and unknown states,
// so the template renders nothing at all — no badge, no reassurance. This is the seam whose
// absence made E-2's degraded banner and offer invisible to every customer.
data["BackupTarget"] = s.backupTargetView(r.Context())
// Customer-configurable backup window (v0.168.0): effective start + derived leg/gate times.
s.backupWindowData(data)
+7
View File
@@ -156,6 +156,13 @@ type Server struct {
disksFn func(context.Context) (agentapi.DisksResponse, error)
diskNotifyFn func(label string, attrs []string, critical bool)
// tiersFn is the sibling test seam for the agent's backup-tier view (nil → the real client's
// BackupTiers()). Added with R-114 so the backup-target state — which is the source of a
// customer-facing banner — is testable through its REAL resolver rather than only through the
// pure copy helper. Without it the resolver's own branching had no test at all, which is how the
// configured-but-absent case reached production saying the wrong thing.
tiersFn func(context.Context) (agentapi.TiersResponse, error)
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
mailShim MailShimController
@@ -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" .}}