monitoring page: storage labels follow the reader's language (W26, rehearsal 2026-10-20); unreleased
gates / gates (push) Successful in 1m11s
gates / gates (push) Successful in 1m11s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user