v0.258.0: the last four Hungarian things an English household met
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:
2026-09-20 18:10:53 +02:00
parent ecefdf2dc5
commit 7c4a33b6d1
12 changed files with 425 additions and 74 deletions
+49
View File
@@ -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) ## v0.257.0 — the app catalog can speak English (2026-09-20, R-560 slice 5 Part A)
**MinAgent: 0.131.0** (unchanged). **MinAgent: 0.131.0** (unchanged).
+14 -7
View File
@@ -1325,7 +1325,7 @@ func main() {
} }
sched.Every("hub-report", pushInterval, func(ctx context.Context) error { 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 := 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) r.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3)
if err := hubPusher.Push(r); err != nil { if err := hubPusher.Push(r); err != nil {
return err return err
@@ -1432,7 +1432,7 @@ func main() {
if hubPusher != nil { if hubPusher != nil {
if cfg.Hub.Enabled { if cfg.Hub.Enabled {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) 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) r.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3)
var pushErr error var pushErr error
for attempt := 1; attempt <= 3; attempt++ { for attempt := 1; attempt <= 3; attempt++ {
@@ -1515,7 +1515,7 @@ func main() {
if hubPusher != nil && cfg.Hub.Enabled { if hubPusher != nil && cfg.Hub.Enabled {
fireReport := func() error { fireReport := func() error {
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) 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) rep.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3)
return hubPusher.Push(rep) 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 // 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. // running controller, so a periodic re-check would add noise without adding signal.
if d := bootstrap.DetectEndpointDrift(*configPath, cfg, logger); d != nil { 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 { if notifier != nil {
notifier.NotifyEndpointDrift(d.EnglishMessage(), d.FingerprintAgrees) notifier.NotifyEndpointDrift(d.EnglishMessage(), d.FingerprintAgrees)
} }
@@ -1720,7 +1720,7 @@ func main() {
if hubPusher != nil { if hubPusher != nil {
dc.TriggerHubReportPush = func() error { dc.TriggerHubReportPush = func() error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger) 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) r.Language = sett.GetLanguage() // v0.247.0 (i18n): the hub e-mails follow it (slice 3)
return hubPusher.Push(r) return hubPusher.Push(r)
} }
@@ -3135,8 +3135,15 @@ type channelSink struct {
alertMgr *web.AlertManager alertMgr *web.AlertManager
} }
func (s channelSink) SetDashboard(down bool, _ channelhealth.Reason, msg string) { // R-573: the checker already CLASSIFIES the fault, so the banner is keyed by that classification
s.alertMgr.SetAgentChannelAlert(down, msg) // 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) { func (s channelSink) NotifyDown(reason channelhealth.Reason, eventType, severity, msg string) {
s.notifier.NotifyAgentChannelDown(string(reason), eventType, severity, msg) s.notifier.NotifyAgentChannelDown(string(reason), eventType, severity, msg)
+22 -1
View File
@@ -2286,5 +2286,26 @@
"event.crossdrive_completed": "Second backup copy finished: %s", "event.crossdrive_completed": "Second backup copy finished: %s",
"event.crossdrive_failed": "Second backup copy failed: %s", "event.crossdrive_failed": "Second backup copy failed: %s",
"event.disaster_recovery_started": "Disaster recovery started (%d app(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"
} }
+21 -1
View File
@@ -2275,5 +2275,25 @@
"event.crossdrive_completed": "Másodlagos mentés elkészült: %s", "event.crossdrive_completed": "Másodlagos mentés elkészült: %s",
"event.crossdrive_failed": "Másodlagos mentés sikertelen: %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_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"
} }
+14 -6
View File
@@ -102,9 +102,15 @@ func (am *AlertManager) SetHubPushStatus(fn func() HubPushStatusData) {
} }
// SetAgentChannelAlert sets (down=true) or clears (down=false) the controller→agent channel-down // 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 // dashboard banner. Called by the channel-health checker each probe (idempotent).
// Hungarian display line. //
func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) { // 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() am.mu.Lock()
defer am.mu.Unlock() defer am.mu.Unlock()
if !down { if !down {
@@ -114,7 +120,8 @@ func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) {
am.agentChannelAlert = &Alert{ am.agentChannelAlert = &Alert{
ID: "agent-channel-down", ID: "agent-channel-down",
Level: "error", 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", Link: "/settings",
LinkTextKey: "alert.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 // 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 // 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. // 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() am.mu.Lock()
defer am.mu.Unlock() defer am.mu.Unlock()
if !drift { if !drift {
@@ -136,7 +143,8 @@ func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) {
am.endpointDriftAlert = &Alert{ am.endpointDriftAlert = &Alert{
ID: "local-api-endpoint-drift", ID: "local-api-endpoint-drift",
Level: "error", 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", Link: "/settings",
LinkTextKey: "alert.link.settings", LinkTextKey: "alert.link.settings",
} }
+13 -9
View File
@@ -1,7 +1,6 @@
package web package web
import ( import (
"fmt"
"path/filepath" "path/filepath"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "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 // 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 // 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. // 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 { switch class {
case appbackup.ClassExcluded: case appbackup.ClassExcluded:
if role == stacks.RoleImport { if role == stacks.RoleImport {
return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " + return msg("datapath.consequence.import_excluded")
"ez a mappa átmeneti, és nem készül róla biztonsági mentés."
} }
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: 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: default:
// No classification (legacy app, or a bind with no backup block). Say nothing rather than // 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. // 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 // 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. // 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 { if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 {
return nil return nil
} }
@@ -71,7 +75,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard {
if s.stackMgr != nil { if s.stackMgr != nil {
if root := s.stackMgr.GetImportRoot(); root != "" { if root := s.stackMgr.GetImportRoot(); root != "" {
if du := system.GetDiskUsage(root); du != nil { 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{ card := DataPathCard{
Label: dp.Label, Label: dp.Label,
Link: link, 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, IsImport: isImport,
} }
if isImport { if isImport {
+54 -15
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
) )
@@ -27,7 +28,7 @@ func TestAppInfo_DataPathCardRenders(t *testing.T) {
cards := []DataPathCard{{ cards := []DataPathCard{{
Label: "Beolvasandó dokumentumok", Label: "Beolvasandó dokumentumok",
Link: importFolderLink("demo-felhom.eu", "paperless"), Link: importFolderLink("demo-felhom.eu", "paperless"),
Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport), Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport, bundleMsg(t, "hu")),
IsImport: true, IsImport: true,
FreeSpace: "44.8 GB szabad", 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 // 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. // 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) { func TestConsequenceIsClassDriven(t *testing.T) {
imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport) cases := []struct {
if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") { lang string
t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp) 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} { for _, c := range cases {
lib := consequenceFor(cls, stacks.RoleLibrary) msg := bundleMsg(t, c.lang)
if !strings.Contains(lib, "Biztonsági mentés készül") { imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport, msg)
t.Errorf("class %q must promise a backup: %q", cls, lib) 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") { for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} {
t.Errorf("class %q must NOT say unbacked: %q", cls, lib) 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 // And the two languages must not say the same thing — that would mean the bundle fell back and
// customer's files is worse than no sentence. // the English household is reading Hungarian, which is the defect this row was opened for.
if got := consequenceFor("", stacks.RoleLibrary); got != "" { hu := consequenceFor(appbackup.ClassMandatory, stacks.RoleLibrary, bundleMsg(t, "hu"))
t.Errorf("an unclassified path must produce no promise, got %q", got) 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{ Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{
{Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"}, {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) t.Errorf("an undeployed app must get no folder card, got %v", got)
} }
} }
+10 -32
View File
@@ -6,7 +6,6 @@ import (
"hash/fnv" "hash/fnv"
"html/template" "html/template"
"regexp" "regexp"
"strings"
"sync" "sync"
"time" "time"
"unicode" "unicode"
@@ -360,37 +359,16 @@ func (s *Server) templateFuncMap() template.FuncMap {
} }
return lt.Format("2006-01-02") + " " + timeStr return lt.Format("2006-01-02") + " " + timeStr
}, },
"pruneLabel": func(s string) string { // R-572 (v0.258.0) — `pruneLabel` and `nextPruneLabel` WERE HERE, and they are deleted
switch strings.ToLower(s) { // rather than translated. The row said "a template calling either renders Hungarian on an
case "weekly": // English page"; measured 2026-09-2X, NO template and no Go file called either — they were
return "vasárnap" // dead func-map entries returning „vasárnap" / „naponta" / „ma". Translating dead code
case "daily": // would have added machinery with no reader and a test pinning a fiction.
return "naponta" //
case "sunday": // Deleting is FAIL-LOUD, which is why it is the safe direction: html/template refuses to
return "vasárnap" // parse a template naming an undefined function, and loadTemplates parses every template
default: // once per supported language at startup. A future template that needs a weekday will get
return s // "function ... not defined" at boot rather than Hungarian on an English page.
}
},
"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")
},
"fmtDuration": func(d time.Duration) string { "fmtDuration": func(d time.Duration) string {
if d < time.Second { if d < time.Second {
return "< 1s" return "< 1s"
+1 -1
View File
@@ -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, // „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 // 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. // 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 data["DataPathCards"] = cards
} }
+42
View File
@@ -357,6 +357,48 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
} }
return &out 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 { "statusText": func(status string) string {
switch status { switch status {
case "pending", "restoring", "done", "failed", "skipped": case "pending", "restoring", "done", "failed", "skipped":
+167
View File
@@ -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) 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. // And the English funcs really are English.
en := s.localeFuncs("en") en := s.localeFuncs("en")
if got := en["timeAgo"].(func(time.Time) string)(now.Add(-time.Minute - time.Second)); got != "1 minute ago" { 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
}
+18 -2
View File
@@ -34,7 +34,15 @@
"page.title.storage": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle", "page.title.storage": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.storage_attach": "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_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.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", "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_completed": "Másodlagos mentés elkészült: %s",
"event.crossdrive_failed": "Másodlagos mentés sikertelen: %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_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."
} }