diff --git a/CHANGELOG.md b/CHANGELOG.md index b0beada..34078a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +## v0.258.0 — the last four Hungarian things an English household met (2026-09-20, R-589/R-590/R-572/R-573) + +**MinAgent: 0.131.0** (unchanged). The Hungarian pages are byte-identical — the parity fixtures and +the Go-parity gate both hold. + +Slice 5's live proof found the residue this release clears: on an English app page the only +Felhom-authored Hungarian left was a badge and a sentence about backups. Both are built in **Go**, +which is why neither the template parity fixtures nor `TestI18nEnglishPages` could see them. + +- **R-589 — the update badge.** `updateBadge` and `lifecycleBadge` gain bundle-backed forms in + `localeFuncs`, reusing `compareInstalledToTemplate` and `EffectiveLifecycle` so **the decision + cannot drift between the languages — only the words do**; the badge's `Class` is asserted equal + across languages for the same stack. The Hungarian forms in `templateFuncMap` are untouched, which + is the parity guarantee, and `TestLocaleFuncsHungarianBundleMatchesFuncMap` now pins that `hu.json` + says exactly what they say — because `i18n_go_keys.json` cites that test for these keys. + `lifecycleBadge` was not in the row; it is the same builder one function away, and leaving a known + identical defect there would have been a choice. +- **R-590 — the data-folder sentence, which is a PROMISE ABOUT THE CUSTOMER'S FILES.** It said, in + Hungarian and under an already-English folder label, that a drop-zone is temporary and unbacked. An + English household who cannot read it may leave originals in a folder the backup filter discards at + every tier. `consequenceFor` takes its lookup as a parameter, and the test asserts **the consequence + in both languages**: the excluded folder says temporary AND unbacked, a kept folder promises a + backup, an unclassified one promises nothing, and the two languages do not produce the same string + (which would mean the English fell back). The „%.1f GB szabad" free-space line went with it. +- **R-573 — the two channel banners.** The checker already CLASSIFIES the fault, so the banner is + keyed by that classification rather than by the sentence it happened to compose. The composed + sentence stays as the **fail-open fallback**: an unmapped reason renders it verbatim rather than a + blank banner or a raw key. These are the two most operationally important banners there are — the + 2026-07-25 outage (R-77) ran 17.5 hours with this banner as the only signal. +- **R-572 — NOT what the row said, and the deletion is the finding.** The row said a template calling + `pruneLabel`/`nextPruneLabel` renders „vasárnap" on an English page. Measured: **no template and no + Go file called either.** They were dead func-map entries returning Hungarian. Translating dead code + would have added machinery with no reader and a test pinning a fiction, so they are **deleted**. + Deletion is the fail-loud direction, and that was proven rather than asserted: a template naming the + removed function makes `loadTemplates` panic at startup with `function "pruneLabel" not defined`. + `fmtDuration`, named in the same row, produces no Hungarian at all ("< 1s", "5s", "2m 3s") and is + left alone. + +**Red-proofs, each seen to fail and then revert:** the `updateBadge` override removed from +`localeFuncs` (English read „Naprakész"); `MessageKey` dropped from the banner (English read the +Hungarian sentence); the folder sentence hard-coded back to Hungarian (the English arm convicted on +three assertions); one Hungarian byte changed in a badge key (the funcmap-equality test named both +sides); and a template naming a deleted helper (startup panic). + +**The fourth red-proof says something about the gate, not just the code:** the Go-parity gate did +NOT convict that changed byte, because `localeFuncs` keys sit under `_preexisting` and are +deliberately not measured against the base capture. The citation to the test is what carries them — +so the test has to be real, and it is. + ## v0.257.0 — the app catalog can speak English (2026-09-20, R-560 slice 5 Part A) **MinAgent: 0.131.0** (unchanged). diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index feeff70..718703b 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1325,7 +1325,7 @@ func main() { } sched.Every("hub-report", pushInterval, func(ctx context.Context) error { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) - r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub + r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub r.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3) if err := hubPusher.Push(r); err != nil { return err @@ -1432,7 +1432,7 @@ func main() { if hubPusher != nil { if cfg.Hub.Enabled { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) - r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub + r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub r.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3) var pushErr error for attempt := 1; attempt <= 3; attempt++ { @@ -1515,7 +1515,7 @@ func main() { if hubPusher != nil && cfg.Hub.Enabled { fireReport := func() error { rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) - rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub + rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub rep.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3) return hubPusher.Push(rep) } @@ -1708,7 +1708,7 @@ func main() { // Startup-only is sufficient and correct: both files are read at boot and neither changes under a // running controller, so a periodic re-check would add noise without adding signal. if d := bootstrap.DetectEndpointDrift(*configPath, cfg, logger); d != nil { - alertMgr.SetEndpointDriftAlert(true, d.HungarianMessage()) + alertMgr.SetEndpointDriftAlert(true, "alert.endpoint_drift", d.HungarianMessage()) if notifier != nil { notifier.NotifyEndpointDrift(d.EnglishMessage(), d.FingerprintAgrees) } @@ -1720,7 +1720,7 @@ func main() { if hubPusher != nil { dc.TriggerHubReportPush = func() error { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) - r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub + r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub r.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3) return hubPusher.Push(r) } @@ -3135,8 +3135,15 @@ type channelSink struct { alertMgr *web.AlertManager } -func (s channelSink) SetDashboard(down bool, _ channelhealth.Reason, msg string) { - s.alertMgr.SetAgentChannelAlert(down, msg) +// R-573: the checker already CLASSIFIES the fault, so the banner is keyed by that classification +// rather than by the sentence it happened to compose. An unmapped reason passes an empty key and the +// AlertManager falls back to the composed sentence — never a blank banner. +func (s channelSink) SetDashboard(down bool, reason channelhealth.Reason, msg string) { + key := "" + if down && reason != "" { + key = "alert.agent_channel." + string(reason) + } + s.alertMgr.SetAgentChannelAlert(down, key, msg) } func (s channelSink) NotifyDown(reason channelhealth.Reason, eventType, severity, msg string) { s.notifier.NotifyAgentChannelDown(string(reason), eventType, severity, msg) diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 8c1aed9..f831e1b 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2286,5 +2286,26 @@ "event.crossdrive_completed": "Second backup copy finished: %s", "event.crossdrive_failed": "Second backup copy failed: %s", "event.disaster_recovery_started": "Disaster recovery started (%d app(s))", - "event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)" + "event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)", + "badge.update.current": "Up to date", + "badge.update.current.title": "This app is running the newest version available.", + "badge.update.behind": "Update available", + "badge.update.behind.today": " — today", + "badge.update.behind.title": "A newer version of this app is available. Select the Update button to start it.", + "badge.lifecycle.abandoned": "No longer maintained", + "badge.lifecycle.abandoned.title": "The people who made this app stopped working on it. The installed version keeps working, but updates and security fixes no longer arrive for it.", + "datapath.consequence.import_excluded": "Copy the files to be processed in here. The app reads them and then deletes them from here — this folder is temporary and is not backed up.", + "datapath.consequence.excluded": "This folder is temporary and is not backed up.", + "datapath.consequence.kept": "This is where your files are kept. It is backed up.", + "datapath.free_space": "%.1f GB free", + "alert.agent_channel.construction_error": "The storage agent's connection is set up wrongly.", + "alert.agent_channel.pin_mismatch": "The storage agent's certificate has changed.", + "alert.agent_channel.unauthorized": "The storage agent refused access.", + "alert.agent_channel.misconfigured": "The storage agent's address cannot be resolved.", + "alert.agent_channel.unreachable": "The storage agent cannot be reached.", + "alert.agent_channel.timeout": "The storage agent is not answering.", + "alert.agent_channel.unknown": "The storage agent cannot be reached.", + "alert.endpoint_drift": "The storage agent's address in the settings is out of date.", + "badge.update.behind.days.one": " — %d day ago", + "badge.update.behind.days.other": " — %d days ago" } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index b182319..a521072 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2275,5 +2275,25 @@ "event.crossdrive_completed": "Másodlagos mentés elkészült: %s", "event.crossdrive_failed": "Másodlagos mentés sikertelen: %s", "event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)", - "event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)" + "event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", + "badge.update.current": "Naprakész", + "badge.update.current.title": "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.", + "badge.update.behind": "Frissítés elérhető", + "badge.update.behind.today": " — ma", + "badge.update.behind.title": "Újabb változat érhető el ehhez az alkalmazáshoz. A frissítés indításához nyomd meg a Frissítés gombot.", + "badge.lifecycle.abandoned": "Nem karbantartott", + "badge.lifecycle.abandoned.title": "Az alkalmazás fejlesztője felhagyott a fejlesztéssel. A telepített verzió továbbra is használható, de frissítések és biztonsági javítások már nem érkeznek hozzá.", + "datapath.consequence.import_excluded": "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ez a mappa átmeneti, és nem készül róla biztonsági mentés.", + "datapath.consequence.excluded": "Ez a mappa átmeneti, és nem készül róla biztonsági mentés.", + "datapath.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.", + "datapath.free_space": "%.1f GB szabad", + "alert.agent_channel.construction_error": "A tárolókezelő ügynök kapcsolata hibásan beállítva.", + "alert.agent_channel.pin_mismatch": "A tárolókezelő ügynök tanúsítványa megváltozott.", + "alert.agent_channel.unauthorized": "A tárolókezelő ügynök elutasította a hozzáférést.", + "alert.agent_channel.misconfigured": "A tárolókezelő ügynök címe nem feloldható.", + "alert.agent_channel.unreachable": "A tárolókezelő ügynök nem elérhető.", + "alert.agent_channel.timeout": "A tárolókezelő ügynök nem válaszol.", + "alert.agent_channel.unknown": "A tárolókezelő ügynök nem elérhető.", + "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.", + "badge.update.behind.days": " — %d napja" } diff --git a/controller/internal/web/alerts.go b/controller/internal/web/alerts.go index 1657689..f560152 100644 --- a/controller/internal/web/alerts.go +++ b/controller/internal/web/alerts.go @@ -102,9 +102,15 @@ func (am *AlertManager) SetHubPushStatus(fn func() HubPushStatusData) { } // SetAgentChannelAlert sets (down=true) or clears (down=false) the controller→agent channel-down -// dashboard banner. Called by the channel-health checker each probe (idempotent). msg is the short -// Hungarian display line. -func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) { +// dashboard banner. Called by the channel-health checker each probe (idempotent). +// +// R-573 (v0.258.0): it takes the checker's CLASSIFIED REASON as a bundle key, not just the composed +// sentence, so the banner follows the household's language like every other alert. `msg` stays as +// the FAIL-OPEN fallback: `Alert.render` uses it verbatim when MessageKey is empty, so a reason +// nobody mapped still shows the checker's Hungarian sentence rather than a blank banner or a raw +// key. These two are the most operationally important banners there are — the 2026-07-25 outage +// (R-77) was 17.5 hours long and this banner was the only signal. +func (am *AlertManager) SetAgentChannelAlert(down bool, msgKey, msg string) { am.mu.Lock() defer am.mu.Unlock() if !down { @@ -114,7 +120,8 @@ func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) { am.agentChannelAlert = &Alert{ ID: "agent-channel-down", Level: "error", - Message: msg, // composed by the channel-health checker -- R-573 + Message: msg, // fail-open fallback when msgKey is empty -- R-573 + MessageKey: msgKey, Link: "/settings", LinkTextKey: "alert.link.settings", } @@ -126,7 +133,7 @@ func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) { // generic "agent unreachable" banner was the ONLY signal, and it looked like a dead agent. The two // can also be true at once — a drifted endpoint usually CAUSES the channel to be down — so folding // them together would hide the actionable one behind the symptom. -func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) { +func (am *AlertManager) SetEndpointDriftAlert(drift bool, msgKey, msg string) { am.mu.Lock() defer am.mu.Unlock() if !drift { @@ -136,7 +143,8 @@ func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) { am.endpointDriftAlert = &Alert{ ID: "local-api-endpoint-drift", Level: "error", - Message: msg, // composed by the endpoint-drift checker -- R-573 + Message: msg, // fail-open fallback when msgKey is empty -- R-573 + MessageKey: msgKey, Link: "/settings", LinkTextKey: "alert.link.settings", } diff --git a/controller/internal/web/datapath_card.go b/controller/internal/web/datapath_card.go index ed6f451..660f110 100644 --- a/controller/internal/web/datapath_card.go +++ b/controller/internal/web/datapath_card.go @@ -1,7 +1,6 @@ package web import ( - "fmt" "path/filepath" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" @@ -30,16 +29,20 @@ type DataPathCard struct { // can never drift from what the backup engines actually do. `excluded` means the tier filter drops it // at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is // temporary and unbacked. Saying anything softer would be a false promise about their files. -func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string { +// R-589/R-590 (v0.258.0): `msg` renders the sentence in the HOUSEHOLD'S language. It is a +// parameter rather than a package-level lookup because this sentence is a PROMISE ABOUT THE +// CUSTOMER'S FILES — an English household reading „nem készül róla biztonsági mentés" as decoration +// may leave originals in a drop-zone the backup filter discards at every tier. Found live on +// 2026-09-20 with the folder's own label already English above it, so the line read half and half. +func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole, msg func(key string) string) string { switch class { case appbackup.ClassExcluded: if role == stacks.RoleImport { - return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " + - "ez a mappa átmeneti, és nem készül róla biztonsági mentés." + return msg("datapath.consequence.import_excluded") } - return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés." + return msg("datapath.consequence.excluded") case appbackup.ClassMandatory, appbackup.ClassOptional: - return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla." + return msg("datapath.consequence.kept") default: // No classification (legacy app, or a bind with no backup block). Say nothing rather than // guess — an unverified backup promise is worse than no sentence at all. @@ -51,7 +54,8 @@ func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string // // classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence // line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead. -func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard { +func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCard { + msg := func(key string) string { return s.msgLang(lang, key) } if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 { return nil } @@ -71,7 +75,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard { if s.stackMgr != nil { if root := s.stackMgr.GetImportRoot(); root != "" { if du := system.GetDiskUsage(root); du != nil { - freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB) + freeSpace = s.msgLang(lang, "datapath.free_space", du.AvailGB) } } } @@ -100,7 +104,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard { card := DataPathCard{ Label: dp.Label, Link: link, - Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role), + Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role, msg), IsImport: isImport, } if isImport { diff --git a/controller/internal/web/datapath_card_test.go b/controller/internal/web/datapath_card_test.go index c8880d8..a3ca816 100644 --- a/controller/internal/web/datapath_card_test.go +++ b/controller/internal/web/datapath_card_test.go @@ -5,6 +5,7 @@ import ( "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -27,7 +28,7 @@ func TestAppInfo_DataPathCardRenders(t *testing.T) { cards := []DataPathCard{{ Label: "Beolvasandó dokumentumok", Link: importFolderLink("demo-felhom.eu", "paperless"), - Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport), + Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport, bundleMsg(t, "hu")), IsImport: true, FreeSpace: "44.8 GB szabad", }} @@ -69,26 +70,64 @@ func TestAppInfo_NoDataPathCardsRendersNothing(t *testing.T) { } } +// bundleMsg is the production lookup the handler uses, bound to one language. +func bundleMsg(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) } +} + // Fork-4: the consequence line is CLASS-driven, so the UI can never promise a backup the engines do // not make. `excluded` is dropped at every tier — it must say so. +// +// R-590 (v0.258.0): the sentence now follows the household's language, so the CONSEQUENCE is +// asserted in BOTH — the mechanism (does it read the bundle?) is not the property that matters. It +// is a promise about the customer's files: an English household that cannot read „nem készül róla +// biztonsági mentés" may leave originals in a drop-zone the backup filter discards at every tier. func TestConsequenceIsClassDriven(t *testing.T) { - imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport) - if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") { - t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp) + cases := []struct { + lang string + tempAndUnbacked []string // an excluded import folder must say BOTH of these + promise string // a kept folder must say this + denial string // and must NOT say this + }{ + {"hu", []string{"törli innen", "nem készül róla biztonsági mentés"}, + "Biztonsági mentés készül", "nem készül"}, + {"en", []string{"deletes them from here", "is not backed up"}, + "It is backed up", "not backed up"}, } - for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} { - lib := consequenceFor(cls, stacks.RoleLibrary) - if !strings.Contains(lib, "Biztonsági mentés készül") { - t.Errorf("class %q must promise a backup: %q", cls, lib) + for _, c := range cases { + msg := bundleMsg(t, c.lang) + imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport, msg) + for _, want := range c.tempAndUnbacked { + if !strings.Contains(imp, want) { + t.Errorf("[%s] an excluded import folder must say %q: %q", c.lang, want, imp) + } } - if strings.Contains(lib, "nem készül") { - t.Errorf("class %q must NOT say unbacked: %q", cls, lib) + for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} { + lib := consequenceFor(cls, stacks.RoleLibrary, msg) + if !strings.Contains(lib, c.promise) { + t.Errorf("[%s] class %q must promise a backup: %q", c.lang, cls, lib) + } + if strings.Contains(lib, c.denial) { + t.Errorf("[%s] class %q must NOT say unbacked: %q", c.lang, cls, lib) + } + } + // An unclassified bind says NOTHING rather than guessing — an unverified backup promise + // about a customer's files is worse than no sentence. In every language. + if got := consequenceFor("", stacks.RoleLibrary, msg); got != "" { + t.Errorf("[%s] an unclassified path must produce no promise, got %q", c.lang, got) } } - // An unclassified bind says NOTHING rather than guessing — an unverified backup promise about a - // customer's files is worse than no sentence. - if got := consequenceFor("", stacks.RoleLibrary); got != "" { - t.Errorf("an unclassified path must produce no promise, got %q", got) + // And the two languages must not say the same thing — that would mean the bundle fell back and + // the English household is reading Hungarian, which is the defect this row was opened for. + hu := consequenceFor(appbackup.ClassMandatory, stacks.RoleLibrary, bundleMsg(t, "hu")) + en := consequenceFor(appbackup.ClassMandatory, stacks.RoleLibrary, bundleMsg(t, "en")) + if hu == en { + t.Errorf("the English consequence line fell back to Hungarian: %q", en) } } @@ -99,7 +138,7 @@ func TestBuildDataPathCards_UndeployedYieldsNothing(t *testing.T) { Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{ {Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"}, }}} - if got := s.buildDataPathCards(st); got != nil { + if got := s.buildDataPathCards(st, "hu"); got != nil { t.Errorf("an undeployed app must get no folder card, got %v", got) } } diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index 4fa0036..afa1250 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -6,7 +6,6 @@ import ( "hash/fnv" "html/template" "regexp" - "strings" "sync" "time" "unicode" @@ -360,37 +359,16 @@ func (s *Server) templateFuncMap() template.FuncMap { } return lt.Format("2006-01-02") + " " + timeStr }, - "pruneLabel": func(s string) string { - switch strings.ToLower(s) { - case "weekly": - return "vasárnap" - case "daily": - return "naponta" - case "sunday": - return "vasárnap" - default: - return s - } - }, - "nextPruneLabel": func(schedule string) string { - now := time.Now().In(loc) - var next time.Time - switch strings.ToLower(schedule) { - case "daily": - next = now.Add(24 * time.Hour) - default: // weekly/sunday - daysUntilSunday := (7 - int(now.Weekday())) % 7 - if daysUntilSunday == 0 { - if now.Hour() >= 4 { - daysUntilSunday = 7 // Already ran today, next week - } else { - return "ma" // Today (Sunday), hasn't run yet - } - } - next = now.AddDate(0, 0, daysUntilSunday) - } - return next.Format("2006-01-02") - }, + // R-572 (v0.258.0) — `pruneLabel` and `nextPruneLabel` WERE HERE, and they are deleted + // rather than translated. The row said "a template calling either renders Hungarian on an + // English page"; measured 2026-09-2X, NO template and no Go file called either — they were + // dead func-map entries returning „vasárnap" / „naponta" / „ma". Translating dead code + // would have added machinery with no reader and a test pinning a fiction. + // + // Deleting is FAIL-LOUD, which is why it is the safe direction: html/template refuses to + // parse a template naming an undefined function, and loadTemplates parses every template + // once per supported language at startup. A future template that needs a weekday will get + // "function ... not defined" at boot rather than Hungarian on an English page. "fmtDuration": func(d time.Duration) string { if d < time.Second { return "< 1s" diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 81afeb5..60252c0 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -752,7 +752,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY, // like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0 // app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did. - if cards := s.buildDataPathCards(found); len(cards) > 0 { + if cards := s.buildDataPathCards(found, s.langFor(r)); len(cards) > 0 { data["DataPathCards"] = cards } diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index 9ffdaf1..92440f8 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -357,6 +357,48 @@ func (s *Server) localeFuncs(lang string) template.FuncMap { } return &out }, + // updateBadge / lifecycleBadge (R-589, v0.258.0) — the two *MetaBadge builders. Their + // Hungarian forms in templateFuncMap are UNTOUCHED, which is the parity guarantee; these + // rebuild the same badge from the bundle, reusing compareInstalledToTemplate and + // EffectiveLifecycle so the DECISION cannot drift between the two languages — only the + // words do. TestLocaleFuncsHungarianBundleMatchesFuncMap pins that hu.json says what the + // Hungarian funcs say. + // + // Found live on 2026-09-20: on an English app page the only Felhom-authored Hungarian left + // was this badge, „Naprakész", with a Hungarian tooltip under it. + "updateBadge": func(st stacks.Stack) *MetaBadge { + switch compareInstalledToTemplate(st) { + case updateCurrent: + return &MetaBadge{ + Label: b.Msg(lang, "badge.update.current"), + Class: "tag-ok", + Title: b.Msg(lang, "badge.update.current.title"), + } + case updateBehind: + label := b.Msg(lang, "badge.update.behind") + if days, ok := st.Meta.CatalogSinceAge(time.Now().UTC()); ok { + if days == 0 { + label += b.Msg(lang, "badge.update.behind.today") + } else { + label += b.Plural(lang, "badge.update.behind.days", days) + } + } + return &MetaBadge{Label: label, Class: "tag-warn", Title: b.Msg(lang, "badge.update.behind.title")} + default: + // UNKNOWN renders nothing, in every language — see updatebadge.go. + return nil + } + }, + "lifecycleBadge": func(m stacks.Metadata) *MetaBadge { + if m.EffectiveLifecycle() != stacks.LifecycleAbandoned { + return nil + } + return &MetaBadge{ + Label: b.Msg(lang, "badge.lifecycle.abandoned"), + Class: "tag-warn", + Title: b.Msg(lang, "badge.lifecycle.abandoned.title"), + } + }, "statusText": func(status string) string { switch status { case "pending", "restoring", "done", "failed", "skipped": diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go index 182bb02..b481f94 100644 --- a/controller/internal/web/i18n_wiring_test.go +++ b/controller/internal/web/i18n_wiring_test.go @@ -170,6 +170,37 @@ func TestLocaleFuncsHungarianBundleMatchesFuncMap(t *testing.T) { t.Errorf("infra %s display: hu.json %q, inframeta.go %q", name, b.Msg("hu", k), m.DisplayName) } } + // The two *MetaBadge builders (R-589, v0.258.0). i18n_go_keys.json lists their keys under + // `_preexisting` CITING THIS TEST, so the citation has to be true: hu.json must say exactly + // what the frozen Hungarian funcmap forms say, word for word. + huBadge := fm["updateBadge"].(func(stacks.Stack) *MetaBadge) + cur := stacks.Stack{Name: "a", Deployed: true, + Meta: stacks.Metadata{Slug: "a", CatalogSince: time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")}, + AppConfig: &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{"x": {Ref: "same"}}}, + CatalogImages: map[string]string{"x": "same"}} + behind := cur + behind.CatalogImages = map[string]string{"x": "moved"} + if got, want := b.Msg("hu", "badge.update.current"), huBadge(cur).Label; got != want { + t.Errorf("badge.update.current: hu.json %q, funcmap %q", got, want) + } + if got, want := b.Msg("hu", "badge.update.current.title"), huBadge(cur).Title; got != want { + t.Errorf("badge.update.current.title: hu.json %q, funcmap %q", got, want) + } + if got, want := b.Msg("hu", "badge.update.behind")+fmt.Sprintf(b.Msg("hu", "badge.update.behind.days"), 2), huBadge(behind).Label; got != want { + t.Errorf("badge.update.behind(+age): hu.json %q, funcmap %q", got, want) + } + if got, want := b.Msg("hu", "badge.update.behind.title"), huBadge(behind).Title; got != want { + t.Errorf("badge.update.behind.title: hu.json %q, funcmap %q", got, want) + } + huLifecycle := fm["lifecycleBadge"].(func(stacks.Metadata) *MetaBadge) + ab := huLifecycle(stacks.Metadata{Lifecycle: stacks.LifecycleAbandoned}) + if got, want := b.Msg("hu", "badge.lifecycle.abandoned"), ab.Label; got != want { + t.Errorf("badge.lifecycle.abandoned: hu.json %q, funcmap %q", got, want) + } + if got, want := b.Msg("hu", "badge.lifecycle.abandoned.title"), ab.Title; got != want { + t.Errorf("badge.lifecycle.abandoned.title: hu.json %q, funcmap %q", got, want) + } + // And the English funcs really are English. en := s.localeFuncs("en") if got := en["timeAgo"].(func(time.Time) string)(now.Add(-time.Minute - time.Second)); got != "1 minute ago" { @@ -564,3 +595,139 @@ func TestParameterisedPageTitles(t *testing.T) { } } } + +// ── R-589 / R-573 (v0.258.0) — the badges and the two channel banners follow the language ────── +// +// These were the last two Felhom-authored Hungarian strings an English household met on an app +// page, found LIVE on 2026-09-20 by localisation slice 5's proof, not by reading. Both are built in +// Go, so neither the template parity fixtures nor TestI18nEnglishPages could see them. + +// TestUpdateBadgeFollowsTheLanguage — the Hungarian funcmap form and the bundle form must AGREE in +// Hungarian (that is the parity guarantee) and DIFFER in English (that is the fix). +// +// RED-PROOF (2026-09-2X): deleting the "updateBadge" entry from localeFuncs made the English arm +// return „Naprakész" and the test failed naming it; restoring it went green. +func TestUpdateBadgeFollowsTheLanguage(t *testing.T) { + s := i18nTestServer(t) + behind := stacks.Stack{ + Name: "app", Deployed: true, + Meta: stacks.Metadata{Slug: "app", CatalogSince: time.Now().UTC().AddDate(0, 0, -3).Format("2006-01-02")}, + AppConfig: &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{"a": {Ref: "old"}}}, + CatalogImages: map[string]string{"a": "new"}, + } + current := behind + current.CatalogImages = map[string]string{"a": "old"} + unknown := stacks.Stack{Name: "app", Deployed: false} + + huFn := s.templateFuncMap()["updateBadge"].(func(stacks.Stack) *MetaBadge) + enFn := s.localeFuncs("en")["updateBadge"].(func(stacks.Stack) *MetaBadge) + + // 1. UNKNOWN renders nothing, in both languages — a badge on an app nobody can judge is a + // question the customer cannot answer. + if huFn(unknown) != nil || enFn(unknown) != nil { + t.Error("an unjudgeable app must carry no badge in either language") + } + + // 2. Hungarian is unchanged: the bundle form equals the frozen funcmap form. + for _, st := range []stacks.Stack{current, behind} { + hu := huFn(st) + viaBundle := s.localeFuncs("hu") + if viaBundle != nil { + t.Fatal("localeFuncs must return nothing for Hungarian — that IS the parity guarantee") + } + if hu == nil { + t.Fatal("a judgeable app must carry a badge") + } + } + if got, want := huFn(current).Label, "Naprakész"; got != want { + t.Errorf("hu current label = %q, want %q", got, want) + } + + // 3. English really is English, and carries the SAME class (the decision, not the words). + enCur, enBehind := enFn(current), enFn(behind) + if enCur.Label != "Up to date" { + t.Errorf("en current label = %q", enCur.Label) + } + if enCur.Class != huFn(current).Class || enBehind.Class != huFn(behind).Class { + t.Error("the badge CLASS must not depend on the language — only the words may") + } + if !strings.HasPrefix(enBehind.Label, "Update available") || !strings.Contains(enBehind.Label, "3 days ago") { + t.Errorf("en behind label = %q, want the English age suffix", enBehind.Label) + } + for _, b := range []*MetaBadge{enCur, enBehind} { + if strings.ContainsAny(b.Label+b.Title, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ") { + t.Errorf("an English badge still carries Hungarian: %q / %q", b.Label, b.Title) + } + } + + // 4. The abandoned lifecycle badge, same shape. + huLife := s.templateFuncMap()["lifecycleBadge"].(func(stacks.Metadata) *MetaBadge) + enLife := s.localeFuncs("en")["lifecycleBadge"].(func(stacks.Metadata) *MetaBadge) + ab := stacks.Metadata{Lifecycle: stacks.LifecycleAbandoned} + if huLife(stacks.Metadata{}) != nil || enLife(stacks.Metadata{}) != nil { + t.Error("an available app must carry no lifecycle badge") + } + if got := huLife(ab).Label; got != "Nem karbantartott" { + t.Errorf("hu lifecycle label = %q", got) + } + if got := enLife(ab).Label; got != "No longer maintained" { + t.Errorf("en lifecycle label = %q", got) + } +} + +// TestAgentChannelBannersFollowTheLanguage — R-573. The checker classifies the fault; the banner is +// keyed by that classification. The CONSEQUENCE asserted here is what the household reads, not +// whether a key was set. +// +// RED-PROOF (2026-09-2X): dropping `MessageKey` from SetAgentChannelAlert left the English banner +// reading the Hungarian sentence and this failed naming it. +func TestAgentChannelBannersFollowTheLanguage(t *testing.T) { + s := i18nTestServer(t) + b := s.i18n + // Every reason the checker can classify must have a key, or the banner silently falls back. + for _, reason := range []string{"construction_error", "pin_mismatch", "unauthorized", + "misconfigured", "unreachable", "timeout", "unknown"} { + key := "alert.agent_channel." + reason + if !b.Has("hu", key) { + t.Errorf("reason %q has no Hungarian banner key %q", reason, key) + continue + } + hu, en := b.Msg("hu", key), b.Msg("en", key) + if hu == en { + t.Errorf("%s: the English banner fell back to Hungarian: %q", key, en) + } + if strings.ContainsAny(en, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ") { + t.Errorf("%s: the English banner carries Hungarian letters: %q", key, en) + } + } + if b.Msg("en", "alert.endpoint_drift") == b.Msg("hu", "alert.endpoint_drift") { + t.Error("the endpoint-drift banner fell back to Hungarian") + } + + // The AlertManager renders the key, and an UNMAPPED reason still shows the checker's sentence + // rather than a blank banner or a raw key — the fail-open half of the fix. + am := NewAlertManager(nil) + am.SetAgentChannelAlert(true, "alert.agent_channel.timeout", "A tárolókezelő ügynök nem válaszol.") + if got := findAlert(am.GetAlerts("en"), "agent-channel-down"); got == nil { + t.Fatal("the banner did not render") + } else if got.Message != "The storage agent is not answering." { + t.Errorf("en banner = %q", got.Message) + } + am.SetAgentChannelAlert(true, "", "A tárolókezelő ügynök nem válaszol.") + if got := findAlert(am.GetAlerts("en"), "agent-channel-down"); got == nil || got.Message != "A tárolókezelő ügynök nem válaszol." { + t.Errorf("an unmapped reason must fall back to the composed sentence, got %+v", got) + } + am.SetAgentChannelAlert(false, "", "") + if findAlert(am.GetAlerts("en"), "agent-channel-down") != nil { + t.Error("clearing the banner must remove it") + } +} + +func findAlert(alerts []Alert, id string) *Alert { + for i := range alerts { + if alerts[i].ID == id { + return &alerts[i] + } + } + return nil +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 6dbbf10..25b59eb 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -34,7 +34,15 @@ "page.title.storage": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle", "page.title.storage_attach": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle", "page.title.storage_init": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle", - "page.title.storage_network": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle" + "page.title.storage_network": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle", + "badge.update.current": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.update.current.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.update.behind": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.update.behind.today": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.update.behind.days": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.update.behind.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.lifecycle.abandoned": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap", + "badge.lifecycle.abandoned.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap" }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", @@ -951,5 +959,13 @@ "event.crossdrive_completed": "Másodlagos mentés elkészült: %s", "event.crossdrive_failed": "Másodlagos mentés sikertelen: %s", "event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)", - "event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)" + "event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", + "datapath.consequence.import_excluded": [ + "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ", + "ez a mappa átmeneti, és nem készül róla biztonsági mentés." + ], + "datapath.consequence.excluded": "Ez a mappa átmeneti, és nem készül róla biztonsági mentés.", + "datapath.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.", + "datapath.free_space": "%.1f GB szabad", + "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban." }