v0.258.0: the last four Hungarian things an English household met
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
R-589 the update badge, R-590 the data-folder backup sentence, R-573 the two channel banners, R-572 two dead helpers. All four were built in Go, which is why neither the template parity fixtures nor TestI18nEnglishPages could see them; slice 5's LIVE proof is what found them. R-590 is the one that matters most: it 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. The test now asserts the CONSEQUENCE in both languages — and that the two languages do not produce the same string, which would mean the English fell back. R-572 is not what its row said. The row claimed a template renders "vasárnap" on an English page; measured, NO template and no Go file called pruneLabel or nextPruneLabel. They were dead func-map entries returning Hungarian, so they are deleted rather than translated — translating dead code would add machinery with no reader and a test pinning a fiction. Deletion is fail-loud and that was proven: a template naming the removed function panics loadTemplates at startup. Five red-proofs. One of them says something about the gate rather than the code: the Go-parity gate does not measure localeFuncs keys against the base capture, so the citation to TestLocaleFuncsHungarianBundleMatchesFuncMap is what carries them — the test was extended to make that citation true. MinAgent: 0.131.0 (unchanged). Hungarian byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user