monitoring page: storage labels follow the reader's language (W26, rehearsal 2026-10-20); unreleased
gates / gates (push) Successful in 1m11s

This commit is contained in:
2026-10-20 22:36:19 +02:00
parent 7ca59e2de3
commit 01c967e49e
8 changed files with 176 additions and 47 deletions
+14
View File
@@ -1,3 +1,17 @@
## Unreleased (2026-10-20) — the monitoring page's storage labels follow the reader's language (W26); text only
Found by the Tester-2 rehearsal of 2026-10-20 (felhom.eu `audits/rehearsal-2026-10-20/B6-failures/s16-README.md`, word
W26): on the English dashboard the monitoring page showed every storage label and purpose in Hungarian. No release in
this run (the system under test is frozen); it ships with the next controller version. **MinAgent: unchanged.**
- `internal/web/agent_host_metrics_handler.go`: `enrichHostStorageTargets` takes a message lookup; `ServeHostMetricsAPI`
passes the request's language. Twelve keys `host_storage.*` in `hu.json` / `en.json`; the Hungarian text is
byte-identical to the old Go literals (`scripts/i18n_go_keys.json`, go-parity gate green).
- Tests: `TestEnrichHostStorageTargets_FollowsTheLanguage` (every type, both languages, never a key, never the Hungarian
text in English) and `TestServeHostMetricsAPI_LabelsFollowTheRequestLanguage` (through a fake agent behind the real
pinned client, `?lang=en|hu`). Each was seen RED first: a Hungarian literal put back for `pbs`, and the handler's
language forced to `hu`.
## v0.336.0 — a failed off-site restore puts the app back exactly as it was and starts it (R-893 option A, operator D20); the held sentence says only what is certain (R-1169) (2026-10-20)
**MinAgent: 0.158.1** — the floor's, carried; no agent change. No golden (`09` §3 decision 250). Task: the night brief
+10 -25
View File
@@ -1,29 +1,14 @@
# REPORT — controller v0.336.0: a failed off-site restore puts the app back exactly (R-893 option A) — 2026-10-20
# REPORT — the monitoring page's storage labels follow the reader's language (W26) — 2026-10-20, unreleased
**Task:** night brief part 2 (run by day, 2026-10-20), Parts 2 and 7. Operator answer: „D20 A". Architecture:
`felhom.eu/documentation/architecture/07-backup-architecture.md` §6.3; `09` §3 decision 192.
**Task:** the Tester-2 rehearsal of 2026-10-20 (operator brief, „small text fixes allowed, committed, not released").
Found in B6 #16 (the dashboard in English): the monitoring page showed the storage labels in Hungarian.
Architecture: `felhom.eu/documentation/architecture/10-localisation.md` (Go-side messages are keys).
**What changed:** `internal/backup/offbox_restore_undo.go` + `offbox_reconstitute.go`. Before the stop, the live volumes the
snapshot will replace are measured and the copy is added to the restore's own free-space need (too little room or an
unmeasurable volume refuses with nothing stopped). Right after the stop they are tarred into
`<namespace>/backups/restore-undo/<app>/<stamp>/`. On any failure after a volume or the definition moved, the copies are poured
back, a volume the snapshot added is removed, the live definition is written back and the app starts; the household reads
„…sikertelen. Nem változott semmi…"; the operator gets `backup_run_failures` leg `restore-put-back`. A put-back that fails holds
the app and keeps the copy. On success the copy is removed. R-1169: the held sentence says only what is certain.
**What changed:** `internal/web/agent_host_metrics_handler.go` — the labels and purposes come from twelve `host_storage.*`
keys in the request's language; the Hungarian text is unchanged byte for byte.
**Tests:** 7 new (`r893_putback_test.go`) + 4 moved to option A (`r893_mixed_restore_test.go`); nine mutations, each red
(felhom.eu `audits/night2-2026-10-20/P2/redproof-unit.txt`). `controller_gates.py --fast` green; `go test ./...` green.
**Tests:** two new (lookup, and handler wiring through a fake agent); each seen red, then green. `go test ./internal/web/
./internal/i18n/` green; `controller_gates.py --fast` green.
**Live proof on 9202** (rc.1, a helper with the brief's fences; `audits/night2-2026-10-20/P2/live/`): Rallly + postgres 18, a
forced failure after the volume was replaced → the copy (69 MB, 0.9 s) poured back: 36 tables, markers A–D, the table only the
backup had ABSENT, schema/data/row-count md5 EQUAL to before, the app running, no hold, the copy gone. Then a normal restore of
the same snapshot: the snapshot's content, the app running, the copy gone. 9202 back on 0.331.0 as found.
**Release:** image `sha256:93cd67d871125877e935a024765075b5a0b7c0e7683e1165976ff7a654fe8d73` (clean clone `93fb251`); per-box
floors → the three test boxes on 0.336.0 within a minute → box walk green (0 unhealthy, 0 allow-list refusals, catalog in
sync, https-only 28 OK, „Fájlok ↗" one sign-in on demo-hp and Tester 1) → global floor 0.336.0 / MinAgent 0.158.1 at 08:54:35
UTC; per-box floors cleared. Evidence `audits/night2-2026-10-20/P7/`.
**Not measured live:** the version-change branch, the `_files` sentence, the refusals before the stop, a failing put-back —
each pinned by a red-proved unit test. **Found:** a successful put-back is logged at ERROR by the web handler, and an empty
`backups/restore-undo/` folder stays — one register row (small; a version is pushed once, so it waits for the next release).
**Not done:** no release, no image, no floor change — the system under test stays frozen during the rehearsal. It ships
with the next controller version. The rehearsal's own report: felhom.eu `REPORT-rehearsal-2026-10-20.md`.
+13 -1
View File
@@ -2792,5 +2792,17 @@
"app_info.plex_claim_btn": "Connect Plex",
"err.plex_claim.bad_code": "This is not a Plex code. A code starts with: claim-",
"err.plex_claim.refused": "Plex did not accept the code. It may have run out: get a new one at plex.tv/claim.",
"err.plex_claim.not_claimed": "Plex does not report the connection yet. Try again in a minute."
"err.plex_claim.not_claimed": "Plex does not report the connection yet. Try again in a minute.",
"host_storage.usb.label": "External data drive (USB)",
"host_storage.local_dir.label": "External data drive",
"host_storage.userdata.purpose": "Your apps’ data (files, media library) is on this drive.",
"host_storage.system.label": "Internal SSD – system and apps",
"host_storage.system.purpose": "Space for the operating system and the installed apps.",
"host_storage.local.label": "Internal disk – templates and local backups",
"host_storage.local.purpose": "System templates and local backups.",
"host_storage.pbs.label": "Off-site backup",
"host_storage.pbs.purpose": "Encrypted backups kept away from your home.",
"host_storage.nfs.label": "Network backup (NFS)",
"host_storage.cifs.label": "Network backup (SMB)",
"host_storage.network.purpose": "Backups kept on a network storage."
}
+13 -1
View File
@@ -2780,5 +2780,17 @@
"app_info.plex_claim_btn": "Plex összekapcsolása",
"err.plex_claim.bad_code": "Ez nem Plex-kód. A kód így kezdődik: claim-",
"err.plex_claim.refused": "A Plex nem fogadta el a kódot. Lehet, hogy lejárt: kérj újat a plex.tv/claim oldalon.",
"err.plex_claim.not_claimed": "A Plex még nem jelzi, hogy össze van kapcsolva. Próbáld újra egy perc múlva."
"err.plex_claim.not_claimed": "A Plex még nem jelzi, hogy össze van kapcsolva. Próbáld újra egy perc múlva.",
"host_storage.usb.label": "Külső adattároló (USB)",
"host_storage.local_dir.label": "Külső adattároló",
"host_storage.userdata.purpose": "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak.",
"host_storage.system.label": "Belső SSD – rendszer és alkalmazások",
"host_storage.system.purpose": "Az operációs rendszer és a telepített alkalmazások tárhelye.",
"host_storage.local.label": "Belső lemez – sablonok és helyi mentések",
"host_storage.local.purpose": "Rendszersablonok és helyi biztonsági mentések.",
"host_storage.pbs.label": "Távoli biztonsági mentés",
"host_storage.pbs.purpose": "Titkosított, telephelyen kívüli biztonsági mentések.",
"host_storage.nfs.label": "Hálózati mentés (NFS)",
"host_storage.cifs.label": "Hálózati mentés (SMB)",
"host_storage.network.purpose": "Hálózati tárolón őrzött biztonsági mentések."
}
@@ -38,9 +38,11 @@ func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) {
return
}
// The agent enumerates storages via `pvesm` in a non-deterministic order, so #host-storage-bars
// reordered on every poll (item 2). Stabilise the order Go-side and attach friendly Hungarian
// labels + a one-line purpose per entry — display-only; we NEVER rename the PVE storage ids.
enrichHostStorageTargets(resp.StorageTargets)
// reordered on every poll (item 2). Stabilise the order Go-side and attach friendly labels + a
// one-line purpose per entry in the reader's language (W26, rehearsal 2026-10-20: they were
// Hungarian on the English dashboard) — display-only; we NEVER rename the PVE storage ids.
lang := s.langFor(r)
enrichHostStorageTargets(resp.StorageTargets, func(key string) string { return s.msgLang(lang, key) })
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
@@ -48,7 +50,8 @@ func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) {
// order (user-data → system+apps → backup → other; alphabetical by id within a tier) and fills in
// a friendly label + purpose per entry. Mirrors the disk-overview's sortDisksForView contract:
// a Go-side ordering beats relying on the agent's enumeration order or template JS.
func enrichHostStorageTargets(targets []agentapi.StorageTarget) {
// msg turns a message key into the reader's text.
func enrichHostStorageTargets(targets []agentapi.StorageTarget, msg func(key string) string) {
sort.SliceStable(targets, func(i, j int) bool {
if ri, rj := storageTypeRank(targets[i].Type), storageTypeRank(targets[j].Type); ri != rj {
return ri < rj
@@ -56,7 +59,7 @@ func enrichHostStorageTargets(targets []agentapi.StorageTarget) {
return targets[i].Name < targets[j].Name
})
for i := range targets {
label, purpose := storageLabelAndPurpose(targets[i])
label, purpose := storageLabelAndPurpose(targets[i], msg)
targets[i].Label = label
targets[i].Purpose = purpose
}
@@ -79,24 +82,24 @@ func storageTypeRank(typ string) int {
}
}
// storageLabelAndPurpose maps a storage target to a friendly Hungarian label + one-line purpose.
// Falls back to the raw id for unrecognised types. The raw id stays in Name (rendered muted).
func storageLabelAndPurpose(t agentapi.StorageTarget) (string, string) {
// storageLabelAndPurpose maps a storage target to a friendly label + one-line purpose (message keys
// host_storage.*). Falls back to the raw id for unrecognised types. The raw id stays in Name (rendered muted).
func storageLabelAndPurpose(t agentapi.StorageTarget, msg func(key string) string) (string, string) {
switch t.Type {
case "usb":
return "Külső adattároló (USB)", "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak."
return msg("host_storage.usb.label"), msg("host_storage.userdata.purpose")
case "local-dir":
return "Külső adattároló", "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak."
return msg("host_storage.local_dir.label"), msg("host_storage.userdata.purpose")
case "lvmthin", "lvm":
return "Belső SSD – rendszer és alkalmazások", "Az operációs rendszer és a telepített alkalmazások tárhelye."
return msg("host_storage.system.label"), msg("host_storage.system.purpose")
case "local":
return "Belső lemez – sablonok és helyi mentések", "Rendszersablonok és helyi biztonsági mentések."
return msg("host_storage.local.label"), msg("host_storage.local.purpose")
case "pbs":
return "Távoli biztonsági mentés", "Titkosított, telephelyen kívüli biztonsági mentések."
return msg("host_storage.pbs.label"), msg("host_storage.pbs.purpose")
case "nfs":
return "Hálózati mentés (NFS)", "Hálózati tárolón őrzött biztonsági mentések."
return msg("host_storage.nfs.label"), msg("host_storage.network.purpose")
case "cifs":
return "Hálózati mentés (SMB)", "Hálózati tárolón őrzött biztonsági mentések."
return msg("host_storage.cifs.label"), msg("host_storage.network.purpose")
default:
return t.Name, ""
}
@@ -1,11 +1,23 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// msgIn returns the real message bundle's text for lang — the same lookup the handler's msgLang does.
func msgIn(t *testing.T, lang string) func(string) string {
t.Helper()
b, err := i18n.Shared()
if err != nil {
t.Fatalf("bundle: %v", err)
}
return func(key string) string { return b.Msg(lang, key) }
}
// enrichHostStorageTargets must produce a STABLE order regardless of how the agent enumerated the
// storages (the #host-storage-bars reorder bug, item 2), and attach a friendly label + purpose per
// entry without ever mutating the raw PVE id (Name).
@@ -17,7 +29,7 @@ func TestEnrichHostStorageTargets_OrderAndLabels(t *testing.T) {
{Name: "felhom-usb", Type: "usb"},
{Name: "local-lvm", Type: "lvmthin"},
}
enrichHostStorageTargets(targets)
enrichHostStorageTargets(targets, msgIn(t, "hu"))
wantOrder := []string{"felhom-usb", "local-lvm", "local", "felhom-pbs"}
for i, want := range wantOrder {
@@ -50,8 +62,8 @@ func TestEnrichHostStorageTargets_Stable(t *testing.T) {
}
}
a, b := mk(), mk()
enrichHostStorageTargets(a)
enrichHostStorageTargets(b)
enrichHostStorageTargets(a, msgIn(t, "hu"))
enrichHostStorageTargets(b, msgIn(t, "hu"))
for i := range a {
if a[i].Name != b[i].Name {
t.Fatalf("non-deterministic at %d: %q vs %q", i, a[i].Name, b[i].Name)
@@ -66,7 +78,7 @@ func TestEnrichHostStorageTargets_Stable(t *testing.T) {
// An unrecognised type falls back to the raw id and an empty purpose.
func TestEnrichHostStorageTargets_UnknownType(t *testing.T) {
targets := []agentapi.StorageTarget{{Name: "weird-store", Type: "zfspool"}}
enrichHostStorageTargets(targets)
enrichHostStorageTargets(targets, msgIn(t, "hu"))
if targets[0].Label != "weird-store" || targets[0].Purpose != "" {
t.Errorf("unknown type: label=%q purpose=%q, want raw id + empty", targets[0].Label, targets[0].Purpose)
}
@@ -79,3 +91,33 @@ func names(ts []agentapi.StorageTarget) []string {
}
return out
}
// W26 (rehearsal 2026-10-20 B6 #16): on the English dashboard the monitoring page showed the storage
// labels and purposes in Hungarian. Every known storage type must come out in the reader's language:
// English text for "en", and never a key name (a missing key) in either language.
func TestEnrichHostStorageTargets_FollowsTheLanguage(t *testing.T) {
mk := func() []agentapi.StorageTarget {
return []agentapi.StorageTarget{
{Name: "u", Type: "usb"}, {Name: "d", Type: "local-dir"}, {Name: "local-lvm", Type: "lvmthin"},
{Name: "local", Type: "local"}, {Name: "felhom-pbs", Type: "pbs"}, {Name: "n", Type: "nfs"}, {Name: "c", Type: "cifs"},
}
}
en, hu := mk(), mk()
enrichHostStorageTargets(en, msgIn(t, "en"))
enrichHostStorageTargets(hu, msgIn(t, "hu"))
for i := range en {
for _, s := range []string{en[i].Label, en[i].Purpose, hu[i].Label, hu[i].Purpose} {
if s == "" || strings.HasPrefix(s, "host_storage.") {
t.Errorf("%s (%s): missing text %q", en[i].Name, en[i].Type, s)
}
}
if en[i].Label == hu[i].Label || en[i].Purpose == hu[i].Purpose {
t.Errorf("%s (%s): the English text is the Hungarian one: %q / %q", en[i].Name, en[i].Type, en[i].Label, en[i].Purpose)
}
}
for _, tg := range en {
if tg.Type == "lvmthin" && tg.Label != "Internal SSD – system and apps" {
t.Errorf("lvmthin English label = %q", tg.Label)
}
}
}
@@ -0,0 +1,49 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// W26 (rehearsal 2026-10-20 B6 #16), pinned at the handler: GET /api/host-metrics answers the storage
// labels in the REQUEST's language. TestEnrichHostStorageTargets_FollowsTheLanguage pins the lookup;
// this pins the wiring (the reader's language reaching it), through a fake agent behind the real pinned client.
func TestServeHostMetricsAPI_LabelsFollowTheRequestLanguage(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /host/metrics", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":1,"host":{"node":"n"},"storage_targets":[
{"name":"local-lvm","type":"lvmthin","state":"attached","reachable":true},
{"name":"felhom-pbs","type":"pbs","state":"attached","reachable":true}]}}`))
})
agent := httptest.NewTLSServer(mux)
defer agent.Close()
sum := sha256.Sum256(agent.Certificate().Raw)
s := testServer(t)
s.cfg = &config.Config{LocalAPI: config.LocalAPIConfig{
Endpoint: strings.TrimPrefix(agent.URL, "https://"), Token: "tok", Fingerprint: hex.EncodeToString(sum[:])}}
get := func(lang string) string {
rec := httptest.NewRecorder()
s.ServeHostMetricsAPI(rec, httptest.NewRequest(http.MethodGet, "/api/host-metrics?lang="+lang, nil))
if rec.Code != http.StatusOK {
t.Fatalf("lang=%s: status %d body %s", lang, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
en, hu := get("en"), get("hu")
if !strings.Contains(en, "Internal SSD") || !strings.Contains(en, "Off-site backup") {
t.Errorf("English answer lacks the English labels: %s", en)
}
if strings.Contains(en, "Belső") || strings.Contains(en, "Távoli") {
t.Errorf("English answer carries Hungarian labels: %s", en)
}
if !strings.Contains(hu, "Belső SSD") || !strings.Contains(hu, "Távoli biztonsági mentés") {
t.Errorf("Hungarian answer lacks the Hungarian labels: %s", hu)
}
}
+13 -1
View File
@@ -1321,5 +1321,17 @@
"restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ",
"restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.",
"restore.refuse.files.second_drive": "A fájlok a második meghajtó másolatából állíthatók vissza: „Fájlok visszaállítása”.",
"restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót."
"restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.",
"host_storage.usb.label": "Külső adattároló (USB)",
"host_storage.local_dir.label": "Külső adattároló",
"host_storage.userdata.purpose": "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak.",
"host_storage.system.label": "Belső SSD – rendszer és alkalmazások",
"host_storage.system.purpose": "Az operációs rendszer és a telepített alkalmazások tárhelye.",
"host_storage.local.label": "Belső lemez – sablonok és helyi mentések",
"host_storage.local.purpose": "Rendszersablonok és helyi biztonsági mentések.",
"host_storage.pbs.label": "Távoli biztonsági mentés",
"host_storage.pbs.purpose": "Titkosított, telephelyen kívüli biztonsági mentések.",
"host_storage.nfs.label": "Hálózati mentés (NFS)",
"host_storage.cifs.label": "Hálózati mentés (SMB)",
"host_storage.network.purpose": "Hálózati tárolón őrzött biztonsági mentések."
}