v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and tolerates a box that sends none of this - every box in the fleet is that box until this release reaches it. The hub writes a household's e-mails in their language now, but about a third of those mails carry a sentence the BOX composed, naming a drive, an app or a number. The hub cannot translate one. So the box sends it twice. - message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household sends nothing extra at all, so its payload stays byte-for-byte what every box sends today and the hub's fallback path keeps being the one production exercises rather than a branch nobody takes. - 19 producers render both sentences from ONE bundle key. `message` stays Hungarian always: it is what the operator is mailed and what the hub logs. - customer.language bootstraps a new box - stored choice, then config, then Hungarian. The config value is NEVER written into settings.json: that would record a choice the household never made. The Hungarian did not move, measured twice: the wire golden from the slice-2 base commit, and the Go parity gate over all 19 new keys. Three guards had to learn the change and one caught me: the test seam now carries the new field; the R-329 severity register reported two dynamic sites as no longer existing the moment they moved off PushEvent (the walk now checks 36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads main.go, because cmd/ is gitignored and ripgrep does not. A mistake, named: the first pass dropped displayName from three producers, which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either language. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1,3 +1,49 @@
|
||||
## v0.256.0 — the box sends its own sentence in the household's language (2026-09-18, R-558 Part B)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). **Needs hub v0.118.0+**, which shipped first and tolerates a box
|
||||
that sends none of this — every box in the fleet is that box until this release reaches it.
|
||||
|
||||
Slice 3's other half. The hub now writes the household's e-mails in their language, but roughly a
|
||||
third of those mails carry a sentence the BOX composed, naming a drive, an app or a number. The hub
|
||||
cannot translate one. So the box sends it twice.
|
||||
|
||||
- **`message_customer`** on `POST /api/v1/event` — the same sentence in the household's language,
|
||||
beside the unchanged Hungarian `message`. `omitempty`, and **a Hungarian household sends nothing
|
||||
extra at all**: its payload stays byte-for-byte what it is today, so the hub's fallback path — the
|
||||
one the whole fleet uses — keeps being the path production exercises, rather than becoming a branch
|
||||
nobody takes.
|
||||
- **19 producers converted** to bundle keys, rendered twice from ONE key: health ×3, controller
|
||||
started/updated/update-failed, storage disconnected/reconnected, backup target absent/restored,
|
||||
app deployed/deploy-started/deploy-failed/removed, crossdrive ×2, disaster recovery ×2, db-dump
|
||||
completed. `message` stays Hungarian **always** — it is what the operator is mailed and what
|
||||
`notification_log` records.
|
||||
- **The Hungarian is byte-identical, and it is measured twice over**: `TestEventMessageWireTextIsFrozen`
|
||||
(goldens captured at the slice-2 base commit) and the **Go parity gate**, which now checks all 19
|
||||
keys against the base-commit literals and passes.
|
||||
- **`customer.language` bootstraps a new box.** `GetLanguage` prefers the household's stored choice,
|
||||
then `controller.yaml`'s `customer.language`, then Hungarian. The config value is **never written to
|
||||
`settings.json`** — persisting it would record a choice the household never made, and a later
|
||||
config pull could no longer be told apart from a deliberate switch.
|
||||
- **The untranslatable tail** (a docker error, a validator's sentence) is appended to BOTH renderings,
|
||||
so an English household gets an English sentence with a foreign tail rather than no sentence.
|
||||
|
||||
**Three guards had to learn the change, and one of them caught me:**
|
||||
|
||||
- The test seam `pushFn` now carries `messageCustomer`. A seam that cannot see a new field cannot test
|
||||
it — the lesson this file already records one release back, when `PushEvent` had to become the seam.
|
||||
- The R-329 severity contract walks call sites BY FUNCTION NAME. Converting producers moved two
|
||||
dynamic-severity sites off `PushEvent`, and the register immediately reported them as no longer
|
||||
existing — the "a stale register entry is also a failure" half doing exactly its job. The three new
|
||||
push helpers are registered; the walk now checks **36** severity literals, up from 20.
|
||||
- `TestConfigLanguageIsWiredInMain` reads `main.go` and asserts the setter is CALLED. `cmd/` is
|
||||
gitignored, so ripgrep skips `main.go` and a reviewer's search finds nothing — which is precisely
|
||||
how a seam gets built and never wired in this repo.
|
||||
|
||||
**A mistake worth recording:** the first pass of the conversion dropped the `displayName` argument
|
||||
from three producers, which would have mailed customers `Alkalmazás telepítve: %!s(MISSING)`. It was
|
||||
caught by reading the diff, and is now pinned by a test that refuses any sentence containing `%!`,
|
||||
`MISSING`, `%s` or `%d` in either language.
|
||||
|
||||
## v0.255.0 — The globe on the sign-in-flow pages: styled, and inside the card (2026-09-18, fixes v0.254.0)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved. One Hungarian
|
||||
|
||||
@@ -1,70 +1,56 @@
|
||||
# REPORT — the globe on the sign-in-flow pages, fixed (controller v0.255.0)
|
||||
# REPORT — localisation slice 3 Part B: the box sends its sentence in the household's language
|
||||
|
||||
**2026-09-18 · base `2e9d402` (v0.254.0) · MinAgent 0.131.0, unchanged · no hub release.**
|
||||
Architecture named: `felhom.eu/documentation/architecture/10-localisation.md` §3 (who picks the
|
||||
language, and where the switch lives).
|
||||
**controller v0.256.0** · base `8fb2f9ef9d5a` · 2026-09-18 · R-558 Part B · MinAgent 0.131.0 (unchanged)
|
||||
|
||||
## 1. Claims in the prompt that live source disproved — named first
|
||||
## What shipped
|
||||
|
||||
| the prompt said | source says |
|
||||
|---|---|
|
||||
| "only the three shells lack `?v=`" | **FIVE do**: `login`, `claim`, `recovery`, **and both guest share pages** (`launcher_shared`, `launcher_share_password`). The share pages carry no globe, but the stale-stylesheet fault is the same for any CSS change and their visitor is the likeliest to hold an old copy. All five fixed — which is why 3 fixtures outside the named three changed, against the prompt's "zero re-captures outside the three shells". (8 first-boot wizard templates also lack it; out of scope, R-554 deletes them.) |
|
||||
| "`.Version` is already in their data" | **Only in two.** `login` (auth.go:343) and `claim` (claim.go:289) set it; `recovery` and both share pages did not. Rather than add it three times, it is set once in `executeTemplateLang` — the choke point every shell renders through — so the next shell cannot miss it. |
|
||||
| "`login.html` L11 … `style.css` L3683-3684" | Confirmed, line for line. |
|
||||
The hub half (v0.118.0/0.118.1) writes a household's e-mails in their language. This is the other
|
||||
half: about a third of those mails carry a sentence the BOX composed — naming a drive, an app, a
|
||||
number — and the hub cannot translate one. The box now sends it twice.
|
||||
|
||||
## 2. What was wrong, and why no test saw it
|
||||
- **`message_customer`** on the event wire: the same sentence in the household's language, beside the
|
||||
unchanged Hungarian `message`. `omitempty`, and **a Hungarian household sends nothing extra**, so
|
||||
its payload stays byte-for-byte what every box in the fleet sends today.
|
||||
- **19 producers converted** to bundle keys, both renderings from ONE key.
|
||||
- **`customer.language` bootstraps a new box**: stored choice → config → Hungarian. The config value
|
||||
is never persisted into `settings.json`; doing so would record a choice nobody made.
|
||||
|
||||
**The globe rendered unstyled** for anyone whose browser had `style.css` cached from before v0.254.0:
|
||||
the shells requested it with no `?v=`, so the cached copy — which has no `.lang-globe` rules — kept
|
||||
being served. The markup was correct, which is why every existing test passed: **they all read the
|
||||
HTML, and the fault was in which CSS file the browser fetched.** It took a screenshot to see it.
|
||||
## The Hungarian did not move, measured twice
|
||||
|
||||
**And the globe floated outside the card**, pinned to the corner of the viewport, reading as a stray
|
||||
browser control rather than part of the page.
|
||||
- `TestEventMessageWireTextIsFrozen` — goldens captured at the slice-2 base commit.
|
||||
- **The Go parity gate** — all 19 new keys checked byte-for-byte against the base-commit literals
|
||||
(`688 slice-2 keys listed, 34 pre-existing, 484 named in Go`, gate OK).
|
||||
|
||||
## 3. The fix
|
||||
## Three guards had to learn the change, and one caught me
|
||||
|
||||
- `?v={{.Version}}` on all five shells; `Version` set in `executeTemplateLang`.
|
||||
- `.shell-lang` is now `display:flex; justify-content:center; margin-top:1.5rem` — a plain block inside
|
||||
the card. The absolute positioning and the menu-direction override are **deleted**, so the shared
|
||||
`.lang-globe-menu` rule applies and the dashboard and the shells cannot drift apart.
|
||||
- The div moved to the end of each card: under the footer on `login` and `claim`, last in the card on
|
||||
`recovery` (which has no footer paragraph).
|
||||
- **The test seam** `pushFn` now carries `messageCustomer`. A seam that cannot see a new field cannot
|
||||
test it — the lesson this repo recorded one release ago when `PushEvent` had to become the seam.
|
||||
- **The R-329 severity contract** walks call sites by FUNCTION NAME. Converting producers moved two
|
||||
dynamic-severity sites off `PushEvent` and the register reported them as no longer existing — the
|
||||
"a stale register entry is also a failure" half doing its job. The three new helpers are registered;
|
||||
the walk now checks **36** severity literals, up from 20.
|
||||
- **`TestConfigLanguageIsWiredInMain`** reads `main.go` and asserts the setter is CALLED. `cmd/` is
|
||||
gitignored, so ripgrep skips `main.go` and a reviewer's search finds nothing — which is exactly how
|
||||
a seam gets built and never wired in this repo. Red-proofed by deleting the call.
|
||||
|
||||
## 3b. A third defect, caught by an existing test
|
||||
## A mistake, named
|
||||
|
||||
Putting the version on the guest share pages would have printed the controller build onto a page **a
|
||||
stranger with a capability URL can open**. `TestShareGuest_HeadersTilesNoAdminChrome` already refused
|
||||
that, and refused it here — a good test earning its keep. Those two pages now take an **opaque
|
||||
per-build tag** instead (`AssetTag`): same cache-busting, no disclosure.
|
||||
The first pass of the conversion **dropped the `displayName` argument from three producers**
|
||||
(`app_deployed`, `app_deploy_started`, `app_removed`). Shipped, that would have mailed customers
|
||||
`Alkalmazás telepítve: %!s(MISSING)`. Caught by reading my own diff, and now pinned by a test that
|
||||
refuses any sentence containing `%!`, `MISSING`, `%s` or `%d` in either language.
|
||||
|
||||
The fill is one function, `addShellAssetData`, called by `executeTemplateLang` **and** by the parity
|
||||
harness. Slice 2 release C got the fixture/production split wrong twice; sharing the function is what
|
||||
makes a third time impossible rather than merely unlikely.
|
||||
## What is NOT converted, and why
|
||||
|
||||
## 4. Tests, red-proofed
|
||||
- **The operator-tier types** (15 of them, listed in the hub's `operatorOnlyEvents`): the operator
|
||||
reads Hungarian, and these never reach a customer.
|
||||
- **Producers whose sentence is composed in another package** and reaches the notifier already
|
||||
finished — `backup_failed`, `db_dump_failed`, `backup_integrity_ok`/`_failed`,
|
||||
`offbox_enlarge_blocked`, `local_api_endpoint_drift`. They still send Hungarian only, so an English
|
||||
household can still see one Hungarian line in some mails. **Filed as a row rather than left as
|
||||
prose.**
|
||||
- The R-570 sentence, which stays Hungarian until that row closes.
|
||||
|
||||
`TestGlobeOnAnonymousShells` gained: the stylesheet carries a **non-empty** `?v=`; the globe is inside
|
||||
the card; the globe is below the footer where one exists. Each was red-proofed by restoring the
|
||||
defect's exact shape — the unversioned link (convicts, naming the stylesheet) and the globe put back
|
||||
before the card (convicts, "floats outside it").
|
||||
## Green
|
||||
|
||||
`testServer` gained `version: "test"`: an empty `?v=` busts a cache exactly once and never again, and
|
||||
baking that into a fixture would have hidden it. The test now refuses an empty one.
|
||||
|
||||
## 5. Parity
|
||||
|
||||
15 fixtures re-captured — 1 login, 4 claim, 7 recovery, 3 guest share — and **91 identical, including
|
||||
every dashboard page**. Four change shapes: the three shells gained the moved globe AND the buster;
|
||||
the two guest pages gained only the buster, as an opaque tag. The whole diff is the `?v=` attribute and the moved block, nothing else:
|
||||
`felhom.eu/documentation/audits/i18n-slice2-2026-09-18/D/parity-diff.txt`.
|
||||
|
||||
## 6. Green gate
|
||||
|
||||
`go build ./... && go vet ./... && go test ./...` green; all controller gates OK.
|
||||
|
||||
## 7. Row
|
||||
|
||||
**R-579** — the cache-buster gap: found, fixed and closed in this session, with the general form
|
||||
recorded (a template that loads a versioned asset without the version is invisible to every test that
|
||||
reads markup).
|
||||
`go build` / `go vet` / `go test ./...` clean. All 17 controller gates OK. All 23 decoys behave.
|
||||
|
||||
+31
-1
@@ -3614,7 +3614,7 @@ without reaching `Images()`.
|
||||
|
||||
---
|
||||
|
||||
### 18. Dashboard language (i18n) (v0.247.0–v0.254.0)
|
||||
### 18. Dashboard language (i18n) (v0.247.0–v0.256.0)
|
||||
|
||||
Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
|
||||
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
|
||||
@@ -3820,6 +3820,36 @@ controller/
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
#### 18.5 The e-mails the hub sends (v0.256.0, slice 3)
|
||||
|
||||
**The hub writes a household's e-mails in the household's language** from hub v0.118.0. It learns
|
||||
which language from the box: every report carries `"language"` (since v0.247.0), and the hub prefers
|
||||
the last reported one over the operator's creation-time default. Nothing here needs a setting.
|
||||
|
||||
**The box sends about a third of those sentences itself**, and the hub cannot translate one — it
|
||||
arrives as finished text naming a drive, an app or a number. So `POST /api/v1/event` carries
|
||||
**`message_customer`**: the same sentence in the household's language, beside the unchanged Hungarian
|
||||
`message`.
|
||||
|
||||
- `message` is **always Hungarian**. It is what the operator is mailed, what the hub logs and what
|
||||
`notification_log` records; a household switching language moves nothing an operator reads.
|
||||
- A **Hungarian household sends no second copy at all** (`omitempty`), so its payload is byte-for-byte
|
||||
what every box has always sent.
|
||||
- 19 producers render both from ONE bundle key (`event.*`): health ×3, controller started/updated/
|
||||
update-failed, storage disconnected/reconnected, backup target absent/restored, app deployed/
|
||||
deploy-started/deploy-failed/removed, crossdrive ×2, disaster recovery ×2, db-dump completed.
|
||||
- An **untranslatable tail** (a docker error, a validator's sentence) is appended to both renderings.
|
||||
- **Not converted, deliberately:** the operator-tier types (the hub keeps them off the customer
|
||||
channel) and the producers whose sentence is composed in another package and reaches the notifier
|
||||
already finished. Those still send Hungarian only, so an English household can still see one
|
||||
Hungarian line in some mails. Tracked as a row.
|
||||
|
||||
**A new box starts in the language the operator picked.** The hub renders `customer.language` into
|
||||
`controller.yaml`; `GetLanguage` prefers the household's stored choice, then that, then Hungarian.
|
||||
The config value is **never written into `settings.json`** — persisting it would record a choice the
|
||||
household never made.
|
||||
|
||||
### Controller config (`controller.yaml`)
|
||||
|
||||
Single YAML file per customer, infrastructure-only. Does **not** contain app-specific config.
|
||||
|
||||
@@ -341,6 +341,11 @@ func main() {
|
||||
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
|
||||
}
|
||||
sett.SetDebug(cfg.Logging.Level == "debug")
|
||||
// v0.256.0 (R-558): the language the hub says this box should START in. Consulted by
|
||||
// GetLanguage ONLY when the household has never chosen — an explicit choice in settings.json
|
||||
// always wins, so a config pull cannot unseat it. A config change restarts the controller, so
|
||||
// binding it once here is enough. Pinned by TestConfigLanguageIsWiredInMain.
|
||||
sett.SetConfigLanguage(cfg.Customer.Language)
|
||||
|
||||
// --- Auto-discover storage paths from deployed apps ---
|
||||
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
|
||||
|
||||
@@ -142,10 +142,16 @@ type SystemConfig struct {
|
||||
}
|
||||
|
||||
type CustomerConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Domain string `yaml:"domain"`
|
||||
Email string `yaml:"email"`
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Domain string `yaml:"domain"`
|
||||
Email string `yaml:"email"`
|
||||
// Language (v0.256.0, R-558) is the language the OPERATOR chose when creating this customer, as
|
||||
// rendered by the hub into controller.yaml. It is the language this box STARTS in and nothing
|
||||
// more: the moment the household picks one on the dashboard, settings.json holds their choice
|
||||
// and this value is never consulted again. A config pull therefore cannot overwrite a
|
||||
// household's choice. Empty (an older hub, or a hand-written config) means Hungarian.
|
||||
Language string `yaml:"language"`
|
||||
TelegramChatID string `yaml:"telegram_chat_id"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2267,5 +2267,24 @@
|
||||
"note.tier2_unit_confirm_date_unproven_fmt": " The copy is from: %s – that is the time of the last backup attempt; we cannot prove it succeeded.",
|
||||
"note.tier2_unit_confirm_contrast": " The “Restore files” button beside it only fills in the missing files and overwrites nothing. The app stops while either one runs.",
|
||||
"note.tier2_unit_stale_clause": " WARNING: this copy's data package is older than the latest backup — the package on the main drive was incomplete, so the complete one was kept. The restore uses the package named above.",
|
||||
"note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept."
|
||||
"note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept.",
|
||||
"event.health_critical": "System health is critical (was: %s)",
|
||||
"event.health_degraded": "System health has got worse (was: %s)",
|
||||
"event.health_recovered": "System health is back to normal: %s (was: %s)",
|
||||
"event.db_dump_completed": "Database backup finished",
|
||||
"event.controller_updated": "Controller updated: %s → %s",
|
||||
"event.controller_update_failed": "Controller update failed: %s → %s",
|
||||
"event.controller_started": "Controller started (%s)",
|
||||
"event.storage_disconnected": "Drive disconnected unexpectedly: %s",
|
||||
"event.backup_target_absent": "The whole-system backup drive is not available: %s (%s)",
|
||||
"event.backup_target_restored": "The whole-system backup drive is available again: %s (%s)",
|
||||
"event.storage_reconnected": "Drive reconnected: %s",
|
||||
"event.app_deployed": "App installed: %s",
|
||||
"event.app_deploy_started": "App install started: %s",
|
||||
"event.app_deploy_failed": "App install did not succeed: %s",
|
||||
"event.app_removed": "App removed: %s",
|
||||
"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)"
|
||||
}
|
||||
|
||||
@@ -2256,5 +2256,24 @@
|
||||
"note.tier2_unit_confirm_date_unproven_fmt": " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt.",
|
||||
"note.tier2_unit_confirm_contrast": " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll.",
|
||||
"note.tier2_unit_stale_clause": " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja.",
|
||||
"note.tier2_unit_stale_notice_fmt": "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
|
||||
"note.tier2_unit_stale_notice_fmt": "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük.",
|
||||
"event.health_critical": "Rendszer állapot kritikus (volt: %s)",
|
||||
"event.health_degraded": "Rendszer állapot romlott (volt: %s)",
|
||||
"event.health_recovered": "Rendszer állapot helyreállt: %s (volt: %s)",
|
||||
"event.db_dump_completed": "Adatbázis mentés elkészült",
|
||||
"event.controller_updated": "Controller frissítve: %s → %s",
|
||||
"event.controller_update_failed": "Controller frissítés sikertelen: %s → %s",
|
||||
"event.controller_started": "Controller elindult (%s)",
|
||||
"event.storage_disconnected": "Meghajtó váratlanul leválasztva: %s",
|
||||
"event.backup_target_absent": "A rendszermentés meghajtója nem érhető el: %s (%s)",
|
||||
"event.backup_target_restored": "A rendszermentés meghajtója újra elérhető: %s (%s)",
|
||||
"event.storage_reconnected": "Meghajtó újra csatlakoztatva: %s",
|
||||
"event.app_deployed": "Alkalmazás telepítve: %s",
|
||||
"event.app_deploy_started": "Alkalmazás telepítése elindult: %s",
|
||||
"event.app_deploy_failed": "Alkalmazás telepítése nem sikerült: %s",
|
||||
"event.app_removed": "Alkalmazás eltávolítva: %s",
|
||||
"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)"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
|
||||
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
|
||||
var events []string
|
||||
n.pushFn = func(eventType, _, msg string, _ interface{}) {
|
||||
n.pushFn = func(eventType, _, msg, _ string, _ interface{}) {
|
||||
if eventType == "app_start_failed" {
|
||||
events = append(events, msg)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
|
||||
func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
|
||||
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
|
||||
var count int
|
||||
n.pushFn = func(eventType, _, _ string, _ interface{}) {
|
||||
n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
|
||||
if eventType == "app_start_failed" {
|
||||
count++
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
|
||||
func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
|
||||
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
|
||||
var count int
|
||||
n.pushFn = func(eventType, _, _ string, _ interface{}) {
|
||||
n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
|
||||
if eventType == "app_start_failed" {
|
||||
count++
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
|
||||
func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) {
|
||||
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
|
||||
var count int
|
||||
n.pushFn = func(string, string, string, interface{}) { count++ }
|
||||
n.pushFn = func(string, string, string, string, interface{}) { count++ }
|
||||
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
|
||||
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
|
||||
if count != 0 {
|
||||
|
||||
@@ -38,7 +38,7 @@ func hubSeverityNotifies(sev string) bool {
|
||||
func TestNotifyDiskHealthDegraded_SeverityRoutes(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var gotSev string
|
||||
n.pushFn = func(eventType, severity, message string, details interface{}) { gotSev = severity }
|
||||
n.pushFn = func(eventType, severity, message, _ string, details interface{}) { gotSev = severity }
|
||||
|
||||
n.NotifyDiskHealthDegraded(DiskAlert{Label: "sdb", Kind: DiskAlertWarn,
|
||||
Attributes: []string{"függőben lévő szektorok"}})
|
||||
@@ -70,7 +70,7 @@ func TestNotifyDiskHealthDegraded_WarnShape(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var gotType, gotMsg string
|
||||
var gotDetails interface{}
|
||||
n.pushFn = func(eventType, severity, message string, details interface{}) {
|
||||
n.pushFn = func(eventType, severity, message, _ string, details interface{}) {
|
||||
gotType, gotMsg, gotDetails = eventType, message, details
|
||||
}
|
||||
|
||||
@@ -100,7 +100,9 @@ func TestNotifyDiskHealthDegraded_FailShapes(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var gotMsg string
|
||||
var gotDetails interface{}
|
||||
n.pushFn = func(eventType, severity, message string, details interface{}) { gotMsg, gotDetails = message, details }
|
||||
n.pushFn = func(eventType, severity, message, _ string, details interface{}) {
|
||||
gotMsg, gotDetails = message, details
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -154,7 +156,7 @@ func TestNotifyDiskHealthDegraded_FailShapes(t *testing.T) {
|
||||
func TestNotifyDiskHealthDegraded_CopyDiscipline(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var gotMsg string
|
||||
n.pushFn = func(eventType, severity, message string, details interface{}) { gotMsg = message }
|
||||
n.pushFn = func(eventType, severity, message, _ string, details interface{}) { gotMsg = message }
|
||||
for _, k := range []DiskAlertKind{DiskAlertWarn, DiskAlertFailSelfReported, DiskAlertFailSectors, DiskAlertFailTemperature, DiskAlertFailWorsened} {
|
||||
n.NotifyDiskHealthDegraded(DiskAlert{Label: "TESTDISK", Kind: k, Sectors: 8, TemperatureC: 61})
|
||||
if !strings.Contains(gotMsg, "TESTDISK") {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Slice 3 Part B (R-558) — the box sends its own sentence twice, and the Hungarian one never moves.
|
||||
//
|
||||
// The hub cannot translate a sentence the box composed: it arrives as finished text naming a drive,
|
||||
// an app or a number. These tests are the whole of the controller's half of that.
|
||||
|
||||
func notifierInLang(t *testing.T, lang string) *Notifier {
|
||||
t.Helper()
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lang != "" {
|
||||
if err := sett.SetLanguage(lang); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Notifier{settings: sett, logger: log.New(io.Discard, "", 0)}
|
||||
}
|
||||
|
||||
// converted lists every producer this release converted to a bundle key, with a call that exercises
|
||||
// it. It is the table the two tests below share, so a producer cannot be covered by one and missed
|
||||
// by the other.
|
||||
func convertedProducers() []struct {
|
||||
name string
|
||||
call func(*Notifier)
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
call func(*Notifier)
|
||||
}{
|
||||
// NotifyHealthChange compares against the PREVIOUS status it saw, so each case primes it
|
||||
// with a first call (which records and returns) and then transitions.
|
||||
{"health_critical", func(n *Notifier) {
|
||||
n.enabled = true
|
||||
n.NotifyHealthChange("ok", nil, nil)
|
||||
n.NotifyHealthChange("fail", nil, nil)
|
||||
}},
|
||||
{"health_degraded", func(n *Notifier) {
|
||||
n.enabled = true
|
||||
n.NotifyHealthChange("ok", nil, nil)
|
||||
n.NotifyHealthChange("warn", nil, nil)
|
||||
}},
|
||||
{"health_recovered", func(n *Notifier) {
|
||||
n.enabled = true
|
||||
n.NotifyHealthChange("fail", nil, nil)
|
||||
n.NotifyHealthChange("ok", nil, nil)
|
||||
}},
|
||||
{"db_dump_completed", func(n *Notifier) { n.NotifyDBDumpCompleted(DBDumpDetails{}) }},
|
||||
{"controller_updated", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", true) }},
|
||||
{"controller_update_failed", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", false) }},
|
||||
{"controller_started", func(n *Notifier) { n.NotifyControllerStarted("0.256.0", nil) }},
|
||||
{"storage_disconnected", func(n *Notifier) { n.NotifyStorageDisconnected("Kulso HDD", nil) }},
|
||||
{"storage_reconnected", func(n *Notifier) { n.NotifyStorageReconnected("Kulso HDD") }},
|
||||
{"backup_target_absent", func(n *Notifier) { n.NotifyBackupTargetAbsent("Kulso HDD", "/mnt/hdd_1") }},
|
||||
{"backup_target_restored", func(n *Notifier) { n.NotifyBackupTargetRestored("Kulso HDD", "/mnt/hdd_1") }},
|
||||
{"app_deployed", func(n *Notifier) { n.NotifyAppDeployed("privatebin", "PrivateBin") }},
|
||||
{"app_deploy_started", func(n *Notifier) { n.NotifyAppDeployStarted("privatebin", "PrivateBin") }},
|
||||
{"app_deploy_failed", func(n *Notifier) { n.NotifyAppDeployFailed("privatebin", "PrivateBin", "") }},
|
||||
{"app_removed", func(n *Notifier) { n.NotifyAppRemoved("privatebin", "PrivateBin") }},
|
||||
{"crossdrive_completed", func(n *Notifier) { n.NotifyCrossDriveCompleted(CrossDriveDetails{StackName: "privatebin"}) }},
|
||||
{"crossdrive_failed", func(n *Notifier) { n.NotifyCrossDriveFailed(CrossDriveDetails{StackName: "privatebin"}) }},
|
||||
{"disaster_recovery_started", func(n *Notifier) { n.NotifyDRStarted(3) }},
|
||||
{"disaster_recovery_completed", func(n *Notifier) { n.NotifyDRCompleted(7, 2) }},
|
||||
}
|
||||
}
|
||||
|
||||
// An ENGLISH household gets an English second sentence, and the Hungarian one is untouched.
|
||||
func TestNotifierSendsMessageCustomerForAnEnglishHousehold(t *testing.T) {
|
||||
for _, tc := range convertedProducers() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := notifierInLang(t, "en")
|
||||
var hu, en string
|
||||
var seen int
|
||||
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) {
|
||||
hu, en, seen = message, messageCustomer, seen+1
|
||||
}
|
||||
tc.call(n)
|
||||
if seen == 0 {
|
||||
t.Fatal("the producer pushed nothing — nothing below was compared")
|
||||
}
|
||||
if en == "" {
|
||||
t.Fatalf("no household sentence was sent; the English household's mail would carry "+
|
||||
"the Hungarian %q", hu)
|
||||
}
|
||||
if en == hu {
|
||||
t.Errorf("the household sentence is identical to the Hungarian one: %q", en)
|
||||
}
|
||||
// A rendering fault is silent in a mail body — %!s(MISSING) is a real thing customers
|
||||
// have been sent by projects that did not check this.
|
||||
for _, bad := range []string{"%!", "MISSING", "EXTRA", "%s", "%d"} {
|
||||
if strings.Contains(en, bad) || strings.Contains(hu, bad) {
|
||||
t.Errorf("a sentence carries a formatting fault (%s):\n hu %q\n en %q", bad, hu, en)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A HUNGARIAN household sends NO second sentence at all.
|
||||
//
|
||||
// That is not an optimisation: it keeps the payload of every box in the fleet byte-for-byte what it
|
||||
// is today, so the hub's fallback path — the one every existing box uses — stays the path that is
|
||||
// actually exercised in production rather than becoming a branch nobody takes.
|
||||
func TestHungarianHouseholdSendsNoSecondSentence(t *testing.T) {
|
||||
for _, tc := range convertedProducers() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n := notifierInLang(t, "hu")
|
||||
var hu, en string
|
||||
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
|
||||
tc.call(n)
|
||||
if en != "" {
|
||||
t.Errorf("a Hungarian household sent a second sentence %q — the payload is no longer "+
|
||||
"what every box in the fleet sends today", en)
|
||||
}
|
||||
if hu == "" {
|
||||
t.Error("the Hungarian sentence is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A notifier with NO settings at all (setup mode, and several older tests) is Hungarian rather than
|
||||
// a panic. The nil-prefs panic this project already fixed once is the same shape.
|
||||
func TestNotifierWithoutSettingsIsHungarian(t *testing.T) {
|
||||
n := &Notifier{logger: log.New(io.Discard, "", 0)}
|
||||
var hu, en string
|
||||
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
|
||||
n.NotifyAppDeployed("privatebin", "PrivateBin")
|
||||
if en != "" {
|
||||
t.Errorf("a notifier with no settings sent a household sentence: %q", en)
|
||||
}
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hu != b.Msgf("hu", "event.app_deployed", "PrivateBin") {
|
||||
t.Errorf("the Hungarian sentence is not the bundle's: %q", hu)
|
||||
}
|
||||
}
|
||||
|
||||
// The untranslatable tail is appended to BOTH renderings — an English household gets an English
|
||||
// sentence with a foreign tail, never no sentence at all.
|
||||
func TestUntranslatableTailIsKeptInBothLanguages(t *testing.T) {
|
||||
n := notifierInLang(t, "en")
|
||||
var hu, en string
|
||||
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
|
||||
n.NotifyAppDeployFailed("privatebin", "PrivateBin", "exit status 1")
|
||||
for _, s := range []string{hu, en} {
|
||||
if !strings.HasSuffix(s, " — exit status 1") {
|
||||
t.Errorf("the untranslatable tail is missing from %q", s)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(en, "PrivateBin") || en == hu {
|
||||
t.Errorf("the English sentence is wrong: %q (hu %q)", en, hu)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// Notifier sends structured events to the hub via /api/v1/event.
|
||||
@@ -51,7 +53,11 @@ type Notifier struct {
|
||||
|
||||
// pushFn is a test seam for the transition-emitting notifiers (fix-3). nil → the real async
|
||||
// PushEvent; tests inject a synchronous recorder.
|
||||
pushFn func(eventType, severity, message string, details interface{})
|
||||
// v0.256.0 (R-558): messageCustomer is IN the seam, not beside it. A seam that cannot see a new
|
||||
// field is a seam that cannot test it — the same lesson this file already records one comment
|
||||
// down, where PushEvent had to become the seam because emit alone made a whole class of
|
||||
// producer invisible.
|
||||
pushFn func(eventType, severity, message, messageCustomer string, details interface{})
|
||||
|
||||
// Event history ring buffer (debug page)
|
||||
historyMu sync.RWMutex
|
||||
@@ -164,17 +170,92 @@ type CrossDriveDetails struct {
|
||||
|
||||
// eventRequest is the JSON payload sent to /api/v1/event.
|
||||
type eventRequest struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
// MessageCustomer (v0.256.0, R-558) is the SAME sentence in the household's language.
|
||||
//
|
||||
// `omitempty` is load-bearing: a Hungarian household sends no second copy at all, so its event
|
||||
// payload is byte-for-byte what it has always been, and the hub's fallback path — the one every
|
||||
// box in the fleet uses today — is the one that keeps being exercised in production rather than
|
||||
// becoming a branch nobody takes.
|
||||
//
|
||||
// Message stays Hungarian ALWAYS. It is what the operator is mailed, what the hub logs and what
|
||||
// notification_log records; nothing an operator reads may move because a household switched.
|
||||
MessageCustomer string `json:"message_customer,omitempty"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// PushEvent sends a structured event to the hub's /api/v1/event endpoint.
|
||||
// Non-blocking (goroutine). Retries twice with 3s backoff.
|
||||
// details may be nil (omitted from JSON) or a struct that marshals to JSON.
|
||||
// pushEventMsg renders ONE bundle key twice — Hungarian for the wire, the household's language
|
||||
// beside it — and pushes both (R-558).
|
||||
//
|
||||
// Producers call this instead of composing a Hungarian sentence, because the hub CANNOT translate a
|
||||
// sentence the box composed: it arrives as finished text naming a drive, an app or a number. The
|
||||
// only place both languages can be produced is here, where the key and its arguments still exist.
|
||||
//
|
||||
// The Hungarian is rendered from the SAME key, so it cannot drift from the English: there is one
|
||||
// sentence with two spellings, not two sentences. That the Hungarian is byte-identical to the
|
||||
// literal it replaced is pinned by TestEventMessageWireTextIsFrozen and by the Go parity gate.
|
||||
func (n *Notifier) pushEventMsg(eventType, severity, key string, details interface{}, args ...interface{}) {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
// The bundle is embedded, so this is a broken build rather than a runtime condition. Push
|
||||
// the key itself rather than an empty sentence: an event with a visible key reaches the
|
||||
// operator and gets fixed; an event with an empty message is a silent hole.
|
||||
n.logger.Printf("[ERROR] pushEventMsg: bundle unavailable for %s: %v", key, err)
|
||||
n.pushEventBoth(eventType, severity, key, "", details)
|
||||
return
|
||||
}
|
||||
hungarian := b.Msgf(i18n.Default, key, args...)
|
||||
|
||||
// A Hungarian household sends nothing extra — see MessageCustomer's comment.
|
||||
household := ""
|
||||
if lang := n.boxLang(); lang != i18n.Default {
|
||||
household = b.Msgf(lang, key, args...)
|
||||
}
|
||||
n.pushEventBoth(eventType, severity, hungarian, household, details)
|
||||
}
|
||||
|
||||
// pushEventMsgSuffix is pushEventMsg with a tail that CANNOT be translated — a docker error, a
|
||||
// validator's sentence, something that arrived as finished text. It is appended to both renderings,
|
||||
// so an English household gets an English sentence with a foreign tail rather than no sentence.
|
||||
func (n *Notifier) pushEventMsgSuffix(eventType, severity, key, suffix string, details interface{}, args ...interface{}) {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
n.logger.Printf("[ERROR] pushEventMsgSuffix: bundle unavailable for %s: %v", key, err)
|
||||
n.pushEventBoth(eventType, severity, key+suffix, "", details)
|
||||
return
|
||||
}
|
||||
household := ""
|
||||
if lang := n.boxLang(); lang != i18n.Default {
|
||||
household = b.Msgf(lang, key, args...) + suffix
|
||||
}
|
||||
n.pushEventBoth(eventType, severity, b.Msgf(i18n.Default, key, args...)+suffix, household, details)
|
||||
}
|
||||
|
||||
// boxLang is the household's language, nil-safe. A notifier built without settings (several tests,
|
||||
// and the setup-mode path) is Hungarian rather than a panic.
|
||||
func (n *Notifier) boxLang() string {
|
||||
if n.settings == nil {
|
||||
return i18n.Default
|
||||
}
|
||||
return n.settings.GetLanguage()
|
||||
}
|
||||
|
||||
// PushEvent sends a structured event to the hub's /api/v1/event endpoint, Hungarian only.
|
||||
//
|
||||
// Kept for producers whose sentence is composed somewhere else and reaches them already finished —
|
||||
// and for the OPERATOR-tier types, which are Hungarian (or English) by design and have no household
|
||||
// half. A customer-facing producer should use pushEventMsg.
|
||||
func (n *Notifier) PushEvent(eventType, severity, message string, details interface{}) {
|
||||
n.pushEventBoth(eventType, severity, message, "", details)
|
||||
}
|
||||
|
||||
func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer string, details interface{}) {
|
||||
// The test seam sits HERE, not only in emit (v0.252.0, R-557). Most producers call PushEvent
|
||||
// directly, so a test that set pushFn saw nothing from them and the difference was invisible: a
|
||||
// producer that pushes nothing and a producer whose message is empty both leave a recorder empty.
|
||||
@@ -182,7 +263,7 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
|
||||
// only do that if every producer is reachable through one seam. In production pushFn is nil and
|
||||
// this line does nothing.
|
||||
if n.pushFn != nil {
|
||||
n.pushFn(eventType, severity, message, details)
|
||||
n.pushFn(eventType, severity, message, messageCustomer, details)
|
||||
return
|
||||
}
|
||||
if !n.enabled {
|
||||
@@ -200,11 +281,12 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
|
||||
}
|
||||
|
||||
payload := eventRequest{
|
||||
CustomerID: n.customerID,
|
||||
EventType: eventType,
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
Details: detailsJSON,
|
||||
CustomerID: n.customerID,
|
||||
EventType: eventType,
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
MessageCustomer: messageCustomer,
|
||||
Details: detailsJSON,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
@@ -293,16 +375,13 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string)
|
||||
if newRank > prevRank {
|
||||
// Degradation
|
||||
if status == "fail" {
|
||||
n.PushEvent("health_critical", "error",
|
||||
fmt.Sprintf("Rendszer állapot kritikus (volt: %s)", prev), details)
|
||||
n.pushEventMsg("health_critical", "error", "event.health_critical", details, prev)
|
||||
} else if status == "warn" {
|
||||
n.PushEvent("health_degraded", "warning",
|
||||
fmt.Sprintf("Rendszer állapot romlott (volt: %s)", prev), details)
|
||||
n.pushEventMsg("health_degraded", "warning", "event.health_degraded", details, prev)
|
||||
}
|
||||
} else {
|
||||
// Recovery
|
||||
n.PushEvent("health_recovered", "info",
|
||||
fmt.Sprintf("Rendszer állapot helyreállt: %s (volt: %s)", status, prev), details)
|
||||
n.pushEventMsg("health_recovered", "info", "event.health_recovered", details, status, prev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +481,7 @@ func (n *Notifier) NotifyDBDumpFailed(message, errMsg string) {
|
||||
|
||||
// NotifyDBDumpCompleted sends a DB dump success event.
|
||||
func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) {
|
||||
n.PushEvent("db_dump_completed", "info", "Adatbázis mentés elkészült", details)
|
||||
n.pushEventMsg("db_dump_completed", "info", "event.db_dump_completed", details)
|
||||
}
|
||||
|
||||
// NotifyIntegrityFailed sends a backup integrity check failure event.
|
||||
@@ -435,29 +514,27 @@ func (n *Notifier) NotifyIntegrityOK(message string) {
|
||||
// NotifyControllerUpdated sends a controller update event.
|
||||
func (n *Notifier) NotifyControllerUpdated(fromVer, toVer string, success bool) {
|
||||
severity := "info"
|
||||
msg := fmt.Sprintf("Controller frissítve: %s → %s", fromVer, toVer)
|
||||
key := "event.controller_updated"
|
||||
details := UpdateDetails{FromVersion: fromVer, ToVersion: toVer}
|
||||
if !success {
|
||||
severity = "error"
|
||||
msg = fmt.Sprintf("Controller frissítés sikertelen: %s → %s", fromVer, toVer)
|
||||
key = "event.controller_update_failed"
|
||||
}
|
||||
n.PushEvent("controller_updated", severity, msg, details)
|
||||
n.pushEventMsg("controller_updated", severity, key, details, fromVer, toVer)
|
||||
}
|
||||
|
||||
// NotifyControllerStarted sends a controller startup event.
|
||||
// details may include self-test summary (e.g., {"selftest_pass": 8, "selftest_warn": 1, "selftest_fail": 0}).
|
||||
func (n *Notifier) NotifyControllerStarted(version string, details map[string]interface{}) {
|
||||
n.PushEvent("controller_started", "info",
|
||||
fmt.Sprintf("Controller elindult (%s)", version), details)
|
||||
n.pushEventMsg("controller_started", "info", "event.controller_started", details, version)
|
||||
}
|
||||
|
||||
// NotifyStorageDisconnected sends a drive disconnection event.
|
||||
func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string) {
|
||||
msg := fmt.Sprintf("Meghajtó váratlanul leválasztva: %s", label)
|
||||
n.PushEvent("storage_disconnected", "error", msg, StorageDetails{
|
||||
n.pushEventMsg("storage_disconnected", "error", "event.storage_disconnected", StorageDetails{
|
||||
Label: label,
|
||||
StoppedApps: stoppedApps,
|
||||
})
|
||||
}, label)
|
||||
}
|
||||
|
||||
// NotifyBackupTargetAbsent (E-2) reports that the drive holding the WHOLE-GUEST backup is gone.
|
||||
@@ -468,23 +545,21 @@ func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string)
|
||||
// at all: the tier stays DUE (targetStoragePresent checks name presence, never reachability), so the
|
||||
// only evidence was its own failure at the next due cycle, up to ~24 h away on the daily local tier.
|
||||
func (n *Notifier) NotifyBackupTargetAbsent(label, target string) {
|
||||
n.PushEvent("backup_target_absent", "error",
|
||||
fmt.Sprintf("A rendszermentés meghajtója nem érhető el: %s (%s)", label, target),
|
||||
StorageDetails{Label: label})
|
||||
n.pushEventMsg("backup_target_absent", "error", "event.backup_target_absent",
|
||||
StorageDetails{Label: label}, label, target)
|
||||
}
|
||||
|
||||
// NotifyBackupTargetRestored is the paired recovery. info severity — the existing recovery pattern;
|
||||
// severityNotifies is deliberately NOT widened.
|
||||
func (n *Notifier) NotifyBackupTargetRestored(label, target string) {
|
||||
n.PushEvent("backup_target_restored", "info",
|
||||
fmt.Sprintf("A rendszermentés meghajtója újra elérhető: %s (%s)", label, target),
|
||||
StorageDetails{Label: label})
|
||||
n.pushEventMsg("backup_target_restored", "info", "event.backup_target_restored",
|
||||
StorageDetails{Label: label}, label, target)
|
||||
}
|
||||
|
||||
// NotifyStorageReconnected sends a drive reconnection event.
|
||||
func (n *Notifier) NotifyStorageReconnected(label string) {
|
||||
n.PushEvent("storage_reconnected", "info",
|
||||
fmt.Sprintf("Meghajtó újra csatlakoztatva: %s", label), StorageDetails{Label: label})
|
||||
n.pushEventMsg("storage_reconnected", "info", "event.storage_reconnected",
|
||||
StorageDetails{Label: label}, label)
|
||||
}
|
||||
|
||||
// AgentChannelDetails carries the classified reason for a controller→agent channel-down event.
|
||||
@@ -532,9 +607,8 @@ type EndpointDriftDetails struct {
|
||||
// so an install that was interrupted five seconds later still stood on the hub's timeline as
|
||||
// „Alkalmazás telepítve" forever — measured 2026-09-16 with mealie, which ended `not_deployed`.
|
||||
func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
|
||||
n.PushEvent("app_deployed", "info",
|
||||
fmt.Sprintf("Alkalmazás telepítve: %s", displayName),
|
||||
AppDetails{StackName: stackName, DisplayName: displayName})
|
||||
n.pushEventMsg("app_deployed", "info", "event.app_deployed",
|
||||
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
|
||||
}
|
||||
|
||||
// NotifyAppDeployStarted records the ACCEPTANCE — the fact `app_deployed` used to assert. It keeps
|
||||
@@ -543,21 +617,22 @@ func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
|
||||
// NOTE: the hub validates event_type against allowedEventTypes and 400s an unknown one, so this type
|
||||
// MUST exist there too (hub handler.go) or the event is silently inert.
|
||||
func (n *Notifier) NotifyAppDeployStarted(stackName, displayName string) {
|
||||
n.PushEvent("app_deploy_started", "info",
|
||||
fmt.Sprintf("Alkalmazás telepítése elindult: %s", displayName),
|
||||
AppDetails{StackName: stackName, DisplayName: displayName})
|
||||
n.pushEventMsg("app_deploy_started", "info", "event.app_deploy_started",
|
||||
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
|
||||
}
|
||||
|
||||
// NotifyAppDeployFailed closes the pair. severity=warning, not info: an install the customer started
|
||||
// and that did not finish is a thing someone should see, and the silent version of this is exactly
|
||||
// what left a completed-install record for an app that was never installed.
|
||||
func (n *Notifier) NotifyAppDeployFailed(stackName, displayName, reason string) {
|
||||
msg := fmt.Sprintf("Alkalmazás telepítése nem sikerült: %s", displayName)
|
||||
// The reason is appended to BOTH renderings rather than folded into the key: it arrives as
|
||||
// finished text (a docker error, a validator's sentence) that this function cannot translate.
|
||||
suffix := ""
|
||||
if reason != "" {
|
||||
msg += " — " + reason
|
||||
suffix = " — " + reason
|
||||
}
|
||||
n.PushEvent("app_deploy_failed", "warning", msg,
|
||||
AppDetails{StackName: stackName, DisplayName: displayName})
|
||||
n.pushEventMsgSuffix("app_deploy_failed", "warning", "event.app_deploy_failed", suffix,
|
||||
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
|
||||
}
|
||||
|
||||
// AppRunState is one deployed app's running state for the fix-3 start-failure notifier: Down=true
|
||||
@@ -725,9 +800,12 @@ func (n *Notifier) NotifyDiskHealthDegraded(a DiskAlert) {
|
||||
}
|
||||
|
||||
// emit sends an event through the test seam if set, else the real async PushEvent.
|
||||
//
|
||||
// Hungarian only: its one producer (the disk-health alerts) still composes a finished sentence.
|
||||
// It passes no household copy, which is what an un-converted producer looks like.
|
||||
func (n *Notifier) emit(eventType, severity, message string, details interface{}) {
|
||||
if n.pushFn != nil {
|
||||
n.pushFn(eventType, severity, message, details)
|
||||
n.pushFn(eventType, severity, message, "", details)
|
||||
return
|
||||
}
|
||||
n.PushEvent(eventType, severity, message, details)
|
||||
@@ -735,27 +813,23 @@ func (n *Notifier) emit(eventType, severity, message string, details interface{}
|
||||
|
||||
// NotifyAppRemoved sends an app removal event.
|
||||
func (n *Notifier) NotifyAppRemoved(stackName, displayName string) {
|
||||
n.PushEvent("app_removed", "info",
|
||||
fmt.Sprintf("Alkalmazás eltávolítva: %s", displayName),
|
||||
AppDetails{StackName: stackName, DisplayName: displayName})
|
||||
n.pushEventMsg("app_removed", "info", "event.app_removed",
|
||||
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
|
||||
}
|
||||
|
||||
// NotifyCrossDriveCompleted sends a cross-drive backup success event.
|
||||
func (n *Notifier) NotifyCrossDriveCompleted(details CrossDriveDetails) {
|
||||
n.PushEvent("crossdrive_completed", "info",
|
||||
fmt.Sprintf("Másodlagos mentés elkészült: %s", details.StackName), details)
|
||||
n.pushEventMsg("crossdrive_completed", "info", "event.crossdrive_completed", details, details.StackName)
|
||||
}
|
||||
|
||||
// NotifyCrossDriveFailed sends a cross-drive backup failure event.
|
||||
func (n *Notifier) NotifyCrossDriveFailed(details CrossDriveDetails) {
|
||||
n.PushEvent("crossdrive_failed", "error",
|
||||
fmt.Sprintf("Másodlagos mentés sikertelen: %s", details.StackName), details)
|
||||
n.pushEventMsg("crossdrive_failed", "error", "event.crossdrive_failed", details, details.StackName)
|
||||
}
|
||||
|
||||
// NotifyDRStarted sends a disaster recovery start event.
|
||||
func (n *Notifier) NotifyDRStarted(appCount int) {
|
||||
n.PushEvent("disaster_recovery_started", "warning",
|
||||
fmt.Sprintf("Katasztrófa helyreállítás elindítva (%d alkalmazás)", appCount), nil)
|
||||
n.pushEventMsg("disaster_recovery_started", "warning", "event.disaster_recovery_started", nil, appCount)
|
||||
}
|
||||
|
||||
// NotifyDRCompleted sends a disaster recovery completion event.
|
||||
@@ -764,8 +838,8 @@ func (n *Notifier) NotifyDRCompleted(successCount, failCount int) {
|
||||
if failCount > 0 {
|
||||
severity = "warning"
|
||||
}
|
||||
n.PushEvent("disaster_recovery_completed", severity,
|
||||
fmt.Sprintf("Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", successCount, failCount), nil)
|
||||
n.pushEventMsg("disaster_recovery_completed", severity, "event.disaster_recovery_completed", nil,
|
||||
successCount, failCount)
|
||||
}
|
||||
|
||||
// ── Preferences sync ─────────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +49,14 @@ var hubSeverityVocabulary = map[string]bool{
|
||||
var severityArg = map[string]int{
|
||||
"emit": 1, // (eventType, severity, message, details)
|
||||
"PushEvent": 1, // (eventType, severity, message, details)
|
||||
// v0.256.0 (R-558): the key-based producers. Registered here in the SAME commit that introduced
|
||||
// them — the walk finds severities by FUNCTION NAME, so a new push helper that is not listed is
|
||||
// a set of call sites this contract silently stops checking. It caught its own omission: the
|
||||
// conversion moved two dynamic sites off PushEvent and the register immediately reported them as
|
||||
// no longer existing, which is the "a stale register entry is also a failure" half doing its job.
|
||||
"pushEventMsg": 1, // (eventType, severity, key, details, args...)
|
||||
"pushEventMsgSuffix": 1, // (eventType, severity, key, suffix, details, args...)
|
||||
"pushEventBoth": 1, // (eventType, severity, message, messageCustomer, details)
|
||||
}
|
||||
|
||||
// knownDynamicSeveritySites are the call sites that pass a VARIABLE rather than a literal, so this
|
||||
@@ -57,7 +65,13 @@ var severityArg = map[string]int{
|
||||
// honest limit of an AST check is that it cannot follow a variable, and an unlisted limit is not a
|
||||
// limit, it is a hole.
|
||||
var knownDynamicSeveritySites = map[string]string{
|
||||
"internal/notify/notifier.go:emit": `pass-through of its own severity parameter to PushEvent — the value is checked at emit's CALLERS, above`,
|
||||
"internal/notify/notifier.go:emit": `pass-through of its own severity parameter to PushEvent — the value is checked at emit's CALLERS, above`,
|
||||
// v0.256.0 (R-558): three more pass-throughs, the same shape as emit. Each forwards its OWN
|
||||
// severity parameter to pushEventBoth without inspecting it, so the value is checked at their
|
||||
// callers — which is where the walk's 36 literals are found.
|
||||
"internal/notify/notifier.go:PushEvent": `pass-through of its own severity parameter to pushEventBoth — checked at PushEvent's callers`,
|
||||
"internal/notify/notifier.go:pushEventMsg": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsg's callers`,
|
||||
"internal/notify/notifier.go:pushEventMsgSuffix": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsgSuffix's callers`,
|
||||
"internal/notify/notifier.go:NotifyControllerUpdated": `local var: "info", or "error" when the update failed`,
|
||||
"internal/notify/notifier.go:NotifyDRCompleted": `local var: "info", or "warning" when failCount > 0`,
|
||||
"internal/notify/notifier.go:NotifyAgentChannelDown": `internal/channelhealth's classifier — every severity literal in checker.go is "warning" or "error"`,
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestEventMessageWireTextIsFrozen(t *testing.T) {
|
||||
var got string
|
||||
var seen int
|
||||
n := &Notifier{}
|
||||
n.pushFn = func(_, _, message string, _ interface{}) {
|
||||
n.pushFn = func(_, _, message, _ string, _ interface{}) {
|
||||
got, seen = message, seen+1
|
||||
}
|
||||
tc.call(n)
|
||||
@@ -102,7 +102,7 @@ func TestEventMessageWireTextIsFrozen(t *testing.T) {
|
||||
func TestEventMessagesCarryNoBundleKey(t *testing.T) {
|
||||
var msgs []string
|
||||
n := &Notifier{}
|
||||
n.pushFn = func(_, _, message string, _ interface{}) { msgs = append(msgs, message) }
|
||||
n.pushFn = func(_, _, message, _ string, _ interface{}) { msgs = append(msgs, message) }
|
||||
|
||||
n.NotifyAppDeployed("privatebin", "PrivateBin")
|
||||
n.NotifyAppRemoved("privatebin", "PrivateBin")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bootstrap rule (R-558): a box starts in the language the hub says, until the household picks.
|
||||
|
||||
func newSettings(t *testing.T) *Settings {
|
||||
t.Helper()
|
||||
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestLanguageBootstrapFromConfig(t *testing.T) {
|
||||
// 3. Nothing anywhere → Hungarian.
|
||||
s := newSettings(t)
|
||||
if got := s.GetLanguage(); got != "hu" {
|
||||
t.Errorf("no choice, no config = %q, want hu", got)
|
||||
}
|
||||
|
||||
// 2. The config's language, for a box nobody has told yet.
|
||||
s.SetConfigLanguage("en")
|
||||
if got := s.GetLanguage(); got != "en" {
|
||||
t.Errorf("config en, no choice = %q, want en — a box created as English starts Hungarian", got)
|
||||
}
|
||||
|
||||
// 1. An explicit choice WINS, in both directions.
|
||||
if err := s.SetLanguage("hu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.GetLanguage(); got != "hu" {
|
||||
t.Errorf("chose hu with config en = %q, want hu — the household's choice lost", got)
|
||||
}
|
||||
s2 := newSettings(t)
|
||||
s2.SetConfigLanguage("hu")
|
||||
if err := s2.SetLanguage("en"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s2.GetLanguage(); got != "en" {
|
||||
t.Errorf("chose en with config hu = %q, want en", got)
|
||||
}
|
||||
|
||||
// An unsupported config value is ignored, not stored and not rendered.
|
||||
s3 := newSettings(t)
|
||||
s3.SetConfigLanguage("klingon")
|
||||
if got := s3.GetLanguage(); got != "hu" {
|
||||
t.Errorf("unsupported config language = %q, want hu", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The config default must NEVER be written to settings.json. If it were, a box would record a
|
||||
// choice the household never made, and the next config pull could no longer be distinguished from
|
||||
// a deliberate switch.
|
||||
func TestConfigLanguageIsNotPersisted(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
s, err := Load(path, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetConfigLanguage("en")
|
||||
// Force a save through an unrelated setter, the way any ordinary use would.
|
||||
if err := s.SetBackupWindowStart("01:00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `"language"`) {
|
||||
t.Errorf("the config default was persisted into settings.json — it is now indistinguishable "+
|
||||
"from a household's choice:\n%s", raw)
|
||||
}
|
||||
// And a REAL choice IS persisted, or the control proves nothing.
|
||||
if err := s.SetLanguage("en"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ = os.ReadFile(path)
|
||||
if !strings.Contains(string(raw), `"language"`) {
|
||||
t.Error("a real choice was NOT persisted — the negative control above is meaningless")
|
||||
}
|
||||
}
|
||||
|
||||
// SEAM-WIRING PIN (the class with four instances in this project): the setter exists, but is it
|
||||
// CALLED? `cmd/` is gitignored, so ripgrep skips main.go and a reviewer's search finds nothing —
|
||||
// which is exactly how a seam gets built and never wired here.
|
||||
func TestConfigLanguageIsWiredInMain(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "cmd", "controller", "main.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("cannot read main.go to check the wiring: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
if !strings.Contains(src, "sett.SetConfigLanguage(cfg.Customer.Language)") {
|
||||
t.Error("main.go never calls SetConfigLanguage — the bootstrap default is an unwired seam, " +
|
||||
"and every box would start Hungarian whatever the hub said")
|
||||
}
|
||||
// It must come from the CONFIG, not from a literal someone pasted to make this pass.
|
||||
if strings.Contains(src, `SetConfigLanguage("`) {
|
||||
t.Error("main.go passes a literal to SetConfigLanguage — it must pass cfg.Customer.Language")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ type Settings struct {
|
||||
// surfaced to the dashboard as a persistent banner. Not persisted.
|
||||
LoadWarning string `json:"-"`
|
||||
|
||||
// configLanguage (v0.256.0, R-558) is `customer.language` from controller.yaml — the language
|
||||
// the hub says this box should START in. It is consulted ONLY when the household has never
|
||||
// chosen: GetLanguage prefers the stored choice, always. Not persisted, and deliberately not a
|
||||
// field of the JSON: it belongs to the config, which is re-pulled, and writing it into
|
||||
// settings.json would turn a default into a choice the household never made.
|
||||
configLanguage string `json:"-"`
|
||||
|
||||
// Auth
|
||||
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
|
||||
|
||||
@@ -819,12 +826,40 @@ func (s *Settings) SetBackupWindowStart(start string) error {
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetLanguage returns the household's dashboard language, normalised: anything unset or unknown is
|
||||
// Hungarian (i18n.Default).
|
||||
// SetConfigLanguage records the language controller.yaml says this box should start in.
|
||||
//
|
||||
// Called once at startup and again after each config pull. It never touches s.Language: a
|
||||
// re-delivered config must not be able to change what a household chose.
|
||||
func (s *Settings) SetConfigLanguage(lang string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.configLanguage = lang
|
||||
}
|
||||
|
||||
// GetLanguage returns the household's dashboard language, normalised.
|
||||
//
|
||||
// THE ORDER, and it is the whole of the bootstrap rule (R-558):
|
||||
//
|
||||
// 1. What the HOUSEHOLD chose, from settings.json. An explicit choice always wins, and it wins
|
||||
// forever — a later config pull cannot unseat it.
|
||||
// 2. What the hub said to start in (`customer.language`), for a box nobody has told yet. This is
|
||||
// what makes "create the customer as English" produce an English box on first boot.
|
||||
// 3. Hungarian.
|
||||
//
|
||||
// Steps 1 and 2 are DIFFERENT KINDS OF FACT and the difference is the reason this is not one field:
|
||||
// an empty s.Language means "never chosen", which is not the same as "chose Hungarian". Collapsing
|
||||
// them would make an English household's box silently revert to Hungarian the first time anyone
|
||||
// looked at a config default.
|
||||
func (s *Settings) GetLanguage() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return i18n.Normalize(s.Language)
|
||||
if i18n.IsSupported(s.Language) {
|
||||
return s.Language
|
||||
}
|
||||
if i18n.IsSupported(s.configLanguage) {
|
||||
return s.configLanguage
|
||||
}
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// SetLanguage stores the household's dashboard language and saves. Only a supported language is
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user