v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s

Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 10:15:56 +02:00
parent 736f54b496
commit 5270bad76e
52 changed files with 10755 additions and 478 deletions
+53
View File
@@ -1,3 +1,56 @@
## v0.252.0 — The sentences the program writes follow the language (2026-09-18, R-557 slice 2 release A)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing a Hungarian household reads
changes: every converted sentence is byte-identical to the literal it replaced, measured by a new
gate rather than read, and nothing on the wire moved.
Slice 1 translated the dashboard's MARKUP. The sentences the program BUILDS — flash lines, page data,
the JSON the page's own script reads, the alert banners, the country names — were still Hungarian
literals in Go, so an English household clicked an English button and was answered in Hungarian.
This release moves 226 of them into the bundle and gives them a way to reach the reader.
**The flash line was the hard part.** It travels to the page INSIDE THE REDIRECT URL, so it is
rendered by a different request from the one that wrote it — until now in the language of whoever
redirected. It now travels as a bundle key plus its parameters (`?flash=flash.share.enabled&fa=…`),
and the page renders it. A link minted by an older controller carries prose; that is shown verbatim,
never as a raw key and never dropped (`TestFlashKeyRoundTrip`, including the legacy and the
`<script>` case).
| what | how it reaches the reader now |
|---|---|
| flash lines (8 writers, 8 readers) | a key in the redirect URL, rendered by the page that reads it |
| page data, view-model labels | `s.msg(r, key, …)` / `s.msgLang(lang, …)`; the builders learned the language |
| API answers (`internal/api`) | `r.msg(req, key, …)` — the same helper, one package over |
| alert banners | `Alert.MessageKey` + args, rendered on the way out of `GetAlerts(lang)` |
| 237 country names | `country.<ISO2>`; the cloudflare TABLE is untouched and still validates codes |
| three app-named page titles + the tier-2 one (R-566) | a title key with a `%s`, plus `TitleArgs` |
**What deliberately stays Hungarian, and why.** Anything a HUB reads: the report's `health.warnings`
and `health.issues`, and every `notify` event message — the hub composes the customer's e-mail from
those and falls back to the controller's own sentence when it has no entry of its own
(`FormatCustomerEmail`). Those follow the household's language in slice 3 (R-558). Both are now
pinned by wire goldens (`internal/monitor`, `internal/notify`) so a later slice cannot translate them
by accident. Also untouched: the R-570 producer, and every `fmt.Errorf` (release B).
**The gate that makes "byte-identical" a measurement.** `controller/scripts/i18n_go_parity.py`
freezes every Go string literal at the base commit (7 467 of them, `scripts/i18n_go_base.json`) and
refuses a key whose Hungarian is not that text, byte for byte — reworded, re-punctuated, split or
joined. `scripts/i18n_go_keys.json` records what each of the 426 keys replaced. Registered in
`controller_gates.py`; three decoys in `test_gate_decoys.py`, each seen to convict. It caught a real
defect in its own first version: the capture filtered literals through an ASCII-Hungarian word list
and missed seven („Naponta", „5 percenkent", „Eletjel (Heartbeat)", …). The list is gone; every
literal is indexed.
- `Bundle.Msgf` fills a message's own printf verbs. English reorders with Go's explicit argument
indexes (`%[2]s`) — no second placeholder syntax, and hu.json stays byte-equal to the format string
the code always had, which is what the parity gate compares.
- `s.bundle()` falls back to the embedded `i18n.Shared()`, so a Server built without `loadTemplates`
renders sentences and never a raw key (red-proofed).
- The notifier's test seam moved into `PushEvent`: most producers called it directly, so a recorder
saw nothing from them and "pushed nothing" looked exactly like "pushed an empty message".
- `HU_FORMAL_CEILING` 16 → 18. Two „ön" forms the gate could not see before are now in the bundle,
word for word as the Go code said them. R-516 still owns the words.
## v0.251.0 — Nothing decides by reading a Hungarian word (2026-09-17, R-553 + R-563)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing a household reads changes: every
+3 -1
View File
@@ -7,7 +7,9 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-09-17 (v0.247.0 — i18n spike: the dashboard can speak English, Hungarian byte-identical)
Last updated: 2026-09-18 (v0.252.0 — localisation slice 2 release A: the sentences the program builds follow the language)
> **2026-09-18 — v0.252.0 (R-557 slice 2 release A).** The sentences the program BUILDS now follow the language: 226 Go literals converted (flash lines, page data, API answers, alert banners, 237 country names, the four app-named page titles — R-566 closed). Flash travels as a KEY in the redirect URL with `fa` parameters; an old link's prose is still shown verbatim. New gate `i18n_go_parity.py` refuses any key whose Hungarian is not byte-identical to the base commit (7 467 literals frozen; 3 decoys). Wire goldens freeze the report warnings and the event messages the hub MAILS — those stay Hungarian until slice 3 (R-558). Remaining: 894 literals, 176 of them `fmt.Errorf` (release B); persisted text (release C, §16 option 1). New rows R-572/R-573/R-574.
> **2026-09-17 night — v0.251.0 (R-553 + R-563 CLOSED).** Five decisions that read their own Hungarian words now read a kind: deploy status sentinels (`internal/stacks/deploy_errors.go`), `backup.ErrOffsiteQuota`, `monitor.WarningKinds`, `settings.LastWarningKind` (legacy text fallback until R-570), and `data-status` on the remote-backup status. `util.KindErrorf` keeps message bytes identical. Slice 2 (R-557) is unblocked except the one producer named in R-570.
+99 -73
View File
@@ -1,89 +1,115 @@
# REPORT — controller v0.251.0: nothing decides by reading a Hungarian word (R-553 + R-563)
# REPORT — localisation slice 2, release A (controller v0.252.0, R-557)
**Date:** 2026-09-17 · **Baseline:** `e236dca48653` (v0.250.0) · **Released:** `0194342` → image
`gitea.dooplex.hu/admin/felhom-controller:0.251.0` · **MinAgent:** 0.131.0 (unchanged).
**Architecture read before any claim:** `felhom.eu/documentation/architecture/10-localisation.md` §1/§9
(§9 rewritten by this session), `07-backup-architecture.md`, `02-controller-module-map.md`.
**Evidence:** `felhom.eu/documentation/audits/r553-2026-09-17/`.
**2026-09-18 · base commit `736f54b49610` (v0.251.0) · MinAgent 0.131.0, unchanged · no hub release.**
Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1
(parity), §2 (mechanism), §5 (gates), §9 (the R-553 signals), §10 (this slice); `07-backup-architecture.md`
for what the hub reads from a box; `05-hub-architecture.md` for what the hub composes itself.
## Claims in the prompt that turned out wrong — named first
---
1. **„`internal/system/mounts_linux.go` L176/L194/L199 produce site 3's warnings."** They do not.
`h.Warning` from `CheckBackupDestination` never reaches `report.Warnings` — the only producer folded
into the health report is `checkStoragePaths` (healthcheck.go). `system/` needed no change at all.
2. **„Site 1 matches `required field` … all producers are ours."** **Nothing produces `required field`.**
The router matched a phrase no code in this repo writes — a dead arm, removed with the rest.
3. **Site 3's condition matched more than the prompt implies.** Of the nine warnings that exist today,
exactly ONE matches („Az adattároló … nem külön meghajtón van"): the three patterns are lower-case
and every other warning capitalises the word. So the fix had to preserve *one* inline warning, not a
family — `WarnKindStorageNotSeparate` does exactly that.
4. **Line numbers drifted** as the prompt warned (router.go:478 → the block moved into a new
`deployStatusFor`; offbox.go:186/921; alerts.go:208; handlers.go:920/931 → 944).
5. **„`errors.New(refusal)` at the memory site"** — correct, and it is the reason the memory refusal
could not be pinned by a unit test: `memoryVerdict` reads the HOST's real memory. Its kind is pinned
at the source line instead, and the mapping is covered with a translated memory message.
6. **Confirmed as stated:** site 2's classifier has exactly one caller; `offbox_handlers.go:132` is a
false positive (an `[INFO]` log line); site 5 is the one rendered byte that changes.
## 1. Claims in the task that live source disproved — named first
## The five sites
| the task said | source says |
|---|---|
| "`cloudflare/countries.go` is used by `report/types.go` (on the wire)" | **No.** `report/builder.go` puts country **CODES** on the wire (`AllowedCountries []string`); no country NAME leaves the box. `CountryName()` has no caller at all. So the names are display-only and were free to translate — which is what this release did. |
| "the hub's dispatcher composes its own customer mail from event kinds (grep of `dispatcher.go` for `.Message`: nothing)" | **Wrong in substance.** `.Message` finds nothing because the message arrives as a PARAMETER, not a field. `FormatCustomerEmail` (hub `notify/templates.go` L167-210) uses `customerMessages[eventType]` when it has one and **falls back to the controller's message** when it does not — and appends it as „- Üzenet: %s" whenever the two differ. Several event types deliberately carry no entry so the controller's sentence IS the mail (hub `api/handler.go`, the notes at L2034/L2045/L2066). The conclusion (do not translate them) was right; the reason was not. |
| "5 flash readers / 8 writers" | 8 writers, **8 read sites** — the three named plus `auth.go:100` (login), `server.go:498` (claim) and the `flash_error` twins. All eight converted. |
| per-file counts (`handlers.go` 121, `offbox_handlers.go` 72, `storage_handlers.go` 48, `handler_debug.go` 40, `offbox_reconstitute.go` 41, `notify/notifier.go` 30, `api/router.go` 19) | measured at `736f54b49610`: **120 / 70 / 45 / 39 / 38 / 31 / 18**. Totals: **1 120** Hungarian literals, not 1 141. |
| "`scripts/i18n_inventory.py`" (as a controller script) | it lives in **`felhom.eu/scripts/`**. Its Go-literal walker was copied into the new gate rather than imported — a controller clone need not sit beside felhom.eu, and a gate that can fail to import its scanner can silently skip. |
| # | decision | signal added | producer(s) changed | decision changed | test | HEAD-red → fixed-green |
|---|---|---|---|---|---|---|
| 1 | deploy HTTP status | `stacks.ErrRequiredField`, `ErrPathMissing`, `ErrNotEnoughMemory`, `ErrAlreadyDeployed` (`deploy_errors.go`) | `deploy.go` 205/311/326/333/245 | `api/router.go` → new `deployStatusFor` | `TestR553_Deploy_DecisionSurvivesWordingChange`, `TestR553_DeployHandlerUsesTheKind`, `TestR553_DeployProducersCarryKindAndKeepTheirWords` | red: every translated row `= 500, want 400/409`; green after |
| 2 | off-site failure class | `backup.ErrOffsiteQuota` | `offbox.go` 921 | `ClassifyOffsiteFailure` | `TestR553_OffsiteQuota_{Decision,HeadLine}SurvivesWordingChange`, `TestR553_QuotaProducerKeepsItsWords` | red: translated quota `= "unknown"`, head line „ismeretlen okból"; green after |
| 3 | disk-warning placement | `monitor.WarnKind*` + `HealthReport.WarningKinds` / `addWarning` / `WarningKindAt` | `healthcheck.go` `checkStoragePaths` | `web/alerts.go` | `TestR553_StorageWarningsCarryKindsAndKeepTheirWords`, `TestR553_DiskWarningPlacementSurvivesWordingChange`, `TestR553_HubReportWarningsAreUnchangedOnTheWire` | red: translated warning `Inline = false`, and an unrelated warning wrongly moved inline; green after |
| 4 | stale „nothing selected" note | `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | `offbox.go` 1053/1095 (+ both clear sites, both copy sites) | `offboxWarningDisplay` | `TestR553_StaleNote*`, `TestR553_WarningKindIsPersistedAndCopied`, `TestR553_OffboxRunRecordsTheKind` | red: translated note shown verbatim; producer without its kind convicted; green after |
| 5 | remote-backup poll (R-563) | `data-status="{{.Offbox.LastStatus}}"` | `backups_remote.html` status element | the page's own script | `TestR563_PollStartsFromAttribute` (hu + en), `TestR563_AttributeFollowsTheStatus` | red twice: poll back on the word (both languages), word back inline (English page shows Hungarian); green after |
## 2. What shipped
**Message bytes:** every producer's sentence is asserted equal to its pre-change string (sites 1, 2, 4)
or rendered identically (site 5). `util.KindErrorf` / `KindError` (new, `internal/util/errkind.go`,
documented in REUSE.md §1) build the same bytes `fmt.Errorf` did and carry the sentinel for `errors.Is`.
**226 Go literals converted**, plus 237 country names now available in English. 426 keys, each
recorded in `scripts/i18n_go_keys.json` against the literal it replaced.
**External signatures kept, on purpose:** the classifier's restic and ssh arms — `unable to open config
file`, `is there a repository at the following location`, `produced no snapshots`, `connection refused`,
`connection reset`, `no route to host`, `i/o timeout`, `timed out`, `permission denied`, `host key`,
`handshake`, `could not resolve`, `network is unreachable`. We neither write nor translate that output.
| file | before | after | converted | of the rest, `fmt.Errorf` (release B) |
|---|---|---|---|---|
| `internal/web/handlers.go` | 120 | 44 | 76 | 0 |
| `internal/web/offbox_handlers.go` | 70 | 22 | 48 | 0 |
| `internal/web/storage_handlers.go` | 45 | 19 | 26 | 16 |
| `internal/api/router.go` | 18 | 0 | 18 | 0 |
| `internal/web/share_handlers.go` | 22 | 5 | 17 | 0 |
| `internal/web/sharing_handlers.go` | 18 | 3 | 15 | 1 |
| `internal/web/alerts.go` | 13 | 0 | 13 | 0 |
| `internal/web/tier2_config_handler.go` | 8 | 3 | 5 | 0 |
| `internal/api/geo.go` | 5 | 0 | 5 | 0 |
| `internal/web/backup_handlers.go` | 12 | 9 | 3 | 0 |
| `internal/cloudflare/countries.go` | 113 | 113 | 0 (the TABLE stays — it validates codes) | 0 |
**The wire:** `internal/report/builder.go` copies Status/Issues/Warnings only; the kinds are internal.
Pinned by field-set + byte test, and confirmed live (the health block on 0.251.0 carries exactly
`issues, status, warnings`).
Mechanism: `i18n.Bundle.Msgf`; `web.(*Server).msg/msgLang/msgN`; `api.(*Router).msg/msgLang/langFor/countryName`;
`flashQuery`/`flashText`/`flashFrom`; `Alert.MessageKey`/`MessageArgs`/`LinkTextKey` + `Alert.rendered(lang)`;
`TitleArgs` for the four app-named titles (R-566, closed).
## Gates
**Word order without a second syntax.** The task asked for a named-parameter (`{{.Name}}`) form for
multi-parameter messages. It is not here, deliberately: English reorders with Go's own explicit
argument indexes (`%[2]s`), which `fmt` already understands. The Hungarian value then stays the format
string the code always had, byte for byte — which is exactly what the parity gate compares, so the
measurement needs no exception. `TestBundleParametersMatchAcrossLanguages` counts an indexed verb as
one verb, so a reordered English still has to use the same verbs on the same values.
`go build ./... && go vet ./... && go test ./...` → rc 0 before each push. `controller_gates.py --fast`
→ rc 0. No `--no-verify`. One existing test (`TestClassifyOffsiteFailure_EachCauseIsDistinct`) built the
quota error the old way and was adapted to build it as the run does — stated here rather than silently.
## 3. The wire — surveyed, frozen, not translated (A.1)
## Live validation (endpoint-level; no browser on DooPlex) — demo-hp guest 9201
Everything a HUB reads keeps its bytes. Filed against **R-558** (slice 3 owns it):
Deployed by hand; **fleet floor unchanged (0.250.0), no golden.**
- **Hungarian pages 0.250.0 → 0.251.0:** `/launcher` and `/monitoring` identical; `/backups/remote`
identical apart from the new attribute and the poll line (+23 bytes); `/dashboard` differs in live
numbers only.
- **Deploy refusal:** `POST /api/stacks/adventurelog/deploy` on an already-deployed app → **409 before
and after with byte-identical message**; the app kept running.
- **Site 5 live:** `id="offbox-status-value" data-status="ok"` and `v.dataset.status === 'running'`.
- **Hub:** `health.warnings` `[]` before and after, health keys unchanged.
- **Not provoked live, and why:** the 400 refusals (a live probe starts an install), the quota failure
(would need a full quota), the stale note (would need clearing the selection), and the disk-warning
placement (this box has no storage warning — 0 banner blocks before and after). All four are carried
by red-proofed tests.
- **A mistake, and what I did:** the FIRST live probe posted an empty value map to `vaultwarden`, which
has no empty required field — so the deploy was accepted (202) and the app installed. I stopped it and
removed it (with the data volume it had just created) within two minutes, verified no container
remained, and rewrote the probe to the 409 form. Nothing else on the box was touched; the standing
apps and `bentopdf` were not involved.
| producer | where it lands | why it stays |
|---|---|---|
| `internal/monitor/healthcheck.go` — 5 storage sentences | report `health.warnings` / `health.issues` | the hub stores and shows them to the operator |
| `internal/notify/notifier.go` — 31 event messages | event `message` → `FormatCustomerEmail` | **the household reads these in an e-mail**; several types have no `customerMessages` entry precisely so this sentence is the mail |
| `internal/cloudflare/countries.go` — the table | nothing (codes only) | it validates codes; the DISPLAY name is what follows the language |
## Teardown
Pinned by `TestStorageWarningWireTextIsFrozen`, `TestStorageWarningFormatsAreFrozen`,
`TestEventMessageWireTextIsFrozen`, `TestEventMessagesCarryNoBundleKey`. Both goldens were captured
from the producers at the base commit, before any change, and both were seen to fail.
Machine: guest on `hu`, controller 0.251.0; password file and probe scripts shredded/removed; evidence
pulled off before anything was cleaned. Host: transfer files removed. Hub: nothing written; the DB copy
shredded. Nothing provisioned.
The **persisted** text found by the same survey is listed in §6 for release C.
## Observations
## 4. Tests and their red-proofs
1. Four more API handlers pick their status by matching ENGLISH internal words (`protected`, `not found`, `not deployed`, `still running`, `not orphaned`). **FILED: R-569**
2. The stale-note display keeps a Hungarian-text fallback for boxes that have not run off-site on ≥ 0.251.0, and slice 2 must not translate that producer until it goes. **FILED: R-570**
3. The off-site failure classifier and the alert-placement rule are described in no architecture document. **FILED: R-571**
4. `required field` was matched by the router and produced by nothing — a dead arm. **NOT-A-FINDING: deleted with the rest of the text chain in this release; nothing to track.**
5. The i18n inventory's comparison detector, re-run on the fixed tree, reports one Go compare and one indirect compare. **NOT-A-FINDING: the first is the known `[INFO]` log-line false positive, the second is the deliberate legacy fallback carried by R-570; decoys prove the detector still convicts.**
Every one was run against a planted break and watched to convict; the planted change was then
reverted and the suite re-run green. Full log: `audits/i18n-slice2-2026-09-18/redproofs.txt`.
| test / gate | what was planted | what convicted |
|---|---|---|
| `i18n_go_parity.py` | one byte added to a hu.json value | `CHANGED`, naming the key and the base literal |
| `i18n_go_parity.py` | a key citing text no literal has | `INVENTED`, naming the text |
| `i18n_go_parity.py` | a joined key with reworded fragments | `INVENTED`, naming the fragment |
| `test_gate_decoys.py` go-parity ×3 | reworded / invented / unlisted | all three rejected |
| `TestEventMessageWireTextIsFrozen` | „telepítve" → „telepitve" | got/want printed |
| `TestStorageWarning*IsFrozen` | a wire warning translated | both tests |
| `TestFlashOnAServerWithNoBundleField` | `s.bundle()` without the `i18n.Shared` fallback | the page showed `flash.share.enabled` |
| `TestFlashKeyRoundTrip` | — | legacy prose, unknown key, `<script>`, empty: all six rows |
| `TestParameterisedPageTitles` | — | the four app-named titles equal the old concatenation |
| `TestAPIMessagesFollowTheHouseholdLanguage` | — | hu byte-identical, en different, per key |
**The gate caught a live defect in itself, not a decoy.** Its first capture filtered literals through
an ASCII-Hungarian word list and missed seven real ones („Naponta", „5 percenkent", „Eletjel
(Heartbeat)", „Adatbazis mentes", „Biztonsagi mentes", „Mentes integritas", „Rendszer allapot") — the
R-565 class exactly. It refused the keys that cited them. The filter is gone: every literal is
indexed, because the only question this index answers is "did the base commit contain this text?".
## 5. Green gate
`go build ./... && go vet ./... && go test ./...` — all green.
`python3 scripts/controller_gates.py` — 16 blocking gates OK + golden-notice advisory, **including the
new `go-parity`**. `python3 scripts/test_gate_decoys.py` — all 23 decoys behaved.
`HU_FORMAL_CEILING` 16 → 18, the measured number, no word changed (R-516 owns the words).
## 6. What is NOT in this release — each is a row, not prose
- **894 Hungarian literals remain**, of which **176 are `fmt.Errorf`/`errors.New`** — release B.
- **Persisted text** (release C): `settings.OffboxTarget.LastError/LastWarning/LastProofReason`,
`LastSyncError`, `RestoreOpResult`, the `unitRestore*MsgFmt` and `tier2*Msg` family in
`handlers.go`, and `backup/offbox_reconstitute.go`. Written by a background run, read days later.
**The §16 decision stands as its default: option 1** — written in the box's language at the time,
with a household that switches seeing the old language until the next run.
- **R-570's producer is untouched**, as its row requires.
- New rows: **R-572** (`pruneLabel`/`fmtDuration` in `funcmap.go` are copy-producing helpers that
`localeFuncs` does not override — „vasárnap" renders on an English page), **R-573** (the
agent-channel and endpoint-drift banner text is composed by the channel-health checker and reaches
`Alert.Message` as finished Hungarian), **R-574** (`handler_debug.go`, 39 literals, page copy and
JSON payload not yet separated).
## 7. Live validation
Endpoint-level on demo-hp guest 9201 (no browser on DooPlex — `claude-in-chrome` is not available
here). Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/A/live/`.
+29 -4
View File
@@ -3614,7 +3614,7 @@ without reaching `Images()`.
---
### 18. Dashboard language (i18n) (v0.247.0–v0.250.0)
### 18. Dashboard language (i18n) (v0.247.0–v0.252.0)
Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
@@ -3622,8 +3622,10 @@ Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
**What a household sees:** Hungarian by default, and since v0.250.0 a **Magyar / English** switch in the
sidebar footer of every dashboard page. Every template is converted (v0.247.0 three pages, v0.248.0
apps and settings, v0.249.0 backups, v0.250.0 storage, sharing, sign-in, claim, guest share, catch-all,
debug). Server-built messages (flash lines, errors, labels built in Go), three page titles built in Go
around an app name, and catalog copy stay Hungarian (slices 2 and 5).
debug). Since **v0.252.0** the sentences the program BUILDS follow the language too — flash lines, page
data, the JSON the page's script reads, the alert banners, the country names, and the four page titles
built around an app name. Still Hungarian: `fmt.Errorf` messages (release B), text persisted by a
background run (release C), catalog copy (slice 5), and **everything the hub reads** (see below).
- **Bundles:** `internal/i18n/locales/hu.json` (authoritative, every key) and `en.json`, embedded.
Flat `key → text`; a value may carry template actions (`{{.RecoveryAbandonDate}}`) and inline
@@ -3648,11 +3650,34 @@ around an app name, and catalog copy stay Hungarian (slices 2 and 5).
literal by `TestHandlerTitleKeysMatchHungarianTitle`); non-Hungarian sets override the copy-producing
template funcs (`stateLabel`, `timeAgo`, `timeAgoStr`, `nextRunLabel`, `statusText`, `infraMeta`) from
the bundle. The Hungarian funcs are untouched.
- **Go-side messages (v0.252.0):** `Bundle.Msgf` fills a message's own printf verbs; English reorders
with Go's explicit argument indexes (`%[2]s`), so the Hungarian value stays the format string the code
always had. Handlers use `s.msg(r, key, …)`; background and view-model builders take a `lang` and use
`s.msgLang`; the API has the same pair (`internal/api/i18n_api.go`). `s.bundle()` falls back to the
embedded `i18n.Shared()`, so a Server built without `loadTemplates` still renders sentences.
- **Flash lines (v0.252.0):** a flash travels in the redirect URL and is rendered by the NEXT request,
so it carries a bundle KEY plus repeated `fa` parameters (`?flash=flash.share.enabled&fa=…`), built
with `flashQuery` and resolved with `s.flashFrom`. A value the bundle does not know — a link minted by
an older controller — is shown verbatim, never as a key and never dropped.
- **Alert banners (v0.252.0):** an alert is built by a background health cycle and read minutes later,
so `Alert` carries `MessageKey` + `MessageArgs` (and `LinkTextKey`) and `GetAlerts(lang)` renders on
the way out. An alert with no key renders `Message` verbatim — which is how the two exceptions work.
- **Country names (v0.252.0):** `country.<ISO2>` keys, translated at DISPLAY in `internal/api/geo.go`
and re-sorted per language. The `internal/cloudflare` table is untouched: it validates codes, and the
report puts codes (never names) on the wire.
- **NOT translated — it is not ours:** the report's `health.warnings` / `health.issues` and every
`notify` event `message`. The hub composes the household's e-mail from those and falls back to the
controller's own sentence when it has no entry (`FormatCustomerEmail`), so translating them here would
change an e-mail nobody asked to change. They follow the language in slice 3 (R-558). Pinned by wire
goldens in `internal/monitor` and `internal/notify`.
- **Report:** the hub report carries `"language"` (always present). No hub release reads it yet.
- **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into
hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys
exist, no orphans, the English gap and the formal „ön" count only shrink (ratchets), no pleading
English. The emoji, native-confirm, secret-in-markup and retrieval-promise gates read templates
English. `scripts/i18n_go_parity.py` (v0.252.0, in `controller_gates.py`) freezes every Go string
literal at slice 2's base commit (`scripts/i18n_go_base.json`) and refuses a key whose Hungarian is not
that text byte for byte; `scripts/i18n_go_keys.json` records what each key replaced. Three decoys.
The emoji, native-confirm, secret-in-markup and retrieval-promise gates read templates
**expanded** through `scripts/i18n_bundle.py`; mojibake scans the bundles.
- **Coverage:** `TestI18nParityCoversEveryMarker` fails when any marker occurrence is rendered by no
parity case. Fixtures are written only when missing (`-update-i18n-golden`, `-i18n-golden-only`).
+6 -2
View File
@@ -57,11 +57,15 @@ func TestRefuseNetworkStubDeploy_Table(t *testing.T) {
}
for _, c := range cases {
r := newDeployGateRouter(t, c.class)
msg := r.refuseNetworkStubDeploy(c.hdd)
// v0.252.0 (R-557): the gate returns a bundle KEY, so the customer reads it in their own
// language. Rendered here rather than compared as a key, because what this test is about is
// the SENTENCE the customer is refused with -- a key renamed with its message intact is not
// a regression, and a message reworded is.
msg := r.msgLang("hu", r.refuseNetworkStubDeploy(c.hdd))
if c.refuse && !strings.Contains(msg, "a telepítés nem indítható") {
t.Errorf("%s: want the refusal message, got %q", c.name, msg)
}
if !c.refuse && msg != "" {
if !c.refuse && r.refuseNetworkStubDeploy(c.hdd) != "" {
t.Errorf("%s: must proceed, got refusal %q", c.name, msg)
}
}
+22 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"sort"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -86,12 +87,12 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
// away instead of after the next ~15-min cycle.
r.reportPushNow()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.set")})
}
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
func (r *Router) geoTriggerSync(w http.ResponseWriter, req *http.Request) {
if r.geoSync == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: r.msg(req, "api.geo.no_cloudflare")})
return
}
@@ -105,11 +106,23 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
r.reportPushNow()
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.sync_started")})
}
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
// geoCountries serves the picker's country list in the request's language.
//
// The TABLE in internal/cloudflare is untouched and stays Hungarian: it is also the validator, and
// internal/report puts country CODES on the wire. Only the NAME shown to the customer is translated,
// and the list is re-sorted afterwards -- alphabetical order is per language, and a list sorted by
// the Hungarian names would read as shuffled in English.
func (r *Router) geoCountries(w http.ResponseWriter, req *http.Request) {
lang := r.langFor(req)
list := cf.AllCountries()
for i := range list {
list[i].Name = r.countryName(lang, list[i].Code, list[i].Name)
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
}
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
@@ -159,10 +172,10 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
}()
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_set")})
}
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
if appName == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
return
@@ -185,5 +198,5 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
}()
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_removed")})
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net/http"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// ── The JSON answers follow the language too (v0.252.0, slice 2 — R-557) ───────────────────────
//
// Design: felhom.eu/documentation/architecture/10-localisation.md. The dashboard's page markup has
// followed the household's language since slice 1, but a large part of what a customer READS on
// those pages never passes through a template: the page's own script fetches /api/… and shows the
// `message` or `error` the answer carries. Until now those were Hungarian literals, so an English
// dashboard answered in Hungarian the moment anything was clicked.
//
// These helpers mirror internal/web's (i18n_web.go) exactly, and for the same reason: the Hungarian
// text is the SAME BYTES the literal carried, pinned by scripts/i18n_go_parity.py against a frozen
// capture of the base commit.
//
// NOT everything here is display. `internal/report` and `internal/notify` put Hungarian on the wire,
// where it is read by the hub and mailed to the household — those stay Hungarian in every language
// until slice 3 (R-558) teaches the hub which language to compose in, and their bytes are pinned by
// the wire goldens in those packages.
// langFor decides the language of one API request: `?lang=hu|en` (a testing override, never
// persisted), else the household's saved setting, else Hungarian. Identical in meaning to
// web.Server.langFor, deliberately — the page and the calls its script makes must never disagree.
func (r *Router) langFor(req *http.Request) string {
if req != nil {
if q := req.URL.Query().Get("lang"); i18n.IsSupported(q) {
return q
}
}
if r.sett != nil {
return r.sett.GetLanguage()
}
return i18n.Default
}
// bundle returns the process-wide bundle, or nil if it somehow failed to load. It is embedded in the
// binary, so a running box cannot reach the nil case; the callers still degrade to the key rather
// than panicking.
func bundle() *i18n.Bundle {
b, err := i18n.Shared()
if err != nil {
return nil
}
return b
}
// msg returns a message in the request's language, with the message's own printf verbs filled in.
func (r *Router) msg(req *http.Request, key string, a ...interface{}) string {
return r.msgLang(r.langFor(req), key, a...)
}
// msgLang is msg for a language that is already known.
func (r *Router) msgLang(lang, key string, a ...interface{}) string {
b := bundle()
if b == nil {
return key
}
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
// countryName returns a country's name in lang, falling back to the name the cloudflare table
// carries when the bundle has no entry for the code.
//
// The TABLE is not translated and must not be: internal/report puts country CODES on the wire, and
// cloudflare/countries.go is also what validates them. Only the NAME shown in the geo picker follows
// the language, which is why this lives here and not in that package.
func (r *Router) countryName(lang, code, fallback string) string {
b := bundle()
if b == nil {
return fallback
}
if v, _, ok := b.Text(lang, "country."+code); ok {
return v
}
return fallback
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Localisation slice 2 (R-557), scenario S2 — the JSON the page's own script reads follows the
// household's language, and the Hungarian answer is byte-identical to what it was.
//
// This is the half of the dashboard a template test cannot see. Since slice 1 the PAGE has been
// English, but every sentence that arrives by fetch — a refused deploy, a start blocked for memory,
// "the backup has started" — was a Hungarian literal in internal/api. An English household clicked an
// English button and was answered in Hungarian.
//
// The Hungarian side is a parity measurement, not a translation check: the `want` strings below are
// the literals that stood at the call sites at 736f54b49610.
func langRouter(t *testing.T, lang string) *Router {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if lang != "" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
return &Router{sett: sett, logger: lg}
}
func TestAPIMessagesFollowTheHouseholdLanguage(t *testing.T) {
cases := []struct {
key, hu, en string
args []interface{}
}{
{key: "api.deploy.started",
hu: "Telepítés elindítva – az állapot a kártyán követhető",
en: "The install has started – the card shows how it is going"},
{key: "api.backup.already_running",
hu: "Mentés már folyamatban", en: "A backup is already running"},
{key: "api.start.drive_missing", args: []interface{}{"Kulso HDD"},
hu: "A(z) Kulso HDD tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
en: "Storage Kulso HDD cannot be reached right now — the app cannot start until the drive is back."},
{key: "api.start.not_enough_memory", args: []interface{}{512, 128, 1400, 1528},
hu: "Nincs elég memória az indításhoz. Szükséges: 512 MB, elérhető: 128 MB (használt: 1400 MB / használható: 1528 MB)",
en: "There is not enough memory to start. Needs 512 MB, 128 MB is free (1400 MB used of 1528 MB usable)"},
}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
// hu: the saved setting, no query override — the ordinary household.
r := langRouter(t, "hu")
req := httptest.NewRequest("POST", "/api/stacks/x/start", nil)
if got := r.msg(req, c.key, c.args...); got != c.hu {
t.Errorf("Hungarian answer moved (parity)\n got %q\n want %q", got, c.hu)
}
// en: the same request shape on a box switched to English.
r = langRouter(t, "en")
if got := r.msg(req, c.key, c.args...); got != c.en {
t.Errorf("English answer\n got %q\n want %q", got, c.en)
}
})
}
}
// TestAPILangForPrecedence — `?lang=` overrides the saved setting (the testing door slice 0 built),
// an unsupported value is ignored rather than honoured, and a box with no setting answers Hungarian.
func TestAPILangForPrecedence(t *testing.T) {
cases := []struct{ saved, query, want string }{
{"hu", "", "hu"},
{"en", "", "en"},
{"hu", "en", "en"},
{"en", "hu", "hu"},
{"en", "de", "en"}, // unsupported: ignored, the saved setting stands
{"", "", i18n.Default},
}
for _, c := range cases {
r := langRouter(t, c.saved)
url := "/api/stacks"
if c.query != "" {
url += "?lang=" + c.query
}
if got := r.langFor(httptest.NewRequest("GET", url, nil)); got != c.want {
t.Errorf("saved=%q query=%q: got %q, want %q", c.saved, c.query, got, c.want)
}
}
}
// TestGeoCountryNamesFollowTheLanguage — the picker's 237 country names. The cloudflare TABLE is not
// translated (it validates codes, and internal/report puts codes on the wire); only the displayed
// name is, and the list is re-sorted afterwards because alphabetical order is per language.
func TestGeoCountryNamesFollowTheLanguage(t *testing.T) {
r := langRouter(t, "hu")
for _, c := range []struct{ lang, code, want string }{
{"hu", "DE", "Németország"},
{"en", "DE", "Germany"},
{"hu", "US", "Egyesült Államok"},
{"en", "US", "United States"},
{"hu", "AO", "Angola"}, // identical in both languages — must still resolve, not fall through
{"en", "AO", "Angola"},
} {
if got := r.countryName(c.lang, c.code, "FALLBACK"); got != c.want {
t.Errorf("%s/%s: got %q, want %q", c.lang, c.code, got, c.want)
}
}
// A code the bundle does not carry keeps the table's own name rather than showing a key.
if got := r.countryName("en", "ZZ", "Ismeretlen"); got != "Ismeretlen" {
t.Errorf("an unknown code must keep the table name, got %q", got)
}
// And the English list really is sorted by the English names: „Németország" sorts under N in
// Hungarian and Germany under G, so the two orders must differ.
if strings.EqualFold(r.countryName("hu", "DE", ""), r.countryName("en", "DE", "")) {
t.Error("the country names did not change with the language at all")
}
}
@@ -1,6 +1,7 @@
package api
import (
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"go/ast"
"go/parser"
"go/token"
@@ -77,21 +78,38 @@ func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
//
// v0.252.0 (R-557) moved the sentence into the message bundle, so router.go now names a KEY. The
// ruling is unchanged and still checkable in one step more: router.go must name that key, and the
// key must still carry the ruled sentence, byte for byte. Both halves matter — a key with no handler
// is dead copy, and a handler naming a key whose text was reworded is the drift this test exists to
// catch. scripts/i18n_go_parity.py proves the same equality against the base commit independently.
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
const key = "api.deploy.not_installable"
const want = "Ez az alkalmazás jelenleg nem telepíthető."
found := false
ast.Inspect(f, func(n ast.Node) bool {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, key) {
found = true
}
return true
})
if !found {
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
t.Fatalf("router.go no longer names %q -- the ruled refusal has no handler", key)
}
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
if got := b.Msg(i18n.Default, key); got != want {
t.Fatalf("the ruled refusal was reworded\n hu.json %s = %q\n ruled %q", key, got, want)
}
if en := b.Msg("en", key); en == "" || en == want {
t.Errorf("%s has no English of its own: %q", key, en)
}
}
+31 -32
View File
@@ -109,8 +109,8 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
// local (non-network) and empty paths keep today's behavior exactly.
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
@@ -120,7 +120,7 @@ func (r *Router) refuseNetworkStubDeploy(hdd string) string {
if r.classifyFSPath(hdd) != system.FSClassStub {
return ""
}
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
return "api.deploy.netstorage_unreachable"
}
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
@@ -223,19 +223,19 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// POST /api/stacks/{name}/start
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
r.actionStack(w, "start", extractName(path, "/start"))
r.actionStack(w, req, "start", extractName(path, "/start"))
// POST /api/stacks/{name}/stop
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
r.actionStack(w, "stop", extractName(path, "/stop"))
r.actionStack(w, req, "stop", extractName(path, "/stop"))
// POST /api/stacks/{name}/restart
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
r.actionStack(w, "restart", extractName(path, "/restart"))
r.actionStack(w, req, "restart", extractName(path, "/restart"))
// POST /api/stacks/{name}/update
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
r.actionStack(w, "update", extractName(path, "/update"))
r.actionStack(w, req, "update", extractName(path, "/update"))
// POST /api/stacks/{name}/optional-config
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
@@ -441,7 +441,7 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
name, st.Meta.EffectiveLifecycle())
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
Error: r.msg(req, "api.deploy.not_installable")})
return
}
@@ -452,9 +452,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
hr.AvailGB, hr.ReserveGB)})
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
return
}
@@ -462,9 +461,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
// send the app's data to the system drive.
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
return
}
@@ -499,7 +498,7 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
if warning != "" {
resp.Data = map[string]string{"warning": warning}
}
@@ -579,7 +578,7 @@ func desiredStateForAction(action string) (string, bool) {
}
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
@@ -610,7 +609,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if gated, hdd := r.startGatedByMissingDrive(name); gated {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
Error: r.msg(req, "api.start.drive_missing", hdd),
})
return
}
@@ -630,7 +629,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if afterMB > usableMB {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
})
return
}
@@ -666,7 +665,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
writeJSON(w, http.StatusInternalServerError, apiResponse{
OK: false,
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
Error: r.msg(req, "api.action.state_not_saved"),
})
return
}
@@ -708,7 +707,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
if action == "update" {
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: "Frissítés elindult – az állapot a kártyán követhető",
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
Data: map[string]interface{}{"accepted": true, "completed": false}})
return
}
@@ -743,7 +742,7 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request,
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
}
// --- Integration API handlers ---
@@ -788,9 +787,9 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro
return
}
msg := "Integráció engedélyezve"
msg := r.msg(req, "api.integration.enabled")
if !body.Enabled {
msg = "Integráció kikapcsolva"
msg = r.msg(req, "api.integration.disabled")
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
}
@@ -1117,7 +1116,7 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
// moved to the host agent (slice 8C).
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
@@ -1126,7 +1125,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
if r.backupMgr.IsRunning() {
r.dbg("triggerBackup: backup already running, rejecting")
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
return
}
@@ -1134,23 +1133,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
go r.backupMgr.RunDBDumps(context.Background())
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
}
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
if r.backupMgr.IsRunning() {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
return
}
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
go r.backupMgr.RunAllTier2()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
}
// --- Metrics handlers ---
@@ -1337,7 +1336,7 @@ func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
}
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
if r.updater == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
return
@@ -1347,7 +1346,7 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
return
}
r.logger.Println("[INFO] [api] Manual self-update triggered")
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
}
// --- Config apply handler ---
@@ -1377,7 +1376,7 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
// idempotently, and a self-restart on every push would be a needless flap.
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.unchanged")})
return
}
@@ -1394,7 +1393,7 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
// effect on restart — singletons such as the Cloudflare client are built once at
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.applied")})
flushResponse(w)
if r.restart != nil {
r.restart()
@@ -97,7 +97,7 @@ func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards,
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, "update", "app")
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
+14
View File
@@ -129,6 +129,20 @@ func (b *Bundle) Msg(lang, key string) string {
return key
}
// Msgf is Msg with the message's own printf verbs filled in — the Go-side counterpart of a template
// message that carries `{{.Field}}`.
//
// WORD ORDER, and why there is no named-parameter form here. A Hungarian value is the format string
// the Go code always had, byte for byte (that is what scripts/i18n_go_parity.py measures), so it
// carries plain positional verbs. English reorders with Go's own explicit argument indexes —
// `%[2]s %[1]s` — which fmt understands and which needs no second mechanism, no second syntax for a
// translator to get wrong, and no exception in the parity gate. TestBundleParametersMatchAcrossLanguages
// compares the verbs with the indexes stripped, so a reordered English still has to use the same
// verbs on the same values.
func (b *Bundle) Msgf(lang, key string, a ...interface{}) string {
return fmt.Sprintf(b.Msg(lang, key), a...)
}
// Plural picks a count-dependent form and formats n into it (%d). Hungarian does not inflect a noun
// after a numeral ("3 perce", "1 perce"), so hu carries ONE form under the key itself. English carries
// key+".one" and key+".other". A language without the split forms falls back to the plain key.
+6 -1
View File
@@ -114,7 +114,12 @@ var fieldRefRe = regexp.MustCompile(`\.[A-Z][A-Za-z0-9_]*(\.[A-Z][A-Za-z0-9_]*)*
// verbRe counts printf verbs OUTSIDE template actions ({{printf "%.0f" .X}} is a parameter, not a verb
// of the message), and without the space flag — „50% szabad" is prose, not „% s".
var verbRe = regexp.MustCompile(`%[-+#0-9.]*[sdvq]`)
//
// `%[2]s` — an EXPLICIT ARGUMENT INDEX — counts as one verb (slice 2, R-557). It is how a translation
// reorders a Go-side message while the Hungarian keeps the plain positional verbs the code always had,
// which is what keeps hu.json byte-identical to the base-commit format string. The index is part of
// the verb, not an extra one, so the counts on both sides stay comparable.
var verbRe = regexp.MustCompile(`%(?:\[\d+\])?[-+#0-9.]*[sdvq]`)
func refs(s string) []string {
var out []string
+427 -1
View File
@@ -1589,5 +1589,431 @@
"storage.figyelem": "WARNING:",
"storage_init.jelenlegi": "current:",
"debug.mp": "s",
"debug.p": "m"
"debug.p": "m",
"flash.share.already_on": "Sharing is already on.",
"flash.share.enable_failed": "Sharing could not be turned on.",
"flash.share.enabled": "Sharing is on.",
"flash.share.not_on": "Sharing is not on.",
"flash.share.relink_failed": "The new link could not be created.",
"flash.share.relinked": "A new share link is ready. The old link and every earlier sign-in are now invalid.",
"flash.share.disable_failed": "Sharing could not be turned off.",
"flash.share.disabled": "Sharing is off.",
"flash.share.pw_clear_failed": "The password could not be removed.",
"flash.share.pw_cleared": "The share password is removed.",
"flash.share.pw_too_short": "The password must be at least 8 characters long.",
"flash.share.pw_set_failed": "The password could not be set.",
"flash.share.pw_set": "The share password is set. Earlier sign-ins are now invalid.",
"flash.restore.started": "The restore has started — the status updates here.",
"flash.restore.file_started": "The file restore has started — the status updates here.",
"flash.restore.full_started": "The full restore has started — the status updates here.",
"flash.login.password_changed": "Your password is changed. Sign in with the new one.",
"flash.backup.window_invalid_time": "That is not a valid time. Use the HH:MM format (02:30, for example).",
"flash.backup.window_save_failed": "The backup window could not be saved.",
"flash.backup.window_updated": "The backup window is updated.",
"flash.sharing.save_failed": "That could not be saved.",
"flash.sharing.disabled": "Network sharing is off. The folders and the files are still there.",
"flash.sharing.prepare_running": "The sharing service is already being prepared.",
"flash.sharing.saved_preparing": "Setting saved. The sharing service is being prepared…",
"flash.sharing.pw_too_short": "The password must be at least 8 characters long.",
"flash.sharing.pw_mismatch": "The two passwords do not match.",
"flash.sharing.pw_set_failed": "The password could not be set.",
"flash.sharing.pw_set_preparing_already": "The sharing password is set. Preparation is already running.",
"flash.sharing.pw_set_preparing": "The sharing password is set. The sharing service is being prepared…",
"flash.sharing.folder_failed": "The folder could not be created.",
"flash.sharing.created": "The share is created.",
"flash.sharing.protected": "This share is part of the system and cannot be deleted.",
"flash.sharing.deleted": "The share is deleted — the folder and the files are still there.",
"flash.sharing.saved": "Setting saved.",
"flash.tier2.target_invalid": "The drive you picked is not valid.",
"flash.tier2.save_failed": "The setting could not be saved.",
"flash.tier2.saved": "The second backup setting is saved.",
"flash.tier2.app_email_off": "Email sending is off for this app.",
"flash.tier2.app_email_on": "Email sending is on for this app.",
"flash.offbox.mgr_unavailable": "The backup manager is not reachable.",
"flash.offbox.fields_required": "The target address, the user and the storage path are all required.",
"flash.offbox.path_absolute": "The storage path must be absolute (it has to start with /).",
"flash.offbox.config_invalid": "That setting is not valid: %s",
"flash.offbox.first_setup_needs_key": "The first setup needs both the SSH private key and the target machine’s known-host line.",
"flash.offbox.creds_save_failed": "The credentials could not be saved.",
"flash.offbox.save_failed": "The setting could not be saved.",
"flash.offbox.target_saved": "The remote backup target is saved.",
"flash.offbox.target_saved_escrow_agent_down": "The remote backup target is saved. — the key could not be prepared for safekeeping (the agent is not reachable); try again.",
"flash.offbox.target_saved_escrow_failed": "The remote backup target is saved. — the key could not be prepared for safekeeping; try again.",
"flash.offbox.target_not_set": "The remote backup target is not set.",
"flash.offbox.escrow_confirmed": "The key is confirmed in safekeeping — the remote backup can run from now on.",
"flash.offbox.repo_pw_required": "The repository password is required.",
"flash.offbox.repo_pw_failed": "The password could not be set: %s",
"flash.offbox.repo_pw_set": "The recovered repository password is set.",
"flash.offbox.app_missing": "The app is missing.",
"flash.offbox.app_setting_updated": "The remote backup setting is updated.",
"flash.offbox.waiting_for_escrow": "The remote backup is waiting for the key to be put into safekeeping.",
"flash.offbox.repo_orphaned": "The remote store is orphaned — start a new remote backup first, the way the card shows.",
"flash.offbox.run_started": "The remote backup has started — the status updates here.",
"flash.offbox.not_orphaned": "The off-site store is not orphaned.",
"flash.offbox.needs_confirmation": "The restore needs your confirmation.",
"flash.offbox.restart_started": "A new remote backup is starting — the old history is set aside, not deleted.",
"flash.offbox.restore_started": "The remote restore has started — the status updates here.",
"flash.offbox.full_needs_confirmation": "The full restore cannot run without your confirmation.",
"flash.offbox.full_restore_started": "The full restore has started — the status updates here.",
"flash.offbox.mgr_unreachable": "The backup manager cannot be reached.",
"flash.offbox.scratch_missing": "The check copy is missing.",
"flash.offbox.delete_needs_confirmation": "The delete cannot run without your confirmation.",
"flash.offbox.delete_failed": "The copy could not be deleted: %s",
"flash.offbox.scratch_deleted": "The check copy is deleted. Your real data is unchanged.",
"flash.offbox.recover_started": "The recovery has started — the status updates here.",
"flash.offbox.shares_restore_started": "The share restore has started — the status updates here.",
"flash.offbox.shares_recover_started": "The share recovery has started — the status updates here.",
"flash.offbox.run_already_going": "A remote backup is already running — this request did not start another one. What you see is still the earlier run; wait for it to finish.",
"page.title.logs": "%s — Logs",
"page.title.deploy": "%s — Install",
"page.title.app_settings": "%s — Settings",
"page.title.tier2_config": "Second backup setup — %s",
"logs.fetch_failed": "The logs could not be read: %v",
"storage.bar_purpose": "External storage — the large files of your installed apps (media, documents) go here; the databases stay on the internal SSD.",
"deploy.path_not_allowed": "network storage — cannot be picked for an app",
"creds.filebrowser_note": "This is the File manager sign-in. Felhom set the password on this machine.",
"ping.label.heartbeat": "Heartbeat",
"ping.label.system_health": "System health",
"ping.label.db_dump": "Database backup",
"ping.label.backup": "Backup",
"ping.label.integrity": "Backup integrity",
"ping.schedule.5min": "Every 5 minutes",
"ping.schedule.daily_at": "Daily at %s",
"offbox.selection_changed": "The selection changed since the last run — the next remote backup includes it.",
"backup.contents.db": "Config",
"backup.contents.data": "Data",
"backup.status.ok": "App data backup is fine",
"backup.status.db_failed": "The database backup failed",
"backup.tier2.no_drive": "No second drive",
"backup.tier2.badge_ok": "Done",
"backup.tier2.badge_error": "Error",
"backup.tier2.badge_running": "Running...",
"backup.tier2.schedule_daily": "Daily",
"backup.removed_app": "Removed app — its backup is here and can be restored",
"backup.tier2.dest_ssd": "internal SSD (database and settings only)",
"flash.backup.not_configured": "Backup is not set up",
"escrow.no_retrieval_password": "This machine has no stored recovery password.",
"escrow.stack_mgr_unavailable": "The app manager is not reachable.",
"escrow.unknown_app": "Unknown app.",
"escrow.missing_field": "A field is missing.",
"escrow.field_not_revealable": "This field cannot be revealed.",
"escrow.app_settings_unreadable": "The app’s settings cannot be read.",
"escrow.no_stored_value": "There is no stored value for this field.",
"escrow.initial_pw_read_failed": "The first password could not be read.",
"escrow.initial_pw_unavailable": "The first password cannot be read right now — the app has to be running, and the file may have been deleted after the first sign-in.",
"escrow.settings_unavailable": "The settings are not reachable.",
"escrow.filebrowser_not_ours": "Felhom did not set the File manager password on this machine, so we cannot show it.",
"escrow.password_unreadable": "The password cannot be read right now.",
"settings.pw_wrong_current": "That is not your current password",
"settings.pw_too_short": "The password must be at least 8 characters long",
"settings.pw_mismatch": "The two passwords do not match",
"settings.pw_save_error": "Something went wrong while saving the password",
"settings.notify_email_required": "Give an address for notifications – with notifications on, we need somewhere to send them.",
"settings.notify_save_error": "Something went wrong while saving the settings",
"settings.notify_saved_sync_failed": "Notification settings saved on this machine. Syncing them to the central service failed: %v",
"settings.notify_saved": "Notification settings saved.",
"settings.app_email_save_error": "Something went wrong while saving the app email setting",
"settings.app_email_shim_failed": "The setting is saved, but the email service could not be started.",
"settings.app_email_on": "App email is on. Turn it on for each app that should send email.",
"settings.app_email_off": "App email is off.",
"settings.notify_disabled": "Notifications are not turned on",
"settings.test_email_failed": "The test email could not be sent: %v",
"settings.test_email_sent": "The test email is sent.",
"storage.err_path_missing": "That path does not exist, or it is not a folder.",
"storage.err_not_separate": "That path is not a separately mounted drive. The data would land on the SSD.",
"storage.err_not_writable": "That path cannot be written to.",
"storage.err_overlaps": "That path overlaps the already registered path %s.",
"storage.err_save": "Something went wrong while saving.",
"storage.err_in_use": "It cannot be removed: these apps use it: %s",
"storage.err_default": "The default storage cannot be removed.",
"storage.err_last": "The last storage cannot be removed.",
"storage.err_delete": "Something went wrong while removing it.",
"storage.err_label": "The name cannot be empty and can be at most 50 characters.",
"storage.err_label_save": "Something went wrong while saving the name.",
"storage.msg_added": "Storage added: %s",
"storage.msg_removed": "Storage removed: %s",
"storage.msg_default_set": "Default storage set: %s",
"storage.msg_state_changed": "Storage state changed: %s",
"storage.msg_label_changed": "Name changed: %s",
"country.AF": "Afghanistan",
"country.AL": "Albania",
"country.DZ": "Algeria",
"country.AS": "American Samoa",
"country.AD": "Andorra",
"country.AO": "Angola",
"country.AI": "Anguilla",
"country.AQ": "Antarctica",
"country.AG": "Antigua and Barbuda",
"country.AR": "Argentina",
"country.AM": "Armenia",
"country.AW": "Aruba",
"country.AU": "Australia",
"country.AT": "Austria",
"country.AZ": "Azerbaijan",
"country.BS": "Bahamas",
"country.BH": "Bahrain",
"country.BD": "Bangladesh",
"country.BB": "Barbados",
"country.BY": "Belarus",
"country.BE": "Belgium",
"country.BZ": "Belize",
"country.BJ": "Benin",
"country.BM": "Bermuda",
"country.BT": "Bhutan",
"country.BO": "Bolivia",
"country.BA": "Bosnia and Herzegovina",
"country.BW": "Botswana",
"country.BR": "Brazil",
"country.BN": "Brunei",
"country.BG": "Bulgaria",
"country.BF": "Burkina Faso",
"country.BI": "Burundi",
"country.CV": "Cabo Verde",
"country.KH": "Cambodia",
"country.CM": "Cameroon",
"country.CA": "Kanada",
"country.KY": "Cayman Islands",
"country.CF": "Central African Republic",
"country.TD": "Chad",
"country.CL": "Chile",
"country.CN": "China",
"country.CO": "Colombia",
"country.KM": "Comoros",
"country.CG": "Congo",
"country.CD": "Democratic Republic of the Congo",
"country.CK": "Cook Islands",
"country.CR": "Costa Rica",
"country.CI": "Cote d’Ivoire",
"country.HR": "Croatia",
"country.CU": "Cuba",
"country.CW": "Curacao",
"country.CY": "Cyprus",
"country.CZ": "Czechia",
"country.DK": "Denmark",
"country.DJ": "Djibouti",
"country.DM": "Dominica",
"country.DO": "Dominican Republic",
"country.EC": "Ecuador",
"country.EG": "Egypt",
"country.SV": "El Salvador",
"country.GQ": "Equatorial Guinea",
"country.ER": "Eritrea",
"country.EE": "Estonia",
"country.SZ": "Eswatini",
"country.ET": "Ethiopia",
"country.FK": "Falkland Islands",
"country.FO": "Faroe Islands",
"country.FJ": "Fiji",
"country.FI": "Finland",
"country.FR": "France",
"country.GF": "French Guiana",
"country.PF": "French Polynesia",
"country.GA": "Gabon",
"country.GM": "Gambia",
"country.GE": "Georgia",
"country.DE": "Germany",
"country.GH": "Ghana",
"country.GI": "Gibraltar",
"country.GR": "Greece",
"country.GL": "Greenland",
"country.GD": "Grenada",
"country.GP": "Guadeloupe",
"country.GU": "Guam",
"country.GT": "Guatemala",
"country.GG": "Guernsey",
"country.GN": "Guinea",
"country.GW": "Guinea-Bissau",
"country.GY": "Guyana",
"country.HT": "Haiti",
"country.HN": "Honduras",
"country.HK": "Hong Kong",
"country.HU": "Hungary",
"country.IS": "Iceland",
"country.IN": "India",
"country.ID": "Indonesia",
"country.IR": "Iran",
"country.IQ": "Iraq",
"country.IE": "Ireland",
"country.IM": "Isle of Man",
"country.IL": "Israel",
"country.IT": "Italy",
"country.JM": "Jamaica",
"country.JP": "Japan",
"country.JE": "Jersey",
"country.JO": "Jordan",
"country.KZ": "Kazakhstan",
"country.KE": "Kenya",
"country.KI": "Kiribati",
"country.KP": "North Korea",
"country.KR": "South Korea",
"country.KW": "Kuwait",
"country.KG": "Kyrgyzstan",
"country.LA": "Laos",
"country.LV": "Latvia",
"country.LB": "Lebanon",
"country.LS": "Lesotho",
"country.LR": "Liberia",
"country.LY": "Libya",
"country.LI": "Liechtenstein",
"country.LT": "Lithuania",
"country.LU": "Luxemburg",
"country.MO": "Macao",
"country.MG": "Madagascar",
"country.MW": "Malawi",
"country.MY": "Malaysia",
"country.MV": "Maldives",
"country.ML": "Mali",
"country.MT": "Malta",
"country.MH": "Marshall Islands",
"country.MQ": "Martinique",
"country.MR": "Mauritania",
"country.MU": "Mauritius",
"country.YT": "Mayotte",
"country.MX": "Mexico",
"country.FM": "Micronesia",
"country.MD": "Moldova",
"country.MC": "Monaco",
"country.MN": "Mongolia",
"country.ME": "Montenegro",
"country.MS": "Montserrat",
"country.MA": "Morocco",
"country.MZ": "Mozambique",
"country.MM": "Myanmar",
"country.NA": "Namibia",
"country.NR": "Nauru",
"country.NP": "Nepal",
"country.NL": "Netherlands",
"country.NC": "New Caledonia",
"country.NZ": "New Zealand",
"country.NI": "Nicaragua",
"country.NE": "Niger",
"country.NG": "Nigeria",
"country.NU": "Niue",
"country.NF": "Norfolk Island",
"country.MK": "North Macedonia",
"country.MP": "Northern Mariana Islands",
"country.NO": "Norway",
"country.OM": "Oman",
"country.PK": "Pakistan",
"country.PW": "Palau",
"country.PS": "Palestine",
"country.PA": "Panama",
"country.PG": "Papua New Guinea",
"country.PY": "Paraguay",
"country.PE": "Peru",
"country.PH": "Philippines",
"country.PL": "Poland",
"country.PT": "Portugal",
"country.PR": "Puerto Rico",
"country.QA": "Qatar",
"country.RE": "Reunion",
"country.RO": "Romania",
"country.RU": "Russia",
"country.RW": "Rwanda",
"country.BL": "Saint Barthelemy",
"country.SH": "Saint Helena",
"country.KN": "Saint Kitts és Nevis",
"country.LC": "Saint Lucia",
"country.MF": "Saint Martin",
"country.PM": "Saint Pierre and Miquelon",
"country.VC": "Saint Vincent and the Grenadines",
"country.WS": "Samoa",
"country.SM": "San Marino",
"country.ST": "Sao Tome and Principe",
"country.SA": "Saudi Arabia",
"country.SN": "Senegal",
"country.RS": "Serbia",
"country.SC": "Seychelles",
"country.SL": "Sierra Leone",
"country.SG": "Singapore",
"country.SX": "Sint Maarten",
"country.SK": "Slovakia",
"country.SI": "Slovenia",
"country.SB": "Solomon Islands",
"country.SO": "Somalia",
"country.ZA": "South Africa",
"country.SS": "South Sudan",
"country.ES": "Spain",
"country.LK": "Srí Lanka",
"country.SD": "Sudan",
"country.SR": "Suriname",
"country.SE": "Sweden",
"country.CH": "Switzerland",
"country.SY": "Syria",
"country.TW": "Taiwan",
"country.TJ": "Tajikistan",
"country.TZ": "Tanzania",
"country.TH": "Thailand",
"country.TL": "Timor-Leste",
"country.TG": "Togo",
"country.TK": "Tokelau",
"country.TO": "Tonga",
"country.TT": "Trinidad and Tobago",
"country.TN": "Tunisia",
"country.TR": "Turkiye",
"country.TM": "Turkmenistan",
"country.TC": "Turks and Caicos Islands",
"country.TV": "Tuvalu",
"country.UG": "Uganda",
"country.UA": "Ukraine",
"country.AE": "United Arab Emirates",
"country.GB": "United Kingdom",
"country.US": "United States",
"country.UY": "Uruguay",
"country.UZ": "Uzbekistan",
"country.VU": "Vanuatu",
"country.VA": "Vatican City",
"country.VE": "Venezuela",
"country.VN": "Vietnam",
"country.VG": "British Virgin Islands",
"country.VI": "United States Virgin Islands",
"country.WF": "Wallis and Futuna",
"country.EH": "Western Sahara",
"country.YE": "Yemen",
"country.ZM": "Zambia",
"country.ZW": "Zimbabwe",
"api.deploy.netstorage_unreachable": "The network storage you picked cannot be reached from the apps right now, so the install cannot start. Try again in a few minutes, or tell your operator.",
"api.deploy.not_installable": "This app cannot be installed right now.",
"api.deploy.no_space": "There is not enough free space to install: only %.0f GB is free, and the system keeps %.0f GB in reserve to protect the core services (controller, proxy). Free some space, or add more storage.",
"api.deploy.started": "The install has started – the card shows how it is going",
"api.start.drive_missing": "Storage %s cannot be reached right now — the app cannot start until the drive is back.",
"api.start.not_enough_memory": "There is not enough memory to start. Needs %d MB, %d MB is free (%d MB used of %d MB usable)",
"api.action.state_not_saved": "This cannot be done: the app’s settings cannot be saved.",
"api.update.started": "The update has started – the card shows how it is going",
"api.settings.updated": "Settings updated",
"api.integration.enabled": "Integration turned on",
"api.integration.disabled": "Integration turned off",
"api.backup.already_running": "A backup is already running",
"api.backup.started": "The backup has started",
"api.backup.tier2_started": "The second backup has started",
"api.update.kicked_off": "The update has started",
"api.config.unchanged": "The configuration is unchanged — no restart is needed.",
"api.config.applied": "The configuration is applied — the controller is restarting.",
"api.geo.set": "Country limit set",
"api.geo.no_cloudflare": "The Cloudflare API is not set up",
"api.geo.sync_started": "The sync has started",
"api.geo.app_set": "App country limit set",
"api.geo.app_removed": "App country limit removed",
"alert.link.settings": "Settings",
"alert.link.monitoring": "System monitor",
"alert.link.update": "Update",
"alert.deadapp.group": "%d installed apps are not running — look at the system monitor",
"alert.deadapp.one": "An installed app is not running: %s",
"alert.deadapp.one_state": "An installed app is not running: %s (%s)",
"alert.storage.disconnected": "A drive is disconnected: %s (%s)",
"alert.hub.disabled": "The hub connection is off — central monitoring is not running",
"alert.hub.unreachable": "The hub cannot be reached — last error: %s",
"alert.backup.disabled": "Backup is not turned on",
"alert.update.available": "A new controller version is available: %s",
"alert.overflow": "+ %d more warnings",
"disk.err.bad_request": "that request is not valid",
"disk.err.bad_mountpoint": "that mount point is not valid",
"disk.err.target_required": "a target storage is required for the move",
"disk.err.name_mismatch": "the name you typed does not match the mount name",
"disk.err.unknown_mode": "unknown mode (migrate or anyway)",
"disk.err.device_required": "a device is required",
"disk.err.init_in_progress": "a drive is already being initialised",
"disk.err.protected": "this drive is protected (system / backup) — erasing it needs an operator signature",
"disk.err.wipe_failed": "the erase failed: %s",
"disk.err.attach_unavailable": "This drive cannot be attached right now — it may already be registered, or it may have been unplugged. Reload the page and look at Storage → Drives."
}
+427 -1
View File
@@ -1586,5 +1586,431 @@
"storage.figyelem": "FIGYELEM:",
"storage_init.jelenlegi": "jelenlegi:",
"debug.mp": "mp",
"debug.p": "p"
"debug.p": "p",
"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.enabled": "A megosztás bekapcsolva.",
"flash.share.not_on": "A megosztás nincs bekapcsolva.",
"flash.share.relink_failed": "Az új link készítése nem sikerült.",
"flash.share.relinked": "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.",
"flash.share.disable_failed": "A megosztás kikapcsolása nem sikerült.",
"flash.share.disabled": "A megosztás kikapcsolva.",
"flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.",
"flash.share.pw_cleared": "A megosztási jelszó törölve.",
"flash.share.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie.",
"flash.share.pw_set_failed": "A jelszó beállítása nem sikerült.",
"flash.share.pw_set": "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.",
"flash.restore.started": "Visszaállítás elindult — az állapot itt frissül.",
"flash.restore.file_started": "Fájl-visszaállítás elindult — az állapot itt frissül.",
"flash.restore.full_started": "Teljes visszaállítás elindult — az állapot itt frissül.",
"flash.login.password_changed": "Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.",
"flash.backup.window_invalid_time": "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).",
"flash.backup.window_save_failed": "A mentési időablak mentése nem sikerült.",
"flash.backup.window_updated": "Mentési időablak frissítve.",
"flash.sharing.save_failed": "A mentés nem sikerült.",
"flash.sharing.disabled": "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.",
"flash.sharing.prepare_running": "A megosztási szolgáltatás előkészítése már folyamatban van.",
"flash.sharing.saved_preparing": "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…",
"flash.sharing.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie.",
"flash.sharing.pw_mismatch": "A két jelszó nem egyezik.",
"flash.sharing.pw_set_failed": "A jelszó beállítása nem sikerült.",
"flash.sharing.pw_set_preparing_already": "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.",
"flash.sharing.pw_set_preparing": "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…",
"flash.sharing.folder_failed": "A mappa létrehozása nem sikerült.",
"flash.sharing.created": "A megosztás létrehozva.",
"flash.sharing.protected": "Ez a megosztás a rendszer része, nem törölhető.",
"flash.sharing.deleted": "A megosztás törölve — a mappa és a fájlok megmaradtak.",
"flash.sharing.saved": "Beállítás mentve.",
"flash.tier2.target_invalid": "A választott cél meghajtó nem érvényes.",
"flash.tier2.save_failed": "A beállítás mentése nem sikerült.",
"flash.tier2.saved": "A 2. mentés beállítása elmentve.",
"flash.tier2.app_email_off": "Email-küldés kikapcsolva ennél az alkalmazásnál.",
"flash.tier2.app_email_on": "Email-küldés bekapcsolva ennél az alkalmazásnál.",
"flash.offbox.mgr_unavailable": "A mentéskezelő nem elérhető.",
"flash.offbox.fields_required": "A cél címe, a felhasználó és a tárhely útvonala kötelező.",
"flash.offbox.path_absolute": "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).",
"flash.offbox.config_invalid": "Érvénytelen beállítás: %s",
"flash.offbox.first_setup_needs_key": "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.",
"flash.offbox.creds_save_failed": "A hitelesítő adatok mentése sikertelen.",
"flash.offbox.save_failed": "A beállítás mentése sikertelen.",
"flash.offbox.target_saved": "A távoli mentési cél elmentve.",
"flash.offbox.target_saved_escrow_agent_down": "A távoli mentési cél elmentve. — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra.",
"flash.offbox.target_saved_escrow_failed": "A távoli mentési cél elmentve. — a kulcs letéti előkészítése nem sikerült; próbáld újra.",
"flash.offbox.target_not_set": "A távoli mentési cél nincs beállítva.",
"flash.offbox.escrow_confirmed": "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.",
"flash.offbox.repo_pw_required": "A repo jelszó kötelező.",
"flash.offbox.repo_pw_failed": "A jelszó beállítása sikertelen: %s",
"flash.offbox.repo_pw_set": "A helyreállított repo jelszó beállítva.",
"flash.offbox.app_missing": "Hiányzó alkalmazás.",
"flash.offbox.app_setting_updated": "A távoli mentés beállítása frissítve.",
"flash.offbox.waiting_for_escrow": "A távoli mentés a kulcs letétbe helyezésére vár.",
"flash.offbox.repo_orphaned": "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.",
"flash.offbox.run_started": "A távoli mentés elindult — az állapot itt frissül.",
"flash.offbox.not_orphaned": "Az offsite tároló nincs elárvult állapotban.",
"flash.offbox.needs_confirmation": "A visszaállításhoz megerősítés szükséges.",
"flash.offbox.restart_started": "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).",
"flash.offbox.restore_started": "A távoli visszaállítás elindult — az állapot itt frissül.",
"flash.offbox.full_needs_confirmation": "A teljes visszaállítás megerősítés nélkül nem hajtható végre.",
"flash.offbox.full_restore_started": "A teljes visszaállítás elindult — az állapot itt frissül.",
"flash.offbox.mgr_unreachable": "A mentéskezelő nem érhető el.",
"flash.offbox.scratch_missing": "Hiányzó ellenőrző másolat.",
"flash.offbox.delete_needs_confirmation": "A törlés megerősítés nélkül nem hajtható végre.",
"flash.offbox.delete_failed": "A másolat törlése nem sikerült: %s",
"flash.offbox.scratch_deleted": "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.",
"flash.offbox.recover_started": "A helyreállítás elindult — az állapot itt frissül.",
"flash.offbox.shares_restore_started": "A megosztások visszaállítása elindult — az állapot itt frissül.",
"flash.offbox.shares_recover_started": "A megosztások helyreállítása elindult — az állapot itt frissül.",
"flash.offbox.run_already_going": "Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.",
"page.title.logs": "%s — Naplók",
"page.title.deploy": "%s — Telepítés",
"page.title.app_settings": "%s — Beállítások",
"page.title.tier2_config": "2. mentés beállítása — %s",
"logs.fetch_failed": "Hiba a naplók lekérésekor: %v",
"storage.bar_purpose": "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak.",
"deploy.path_not_allowed": "hálózati tárhely — alkalmazáshoz nem választható",
"creds.filebrowser_note": "A Fájlkezelő belépése. A jelszót a Felhom állította be ezen a gépen.",
"ping.label.heartbeat": "Eletjel (Heartbeat)",
"ping.label.system_health": "Rendszer allapot",
"ping.label.db_dump": "Adatbazis mentes",
"ping.label.backup": "Biztonsagi mentes",
"ping.label.integrity": "Mentes integritas",
"ping.schedule.5min": "5 percenkent",
"ping.schedule.daily_at": "Naponta %s",
"offbox.selection_changed": "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza.",
"backup.contents.db": "Konfig",
"backup.contents.data": "Adatok",
"backup.status.ok": "Alkalmazás-adat mentés rendben",
"backup.status.db_failed": "Adatbázis mentés sikertelen",
"backup.tier2.no_drive": "Nincs 2. meghajtó",
"backup.tier2.badge_ok": "Sikeres",
"backup.tier2.badge_error": "Hiba",
"backup.tier2.badge_running": "Fut...",
"backup.tier2.schedule_daily": "Naponta",
"backup.removed_app": "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
"backup.tier2.dest_ssd": "belső SSD (csak DB/konfiguráció)",
"flash.backup.not_configured": "Mentés nincs beállítva",
"escrow.no_retrieval_password": "Ezen a gépen nincs tárolt visszaállítási jelszó.",
"escrow.stack_mgr_unavailable": "Az alkalmazáskezelő nem elérhető.",
"escrow.unknown_app": "Ismeretlen alkalmazás.",
"escrow.missing_field": "Hiányzó mező.",
"escrow.field_not_revealable": "Ez a mező nem kérhető le.",
"escrow.app_settings_unreadable": "Az alkalmazás beállításai nem olvashatók.",
"escrow.no_stored_value": "Ehhez a mezőhöz nincs mentett érték.",
"escrow.initial_pw_read_failed": "A kezdeti jelszó beolvasása nem sikerült.",
"escrow.initial_pw_unavailable": "A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.",
"escrow.settings_unavailable": "A beállítások nem elérhetők.",
"escrow.filebrowser_not_ours": "A Fájlkezelő jelszavát nem a Felhom állította be ezen a gépen, ezért nem tudjuk megmutatni.",
"escrow.password_unreadable": "A jelszó most nem olvasható ki.",
"settings.pw_wrong_current": "Hibás jelenlegi jelszó",
"settings.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie",
"settings.pw_mismatch": "A két jelszó nem egyezik",
"settings.pw_save_error": "Belső hiba a jelszó mentésekor",
"settings.notify_email_required": "Adj meg egy értesítési e-mail címet – bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket.",
"settings.notify_save_error": "Hiba a beállítások mentésekor",
"settings.notify_saved_sync_failed": "Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v",
"settings.notify_saved": "Értesítési beállítások mentve.",
"settings.app_email_save_error": "Hiba az alkalmazás-email beállítás mentésekor",
"settings.app_email_shim_failed": "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült.",
"settings.app_email_on": "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél.",
"settings.app_email_off": "Alkalmazás-email kikapcsolva.",
"settings.notify_disabled": "Az értesítések nincsenek bekapcsolva",
"settings.test_email_failed": "Teszt email küldése sikertelen: %v",
"settings.test_email_sent": "Teszt email elküldve.",
"storage.err_path_missing": "Az útvonal nem létezik vagy nem mappa.",
"storage.err_not_separate": "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!",
"storage.err_not_writable": "Az útvonal nem írható.",
"storage.err_overlaps": "Az útvonal átfedi a már regisztrált %s útvonalat.",
"storage.err_save": "Hiba a mentés során.",
"storage.err_in_use": "Nem törölhető: az alábbi alkalmazások használják: %s",
"storage.err_default": "Az alapértelmezett adattároló nem törölhető.",
"storage.err_last": "Az utolsó adattároló nem törölhető.",
"storage.err_delete": "Hiba a törlés során.",
"storage.err_label": "A megnevezés nem lehet üres és legfeljebb 50 karakter.",
"storage.err_label_save": "Hiba a megnevezés mentésekor.",
"storage.msg_added": "Adattároló sikeresen hozzáadva: %s",
"storage.msg_removed": "Adattároló eltávolítva: %s",
"storage.msg_default_set": "Alapértelmezett adattároló beállítva: %s",
"storage.msg_state_changed": "Adattároló állapot módosítva: %s",
"storage.msg_label_changed": "Megnevezés módosítva: %s",
"country.AF": "Afganisztán",
"country.AL": "Albánia",
"country.DZ": "Algéria",
"country.AS": "Amerikai Szamoa",
"country.AD": "Andorra",
"country.AO": "Angola",
"country.AI": "Anguilla",
"country.AQ": "Antarktisz",
"country.AG": "Antigua és Barbuda",
"country.AR": "Argentína",
"country.AM": "Örményország",
"country.AW": "Aruba",
"country.AU": "Ausztrália",
"country.AT": "Ausztria",
"country.AZ": "Azerbajdzsán",
"country.BS": "Bahama-szigetek",
"country.BH": "Bahrein",
"country.BD": "Banglades",
"country.BB": "Barbados",
"country.BY": "Fehéroroszország",
"country.BE": "Belgium",
"country.BZ": "Belize",
"country.BJ": "Benin",
"country.BM": "Bermuda",
"country.BT": "Bhután",
"country.BO": "Bolívia",
"country.BA": "Bosznia-Hercegovina",
"country.BW": "Botswana",
"country.BR": "Brazília",
"country.BN": "Brunei",
"country.BG": "Bulgária",
"country.BF": "Burkina Faso",
"country.BI": "Burundi",
"country.CV": "Cabo Verde",
"country.KH": "Kambodzsa",
"country.CM": "Kamerun",
"country.CA": "Kanada",
"country.KY": "Kajmán-szigetek",
"country.CF": "Közép-afrikai Köztársaság",
"country.TD": "Csád",
"country.CL": "Chile",
"country.CN": "Kína",
"country.CO": "Kolumbia",
"country.KM": "Comore-szigetek",
"country.CG": "Kongó",
"country.CD": "Kongói Demokratikus Köztársaság",
"country.CK": "Cook-szigetek",
"country.CR": "Costa Rica",
"country.CI": "Elefántcsontpart",
"country.HR": "Horvátország",
"country.CU": "Kuba",
"country.CW": "Curaçao",
"country.CY": "Ciprus",
"country.CZ": "Csehország",
"country.DK": "Dánia",
"country.DJ": "Dzsibuti",
"country.DM": "Dominika",
"country.DO": "Dominikai Köztársaság",
"country.EC": "Ecuador",
"country.EG": "Egyiptom",
"country.SV": "Salvador",
"country.GQ": "Egyenlítői-Guinea",
"country.ER": "Eritrea",
"country.EE": "Észtország",
"country.SZ": "Eswatini",
"country.ET": "Etiópia",
"country.FK": "Falkland-szigetek",
"country.FO": "Feröer-szigetek",
"country.FJ": "Fidzsi-szigetek",
"country.FI": "Finnország",
"country.FR": "Franciaország",
"country.GF": "Francia Guyana",
"country.PF": "Francia Polinézia",
"country.GA": "Gabon",
"country.GM": "Gambia",
"country.GE": "Grúzia",
"country.DE": "Németország",
"country.GH": "Ghána",
"country.GI": "Gibraltár",
"country.GR": "Görögország",
"country.GL": "Grönland",
"country.GD": "Grenada",
"country.GP": "Guadeloupe",
"country.GU": "Guam",
"country.GT": "Guatemala",
"country.GG": "Guernsey",
"country.GN": "Guinea",
"country.GW": "Bissau-Guinea",
"country.GY": "Guyana",
"country.HT": "Haiti",
"country.HN": "Honduras",
"country.HK": "Hongkong",
"country.HU": "Magyarország",
"country.IS": "Izland",
"country.IN": "India",
"country.ID": "Indonézia",
"country.IR": "Irán",
"country.IQ": "Irak",
"country.IE": "Írország",
"country.IM": "Man-sziget",
"country.IL": "Izrael",
"country.IT": "Olaszország",
"country.JM": "Jamaica",
"country.JP": "Japán",
"country.JE": "Jersey",
"country.JO": "Jordánia",
"country.KZ": "Kazahsztán",
"country.KE": "Kenya",
"country.KI": "Kiribati",
"country.KP": "Észak-Korea",
"country.KR": "Dél-Korea",
"country.KW": "Kuvait",
"country.KG": "Kirgizisztán",
"country.LA": "Laosz",
"country.LV": "Lettország",
"country.LB": "Libanon",
"country.LS": "Lesotho",
"country.LR": "Libéria",
"country.LY": "Líbia",
"country.LI": "Liechtenstein",
"country.LT": "Litvánia",
"country.LU": "Luxemburg",
"country.MO": "Makaó",
"country.MG": "Madagaszkár",
"country.MW": "Malawi",
"country.MY": "Malajzia",
"country.MV": "Maldív-szigetek",
"country.ML": "Mali",
"country.MT": "Málta",
"country.MH": "Marshall-szigetek",
"country.MQ": "Martinique",
"country.MR": "Mauritánia",
"country.MU": "Mauritius",
"country.YT": "Mayotte",
"country.MX": "Mexikó",
"country.FM": "Mikronézia",
"country.MD": "Moldova",
"country.MC": "Monaco",
"country.MN": "Mongólia",
"country.ME": "Montenegró",
"country.MS": "Montserrat",
"country.MA": "Marokkó",
"country.MZ": "Mozambik",
"country.MM": "Mianmar",
"country.NA": "Namíbia",
"country.NR": "Nauru",
"country.NP": "Nepál",
"country.NL": "Hollandia",
"country.NC": "Új-Kaledónia",
"country.NZ": "Új-Zéland",
"country.NI": "Nicaragua",
"country.NE": "Niger",
"country.NG": "Nigéria",
"country.NU": "Niue",
"country.NF": "Norfolk-sziget",
"country.MK": "Észak-Macedónia",
"country.MP": "Északi-Mariana-szigetek",
"country.NO": "Norvégia",
"country.OM": "Omán",
"country.PK": "Pakisztán",
"country.PW": "Palau",
"country.PS": "Palesztina",
"country.PA": "Panama",
"country.PG": "Pápua Új-Guinea",
"country.PY": "Paraguay",
"country.PE": "Peru",
"country.PH": "Fülöp-szigetek",
"country.PL": "Lengyelország",
"country.PT": "Portugália",
"country.PR": "Puerto Rico",
"country.QA": "Katar",
"country.RE": "Réunion",
"country.RO": "Románia",
"country.RU": "Oroszország",
"country.RW": "Ruanda",
"country.BL": "Saint-Barthélemy",
"country.SH": "Szent Ilona",
"country.KN": "Saint Kitts és Nevis",
"country.LC": "Saint Lucia",
"country.MF": "Saint-Martin",
"country.PM": "Saint-Pierre és Miquelon",
"country.VC": "Saint Vincent és a Grenadine-szigetek",
"country.WS": "Szamoa",
"country.SM": "San Marino",
"country.ST": "São Tomé és Príncipe",
"country.SA": "Szaúd-Arábia",
"country.SN": "Szenegál",
"country.RS": "Szerbia",
"country.SC": "Seychelle-szigetek",
"country.SL": "Sierra Leone",
"country.SG": "Szingapúr",
"country.SX": "Sint Maarten",
"country.SK": "Szlovákia",
"country.SI": "Szlovénia",
"country.SB": "Salamon-szigetek",
"country.SO": "Szomália",
"country.ZA": "Dél-afrikai Köztársaság",
"country.SS": "Dél-Szudán",
"country.ES": "Spanyolország",
"country.LK": "Srí Lanka",
"country.SD": "Szudán",
"country.SR": "Suriname",
"country.SE": "Svédország",
"country.CH": "Svájc",
"country.SY": "Szíria",
"country.TW": "Tajvan",
"country.TJ": "Tádzsikisztán",
"country.TZ": "Tanzánia",
"country.TH": "Thaiföld",
"country.TL": "Kelet-Timor",
"country.TG": "Togo",
"country.TK": "Tokelau",
"country.TO": "Tonga",
"country.TT": "Trinidad és Tobago",
"country.TN": "Tunézia",
"country.TR": "Törökország",
"country.TM": "Türkmenisztán",
"country.TC": "Turks- és Caicos-szigetek",
"country.TV": "Tuvalu",
"country.UG": "Uganda",
"country.UA": "Ukrajna",
"country.AE": "Egyesült Arab Emírségek",
"country.GB": "Egyesült Királyság",
"country.US": "Egyesült Államok",
"country.UY": "Uruguay",
"country.UZ": "Üzbegisztán",
"country.VU": "Vanuatu",
"country.VA": "Vatikán",
"country.VE": "Venezuela",
"country.VN": "Vietnám",
"country.VG": "Brit Virgin-szigetek",
"country.VI": "Amerikai Virgin-szigetek",
"country.WF": "Wallis és Futuna",
"country.EH": "Nyugat-Szahara",
"country.YE": "Jemen",
"country.ZM": "Zambia",
"country.ZW": "Zimbabwe",
"api.deploy.netstorage_unreachable": "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek.",
"api.deploy.not_installable": "Ez az alkalmazás jelenleg nem telepíthető.",
"api.deploy.no_space": "Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
"api.deploy.started": "Telepítés elindítva – az állapot a kártyán követhető",
"api.start.drive_missing": "A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
"api.start.not_enough_memory": "Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)",
"api.action.state_not_saved": "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
"api.update.started": "Frissítés elindult – az állapot a kártyán követhető",
"api.settings.updated": "Beállítások frissítve",
"api.integration.enabled": "Integráció engedélyezve",
"api.integration.disabled": "Integráció kikapcsolva",
"api.backup.already_running": "Mentés már folyamatban",
"api.backup.started": "Mentés elindítva",
"api.backup.tier2_started": "2. mentés elindítva",
"api.update.kicked_off": "Frissítés elindítva",
"api.config.unchanged": "A konfiguráció változatlan — nincs szükség újraindításra.",
"api.config.applied": "Konfiguráció alkalmazva — a vezérlő újraindul.",
"api.geo.set": "Geo-korlátozás beállítva",
"api.geo.no_cloudflare": "Cloudflare API nincs konfigurálva",
"api.geo.sync_started": "Szinkronizálás elindítva",
"api.geo.app_set": "Alkalmazás geo-korlátozás beállítva",
"api.geo.app_removed": "Alkalmazás geo-korlátozás eltávolítva",
"alert.link.settings": "Beállítások",
"alert.link.monitoring": "Rendszermonitor",
"alert.link.update": "Frissítés",
"alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort",
"alert.deadapp.one": "Telepített alkalmazás nem fut: %s",
"alert.deadapp.one_state": "Telepített alkalmazás nem fut: %s (%s)",
"alert.storage.disconnected": "Meghajtó leválasztva: %s (%s)",
"alert.hub.disabled": "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
"alert.hub.unreachable": "Hub nem elérhető — utolsó hiba: %s",
"alert.backup.disabled": "A biztonsági mentés nincs bekapcsolva",
"alert.update.available": "Új controller verzió elérhető: %s",
"alert.overflow": "+ %d további figyelmeztetés",
"disk.err.bad_request": "érvénytelen kérés",
"disk.err.bad_mountpoint": "érvénytelen csatlakoztatási pont",
"disk.err.target_required": "céltároló kötelező az áthelyezéshez",
"disk.err.name_mismatch": "a beírt név nem egyezik a csatlakoztatási névvel",
"disk.err.unknown_mode": "ismeretlen mód (migrate vagy anyway)",
"disk.err.device_required": "eszköz kötelező",
"disk.err.init_in_progress": "már folyamatban van egy meghajtó-inicializálás",
"disk.err.protected": "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással",
"disk.err.wipe_failed": "törlés sikertelen: %s",
"disk.err.attach_unavailable": "Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát."
}
+18 -5
View File
@@ -38,6 +38,19 @@ const (
WarnKindStorageUsageHigh = "storage-usage-high" // a data drive is filling up
)
// Storage warning formats. Named (v0.252.0, R-557) so the one property localisation slice 2 must not
// break is testable: these sentences are ON THE WIRE (report `health.warnings`), so they stay
// Hungarian in every language and their bytes may not move. The dashboard renders its OWN sentence
// for the household's language from the kind above plus the parameters below — never from these.
// Pinned by TestStorageWarningFormatsAreFrozen.
const (
warnFmtStorageDisconnected = "Meghajtó leválasztva: %s (%s)"
warnFmtStorageUnavailable = "Adattároló nem elérhető: %s"
warnFmtStorageNotSeparate = "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak"
warnFmtStorageUsageHigh = "Adattároló használat magas: %s (%.0f%%)"
issueFmtStorageAlmostFull = "Adattároló majdnem megtelt: %s (%.0f%%)"
)
// addWarning appends a warning together with its kind, so the two slices cannot drift apart. Every
// warning goes through here; `WarningKindAt` reads them back.
func (r *HealthReport) addWarning(text, kind string) {
@@ -354,29 +367,29 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds []
// Skip disconnected paths — handled by the storage watchdog
if sp.Disconnected {
warnings, kinds = append(warnings, fmt.Sprintf("Meghajtó leválasztva: %s (%s)", sp.Label, sp.Path)), append(kinds, WarnKindStorageDisconnected)
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path)), append(kinds, WarnKindStorageDisconnected)
continue
}
// Path accessible?
if _, err := os.Stat(sp.Path); err != nil {
warnings, kinds = append(warnings, fmt.Sprintf("Adattároló nem elérhető: %s", sp.Path)), append(kinds, WarnKindStorageUnavailable)
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUnavailable, sp.Path)), append(kinds, WarnKindStorageUnavailable)
continue
}
// Mount point check — warning, not issue (avoids false FAIL on demo/test environments)
if !system.IsMountPoint(sp.Path) {
warnings = append(warnings, fmt.Sprintf(
"Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", sp.Path))
warnFmtStorageNotSeparate, sp.Path))
kinds = append(kinds, WarnKindStorageNotSeparate)
}
// Disk usage
if di := system.GetDiskUsage(sp.Path); di != nil {
if di.UsedPercent >= 95 {
issues = append(issues, fmt.Sprintf("Adattároló majdnem megtelt: %s (%.0f%%)", sp.Path, di.UsedPercent))
issues = append(issues, fmt.Sprintf(issueFmtStorageAlmostFull, sp.Path, di.UsedPercent))
} else if di.UsedPercent >= 90 {
warnings, kinds = append(warnings, fmt.Sprintf("Adattároló használat magas: %s (%.0f%%)", sp.Path, di.UsedPercent)), append(kinds, WarnKindStorageUsageHigh)
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent)), append(kinds, WarnKindStorageUsageHigh)
}
}
}
@@ -0,0 +1,100 @@
package monitor
import (
"fmt"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// TestStorageWarningWireTextIsFrozen — localisation slice 2 (R-557), scenario S6.
//
// `HealthReport.Warnings` is ON THE WIRE: report/builder.go copies it into `health.warnings`, the hub
// stores it and the operator reads it there. The rule for slice 2 is that nothing on the wire moves —
// so these five sentences may NOT become bundle keys the way a page's copy does, however English the
// household's dashboard is.
//
// The golden below was captured from this file at 736f54b49610, the base commit of v0.252.0, BEFORE
// any conversion. It is the measurement, not a restatement of the code: a rewording, a re-punctuation
// or a translation of any producer in checkStoragePaths fails here and prints both strings.
//
// What the DASHBOARD shows for these warnings follows the language by a different route — the kinds
// beside them (WarnKind*, internal, never on the wire) plus the parameters, so the page can render its
// own sentence without the wire text changing. See internal/web/alerts.go.
func TestStorageWarningWireTextIsFrozen(t *testing.T) {
// A path that cannot exist, so os.Stat fails deterministically on any machine.
const absent = "/felhom-test-does-not-exist/adat"
cases := []struct {
name string
in settings.StoragePath
want []string
kind []string
}{
{
name: "disconnected",
in: settings.StoragePath{Path: "/mnt/hdd_1", Label: "Kulso HDD", Disconnected: true},
want: []string{"Meghajtó leválasztva: Kulso HDD (/mnt/hdd_1)"},
kind: []string{WarnKindStorageDisconnected},
},
{
name: "unavailable",
in: settings.StoragePath{Path: absent, Label: "Adat"},
want: []string{"Adattároló nem elérhető: " + absent},
kind: []string{WarnKindStorageUnavailable},
},
{
name: "decommissioned is silent",
in: settings.StoragePath{Path: absent, Decommissioned: true},
want: nil,
kind: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, warnings, kinds := checkStoragePaths([]settings.StoragePath{tc.in})
if len(warnings) != len(tc.want) {
t.Fatalf("got %d warnings %q, want %d %q", len(warnings), warnings, len(tc.want), tc.want)
}
for i := range tc.want {
if warnings[i] != tc.want[i] {
t.Errorf("wire text moved:\n got %q\n want %q", warnings[i], tc.want[i])
}
if kinds[i] != tc.kind[i] {
t.Errorf("kind moved: got %q, want %q", kinds[i], tc.kind[i])
}
}
})
}
}
// TestStorageWarningFormatsAreFrozen pins the three producers the table above cannot reach on every
// machine — they need a real mount point and a real 91 %-full disk, and faking either would test the
// fake. It compares the CONSTANTS the producers use, so a translation fails here whatever the machine.
//
// The rendered form is compared too, not only the format string: a change from `%s` to `%v`, or a
// verb reordered with an explicit index, leaves the format literal looking similar and the sentence
// different.
func TestStorageWarningFormatsAreFrozen(t *testing.T) {
cases := []struct {
name, got, want string
}{
{"disconnected", fmt.Sprintf(warnFmtStorageDisconnected, "Kulso HDD", "/mnt/hdd_1"),
"Meghajtó leválasztva: Kulso HDD (/mnt/hdd_1)"},
{"unavailable", fmt.Sprintf(warnFmtStorageUnavailable, "/mnt/adat"),
"Adattároló nem elérhető: /mnt/adat"},
{"not separate", fmt.Sprintf(warnFmtStorageNotSeparate, "/mnt/adat"),
"Az adattároló (/mnt/adat) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak"},
{"usage high", fmt.Sprintf(warnFmtStorageUsageHigh, "/mnt/adat", 91.4),
"Adattároló használat magas: /mnt/adat (91%)"},
{"almost full", fmt.Sprintf(issueFmtStorageAlmostFull, "/mnt/adat", 96.0),
"Adattároló majdnem megtelt: /mnt/adat (96%)"},
}
for _, tc := range cases {
if tc.got != tc.want {
t.Errorf("%s: wire text moved (slice 2 may not translate a sentence the hub reads)\n"+
" got %q\n want %q", tc.name, tc.got, tc.want)
}
}
}
+10
View File
@@ -174,6 +174,16 @@ type eventRequest struct {
// Non-blocking (goroutine). Retries twice with 3s backoff.
// details may be nil (omitted from JSON) or a struct that marshals to JSON.
func (n *Notifier) PushEvent(eventType, severity, message 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.
// TestEventMessageWireTextIsFrozen pins the exact bytes the hub mails to a household, and it can
// 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)
return
}
if !n.enabled {
return
}
@@ -0,0 +1,122 @@
package notify
import (
"strings"
"testing"
)
// TestEventMessageWireTextIsFrozen — localisation slice 2 (R-557), scenario S6.
//
// An event's `message` is ON THE WIRE and, unlike most wire text, a CUSTOMER READS IT. The hub's
// FormatCustomerEmail (felhom.eu/hub/internal/notify/templates.go) uses the per-type Hungarian
// `customerMessages` entry when it has one and FALLS BACK TO THIS MESSAGE when it has none — and
// appends it as „- Üzenet: %s" whenever it differs from the entry. Several event types deliberately
// have no entry precisely so the controller's dynamic sentence is what the household is sent
// (hub/internal/api/handler.go, the notes around offbox_enlarge_blocked and its neighbours).
//
// So these sentences are NOT the dashboard's to translate. They follow the household's language in
// slice 3 (R-558), where the hub learns which language the box reports and composes the mail. Until
// then they stay Hungarian in every language, and this test is what makes "stay" measurable: the
// golden strings below were captured from the producers at 736f54b49610, the base commit of
// v0.252.0, before any conversion.
//
// Not every producer is here — the table covers one representative of each SHAPE (fixed sentence,
// one parameter, two parameters, a numeric parameter, a branch per case), which is what a conversion
// would break. A producer converted by accident would have to escape every shape to escape this.
func TestEventMessageWireTextIsFrozen(t *testing.T) {
cases := []struct {
name string
call func(n *Notifier)
want string
}{
{
name: "fixed sentence",
call: func(n *Notifier) { n.NotifyDBDumpCompleted(DBDumpDetails{}) },
want: "Adatbázis mentés elkészült",
},
{
name: "one parameter",
call: func(n *Notifier) { n.NotifyAppDeployed("privatebin", "PrivateBin") },
want: "Alkalmazás telepítve: PrivateBin",
},
{
name: "two parameters",
call: func(n *Notifier) { n.NotifyBackupTargetAbsent("Kulso HDD", "/mnt/hdd_1") },
want: "A rendszermentés meghajtója nem érhető el: Kulso HDD (/mnt/hdd_1)",
},
{
name: "numeric parameter",
call: func(n *Notifier) { n.NotifyDRStarted(3) },
want: "Katasztrófa helyreállítás elindítva (3 alkalmazás)",
},
{
name: "two numeric parameters",
call: func(n *Notifier) { n.NotifyDRCompleted(7, 2) },
want: "Katasztrófa helyreállítás befejezve (7 sikeres, 2 sikertelen)",
},
{
name: "branch: update succeeded",
call: func(n *Notifier) { n.NotifyControllerUpdated("0.251.0", "0.252.0", true) },
want: "Controller frissítve: 0.251.0 → 0.252.0",
},
{
name: "branch: update failed",
call: func(n *Notifier) { n.NotifyControllerUpdated("0.251.0", "0.252.0", false) },
want: "Controller frissítés sikertelen: 0.251.0 → 0.252.0",
},
{
name: "disk health, sector count",
call: func(n *Notifier) {
n.NotifyDiskHealthDegraded(DiskAlert{Label: "Kulso HDD", Kind: DiskAlertFailSectors, Sectors: 352})
},
want: "Lemez hiba: Kulso HDD — a meghajtón 352 olvashatatlan szektor van. " +
"Mentse az adatait, és keressen meg minket a meghajtó cseréjéhez.",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got string
var seen int
n := &Notifier{}
n.pushFn = func(_, _, message string, _ interface{}) {
got, seen = message, seen+1
}
tc.call(n)
// A producer that stopped pushing would otherwise leave `got` empty and compare unequal
// with a confusing message; say which it was.
if seen == 0 {
t.Fatalf("the producer pushed no event at all — the golden below was never compared")
}
if got != tc.want {
t.Errorf("event message moved (slice 2 may not translate what the hub mails)\n"+
" got %q\n want %q", got, tc.want)
}
})
}
}
// TestEventMessagesCarryNoBundleKey is the other half: a converted producer would push a KEY, or a
// sentence assembled from one. Neither can look Hungarian, so this catches the accident the golden
// table above would miss for a producer it does not list.
func TestEventMessagesCarryNoBundleKey(t *testing.T) {
var msgs []string
n := &Notifier{}
n.pushFn = func(_, _, message string, _ interface{}) { msgs = append(msgs, message) }
n.NotifyAppDeployed("privatebin", "PrivateBin")
n.NotifyAppRemoved("privatebin", "PrivateBin")
n.NotifyStorageReconnected("Kulso HDD")
n.NotifyDRStarted(1)
n.NotifyCrossDriveCompleted(CrossDriveDetails{StackName: "privatebin"})
if len(msgs) == 0 {
t.Fatal("no producer pushed — nothing was checked")
}
for _, m := range msgs {
// A bundle key is lower-case, dotted and has no space: "event.app_deployed".
if !strings.Contains(m, " ") && strings.Contains(m, ".") && m == strings.ToLower(m) {
t.Errorf("an event message looks like a bundle key, not a sentence: %q", m)
}
}
}
+82 -29
View File
@@ -9,21 +9,61 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Alert represents a persistent dashboard alert banner.
//
// LOCALISATION (v0.252.0, R-557). An alert is BUILT by a background health cycle and READ minutes
// later by whichever page the household opens, so its text cannot be rendered where it is made —
// that is the same shape as a flash in a redirect URL, one layer in. An alert therefore carries a
// bundle KEY plus its parameters, and GetAlerts renders it in the language the reader asked for.
//
// Two deliberate exceptions, both because the text is NOT ours to translate:
//
// - the health report's Issues and Warnings, which arrive as finished sentences and go ON THE WIRE
// to the hub (report `health.warnings`, pinned by internal/monitor's wire golden). They keep
// `Message` and stay Hungarian in every language until slice 3 gives the hub a language;
// - the agent-channel and endpoint-drift lines, whose text is composed by the channel-health
// checker. Filed as R-573.
//
// An alert with no MessageKey renders `Message` verbatim, which is what makes both exceptions work
// without a second mechanism.
type Alert struct {
ID string // unique identifier for filtering
Level string // "error", "warning", "info"
Message string // Hungarian display text
Message string // display text; used verbatim when MessageKey is empty
MessageKey string // bundle key for Message (preferred); empty = Message is already text
MessageArgs []interface{} // the key's printf parameters, in order
Link string // optional link to relevant page
LinkText string // link display text
LinkText string // link display text; used verbatim when LinkTextKey is empty
LinkTextKey string // bundle key for LinkText
PageOnly []string // if non-empty, only show on these pages (e.g., ["dashboard", "monitoring"])
Inline bool // if true, rendered by page template inline, not in layout banner
}
// rendered returns a copy of the alert with its keys resolved in lang. A key the bundle does not
// carry falls back to whatever Message/LinkText already held, so an alert can never render blank.
func (a Alert) rendered(lang string) Alert {
b, err := i18n.Shared()
if err != nil {
return a
}
if a.MessageKey != "" {
if len(a.MessageArgs) > 0 {
a.Message = b.Msgf(lang, a.MessageKey, a.MessageArgs...)
} else {
a.Message = b.Msg(lang, a.MessageKey)
}
}
if a.LinkTextKey != "" {
a.LinkText = b.Msg(lang, a.LinkTextKey)
}
return a
}
// AlertManager generates and stores dashboard alerts from health check results.
// Alerts are state-based (not event-based) — they reflect current system state
// and are regenerated after each health check cycle.
@@ -74,9 +114,9 @@ func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) {
am.agentChannelAlert = &Alert{
ID: "agent-channel-down",
Level: "error",
Message: msg,
Message: msg, // composed by the channel-health checker -- R-573
Link: "/settings",
LinkText: "Beállítások",
LinkTextKey: "alert.link.settings",
}
}
@@ -96,9 +136,9 @@ func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) {
am.endpointDriftAlert = &Alert{
ID: "local-api-endpoint-drift",
Level: "error",
Message: msg,
Message: msg, // composed by the endpoint-drift checker -- R-573
Link: "/settings",
LinkText: "Beállítások",
LinkTextKey: "alert.link.settings",
}
}
@@ -124,9 +164,10 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
return []Alert{{
ID: "deadapp-group",
Level: "warning",
Message: fmt.Sprintf("%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", len(dead)),
MessageKey: "alert.deadapp.group",
MessageArgs: []interface{}{len(dead)},
Link: "/monitoring",
LinkText: "Rendszermonitor",
LinkTextKey: "alert.link.monitoring",
}}
}
alerts := make([]Alert, 0, len(dead))
@@ -135,16 +176,17 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
if name == "" {
name = d.Name
}
msg := "Telepített alkalmazás nem fut: " + name
key, args := "alert.deadapp.one", []interface{}{name}
if d.State != "" {
msg += " (" + d.State + ")"
key, args = "alert.deadapp.one_state", []interface{}{name, d.State}
}
alerts = append(alerts, Alert{
ID: "deadapp-" + simpleHash(d.Name),
Level: "warning",
Message: msg,
MessageKey: key,
MessageArgs: args,
Link: "/monitoring",
LinkText: "Rendszermonitor",
LinkTextKey: "alert.link.monitoring",
})
}
return alerts
@@ -175,9 +217,10 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alerts = append(alerts, Alert{
ID: "storage-disconnected-" + simpleHash(sp.Path),
Level: "error",
Message: fmt.Sprintf("Meghajtó leválasztva: %s (%s)", label, sp.Path),
MessageKey: "alert.storage.disconnected",
MessageArgs: []interface{}{label, sp.Path},
Link: "/settings",
LinkText: "Beállítások",
LinkTextKey: "alert.link.settings",
})
}
}
@@ -188,9 +231,9 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alerts = append(alerts, Alert{
ID: "health-" + simpleHash(issue),
Level: "error",
Message: issue,
Message: issue, // ON THE WIRE (report health.issues) -- not ours to translate; slice 3
Link: "/monitoring",
LinkText: "Rendszermonitor",
LinkTextKey: "alert.link.monitoring",
})
}
@@ -199,9 +242,9 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alert := Alert{
ID: "health-" + simpleHash(w),
Level: "warning",
Message: w,
Message: w, // ON THE WIRE (report health.warnings) -- not ours to translate; slice 3
Link: "/monitoring",
LinkText: "Rendszermonitor",
LinkTextKey: "alert.link.monitoring",
}
// R-553 — WHERE this warning is shown is decided by its KIND, not by the Hungarian words it
// contains. The old test was `strings.Contains(w, "meghajtón"/"adattároló"/"meghajtó")`, which
@@ -222,9 +265,9 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alerts = append(alerts, Alert{
ID: "hub-disabled",
Level: "warning",
Message: "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
MessageKey: "alert.hub.disabled",
Link: "/monitoring",
LinkText: "Rendszermonitor",
LinkTextKey: "alert.link.monitoring",
})
} else if am.hubPushStatusFn != nil {
ps := am.hubPushStatusFn()
@@ -232,9 +275,10 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alerts = append(alerts, Alert{
ID: "hub-unreachable",
Level: "error",
Message: fmt.Sprintf("Hub nem elérhető — utolsó hiba: %s", ps.LastError),
MessageKey: "alert.hub.unreachable",
MessageArgs: []interface{}{ps.LastError},
Link: "/monitoring",
LinkText: "Rendszermonitor",
LinkTextKey: "alert.link.monitoring",
})
}
}
@@ -244,9 +288,9 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alerts = append(alerts, Alert{
ID: "backup-disabled",
Level: "warning",
Message: "A biztonsági mentés nincs bekapcsolva",
MessageKey: "alert.backup.disabled",
Link: "/settings",
LinkText: "Beállítások",
LinkTextKey: "alert.link.settings",
})
}
@@ -255,9 +299,10 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
alerts = append(alerts, Alert{
ID: "update-available",
Level: "info",
Message: fmt.Sprintf("Új controller verzió elérhető: %s", latestVersion),
MessageKey: "alert.update.available",
MessageArgs: []interface{}{latestVersion},
Link: "/settings",
LinkText: "Frissítés",
LinkTextKey: "alert.link.update",
})
}
@@ -270,7 +315,7 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
}
// GetAlerts returns a copy of the current alerts, optionally excluding specific IDs.
func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert {
func (am *AlertManager) GetAlerts(lang string, excludeIDs ...string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
@@ -315,16 +360,21 @@ func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert {
result = append(result, Alert{
ID: "overflow",
Level: "info",
Message: fmt.Sprintf("+ %d további figyelmeztetés", overflow),
MessageKey: "alert.overflow", MessageArgs: []interface{}{overflow},
Link: "/monitoring",
})
}
// Rendered LAST, once, on the way out: the alerts are stored as keys and only the reader knows
// the language. Every return path goes through here, so an alert cannot escape with a raw key.
for i := range result {
result[i] = result[i].rendered(lang)
}
return result
}
// GetInlineAlerts returns alerts marked as Inline for a specific page.
func (am *AlertManager) GetInlineAlerts(page string) []Alert {
func (am *AlertManager) GetInlineAlerts(page, lang string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
@@ -344,6 +394,9 @@ func (am *AlertManager) GetInlineAlerts(page string) []Alert {
}
}
}
for i := range result {
result[i] = result[i].rendered(lang)
}
return result
}
@@ -125,8 +125,8 @@ func TestBackupTier2Restore_AsyncReturnsInstantly(t *testing.T) {
if w.Code != http.StatusFound {
t.Fatalf("want 302, got %d", w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "elindult") {
t.Fatalf("redirect should carry the 'elindult' flash; got %q", loc)
if loc := w.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "elindult") {
t.Fatalf("redirect should carry the 'elindult' flash; got %q -> %q", loc, flashSentence(t, loc))
}
// the background restore is now parked in StopStack → op-status shows running.
waitFor(t, func() bool { return m.RestoreStatus().Running }, "restore op running")
+1 -1
View File
@@ -97,7 +97,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
if r.URL.Path == "/login" {
s.renderLogin(w, r, "", r.URL.Query().Get("flash"))
s.renderLogin(w, r, "", s.flashFrom(r, "flash"))
return
}
if r.URL.Path == "/logout" {
+5 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"time"
@@ -42,12 +41,12 @@ func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request)
_ = r.ParseForm()
start := strings.TrimSpace(r.FormValue("window_start"))
if backupwindow.Valid(start) != nil {
s.backupWindowRedirect(w, r, "", "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).")
s.backupWindowRedirect(w, r, "", "flash.backup.window_invalid_time")
return
}
if err := s.settings.SetBackupWindowStart(start); err != nil {
s.logger.Printf("[ERROR] [web] backup window save failed: %v", err)
s.backupWindowRedirect(w, r, "", "A mentési időablak mentése nem sikerült.")
s.backupWindowRedirect(w, r, "", "flash.backup.window_save_failed")
return
}
// Fan out to the three daily legs at their fixed offsets — takes effect at the next scheduling
@@ -63,16 +62,16 @@ func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request)
s.backupMgr.RefreshCache(scheduler.NextDailyRun(db))
}
s.logger.Printf("[INFO] [web] backup window set to %s (legs %s/%s/%s)", start, db, tier2, offbox)
s.backupWindowRedirect(w, r, "Mentési időablak frissítve.", "")
s.backupWindowRedirect(w, r, "flash.backup.window_updated", "")
}
// backupWindowRedirect PRG-redirects back to the Áttekintés page with a success or error flash.
func (s *Server) backupWindowRedirect(w http.ResponseWriter, r *http.Request, flash, flashErr string) {
dest := "/backups"
if flashErr != "" {
dest += "?flash_error=" + url.QueryEscape(flashErr)
dest += "?" + flashQuery("flash_error", flashErr)
} else if flash != "" {
dest += "?flash=" + url.QueryEscape(flash)
dest += "?" + flashQuery("flash", flash)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
@@ -104,7 +104,7 @@ func TestBuildAppBackupRows_OffboxMapping(t *testing.T) {
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
{StackName: "radarr", DisplayName: "Radarr"},
}}
rows := s.buildAppBackupRows(status)
rows := s.buildAppBackupRows(status, "hu")
cal := findRow(rows, "calibre-web")
if cal == nil || !cal.OffboxEnabled || cal.Tier3State != "active" {
@@ -126,7 +126,7 @@ func TestBuildAppBackupRows_EscrowPendingPrecedence(t *testing.T) {
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
}})
}}, "hu")
cal := findRow(rows, "calibre-web")
if cal == nil || cal.Tier3State != "escrow_pending" {
t.Fatalf("escrow-pending must win over a prior ok run: %+v", cal)
@@ -158,7 +158,7 @@ func TestBuildAppBackupRows_Tier1FromRestorePoints(t *testing.T) {
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "hasunit", DisplayName: "Has Unit"},
{StackName: "nounit", DisplayName: "No Unit"},
}})
}}, "hu")
hu := findRow(rows, "hasunit")
if hu == nil || hu.Tier1LastRun != mtime.Format(time.RFC3339) {
@@ -14,7 +14,7 @@ func TestDeadAppAlerts_PresentAndSelfClearing(t *testing.T) {
am := NewAlertManager(log.New(io.Discard, "", 0))
am.SetDeadAppAlerts([]DeadApp{{Name: "cwa", DisplayName: "Calibre-Web", State: "stopped"}})
got := am.GetAlerts()
got := am.GetAlerts("hu")
if len(got) != 1 || got[0].Level != "warning" || !strings.Contains(got[0].Message, "Telepített alkalmazás nem fut: Calibre-Web") {
t.Fatalf("expected a WARN dead-app banner, got %+v", got)
}
@@ -24,7 +24,7 @@ func TestDeadAppAlerts_PresentAndSelfClearing(t *testing.T) {
// The app recovers → an empty set clears the banner (state-based, no manual dismissal).
am.SetDeadAppAlerts(nil)
if got := am.GetAlerts(); len(got) != 0 {
if got := am.GetAlerts("hu"); len(got) != 0 {
t.Fatalf("dead-app banner must self-clear when the app recovers, got %+v", got)
}
}
@@ -38,7 +38,7 @@ func TestDeadAppAlerts_GroupedAboveThreshold(t *testing.T) {
many = append(many, DeadApp{Name: n, DisplayName: n, State: "stopped"})
}
am.SetDeadAppAlerts(many)
got := am.GetAlerts()
got := am.GetAlerts("hu")
if len(got) != 1 || !strings.Contains(got[0].Message, "5 telepített alkalmazás nem fut") {
t.Fatalf("expected one grouped banner for %d dead apps, got %+v", len(many), got)
}
+1 -1
View File
@@ -352,7 +352,7 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
// Alerts
if s.alertManager != nil {
dump["alerts"] = s.alertManager.GetAlerts()
dump["alerts"] = s.alertManager.GetAlerts(s.langFor(r))
}
w.Header().Set("Content-Type", "application/json")
+109 -106
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -47,11 +48,11 @@ type StorageBarInfo struct {
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
// purpose text on the storage-management page (Phase 4C).
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."
const storageBarPurposeKey = "storage.bar_purpose"
// buildStorageBars returns usage bars for all registered storage paths, in a stable order
// (by path) with a purpose description.
func (s *Server) buildStorageBars() []StorageBarInfo {
func (s *Server) buildStorageBars(lang string) []StorageBarInfo {
var bars []StorageBarInfo
for _, sp := range s.settings.GetStoragePaths() {
// Skip decommissioned drives — they are no longer in active use
@@ -62,7 +63,7 @@ func (s *Server) buildStorageBars() []StorageBarInfo {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
Purpose: s.msgLang(lang, storageBarPurposeKey),
Disconnected: true,
})
continue
@@ -74,7 +75,7 @@ func (s *Server) buildStorageBars() []StorageBarInfo {
bars = append(bars, StorageBarInfo{
Label: sp.Label,
Path: sp.Path,
Purpose: storageBarPurpose,
Purpose: s.msgLang(lang, storageBarPurposeKey),
TotalGB: di.TotalGB,
UsedGB: di.UsedGB,
Percent: di.UsedPercent,
@@ -134,7 +135,9 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
"ClaimLegacyOpen": s.claimLegacyOpen(),
}
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
// Hungarian here; addLanguageData re-renders the set in the request's language, because
// baseData has no request and every page goes through executeTemplate (v0.252.0, R-557).
data["Alerts"] = s.alertManager.GetAlerts(i18n.Default)
}
return data
}
@@ -189,7 +192,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
data["SystemInfo"] = sysInfo
data["StorageBars"] = s.buildStorageBars()
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
// Disk-health card (v0.169.0) — physical-disk SMART verdicts via the 60s-TTL-cached /disks call.
// Never blocks the render: an unreachable agent yields nil rows and the card shows its empty state.
@@ -214,7 +217,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["Subdomains"] = s.subdomainMap(deployedStacks)
if s.alertManager != nil {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard", s.langFor(r))
}
s.executeTemplate(w, r, "dashboard", data)
@@ -328,7 +331,7 @@ func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
data["ShareURL"] = "https://" + r.Host + "/s/" + token
}
data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != ""
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
if f := s.flashFrom(r, "flash"); f != "" {
data["ShareFlash"] = f
}
s.executeTemplate(w, r, "launcher", data)
@@ -400,7 +403,7 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
logs, err := s.stackMgr.GetLogs(name, 200)
if err != nil {
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
logs = s.msg(r, "logs.fetch_failed", err)
}
// Raw mode: return plain text for AJAX polling
@@ -411,6 +414,7 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
}
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
data["TitleKey"], data["TitleArgs"] = "page.title.logs", []interface{}{stack.Meta.DisplayName} // i18n: the Hungarian title above is what hu renders
data["Stack"] = stack
data["Logs"] = logs
s.executeTemplate(w, r, "logs", data)
@@ -432,11 +436,12 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
stack, _ := s.stackMgr.GetStack(name)
alreadyDeployed := appCfg != nil && appCfg.Deployed
pageTitle := meta.DisplayName + " — Telepítés"
pageTitle, pageTitleKey := meta.DisplayName+" — Telepítés", "page.title.deploy"
if alreadyDeployed {
pageTitle = meta.DisplayName + " — Beállítások"
pageTitle, pageTitleKey = meta.DisplayName+" — Beállítások", "page.title.app_settings"
}
data := s.baseData("deploy", pageTitle)
data["TitleKey"], data["TitleArgs"] = pageTitleKey, []interface{}{meta.DisplayName} // i18n: the Hungarian title above is what hu renders
data["Stack"] = stack
data["Meta"] = meta
data["AppConfig"] = appCfg
@@ -522,7 +527,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
// the honest UI over it, and it must not be mistaken for the boundary itself.
if refuse, _ := s.settings.RefuseAsAppNamespace(sp.Path); refuse {
dp.NotAllowed = true
dp.NotAllowedNote = "hálózati tárhely — alkalmazáshoz nem választható"
dp.NotAllowedNote = s.msg(r, "deploy.path_not_allowed")
}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
@@ -671,10 +676,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
// Flash messages from cross-drive backup save redirect
if flash := r.URL.Query().Get("flash"); flash != "" {
if flash := s.flashFrom(r, "flash"); flash != "" {
data["FlashSuccess"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
@@ -754,7 +759,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
case settings.FileBrowserAdminGenerated:
data["HasAppInfo"] = true
data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin",
Note: "A Fájlkezelő belépése. A jelszót a Felhom állította be ezen a gépen."}
Note: s.msg(r, "creds.filebrowser_note")}
data["InitialCredsHasPassword"] = enc != ""
case settings.FileBrowserAdminOperator:
data["HasAppInfo"] = true
@@ -797,11 +802,11 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("monitoring", "Rendszermonitor")
data["TitleKey"] = "page.title.monitoring" // i18n: the Hungarian title above is what hu renders
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
data["Alerts"] = s.alertManager.GetAlerts(s.langFor(r))
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring", s.langFor(r))
}
// Hub connection status section
@@ -824,11 +829,11 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
if s.cfg.Monitoring.Enabled {
pings := []map[string]interface{}{
{"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"},
{"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"},
{"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
{"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
{"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": monitoringIntegritySchedule},
{"Label": s.msg(r, "ping.label.heartbeat"), "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": s.msg(r, "ping.schedule.5min")},
{"Label": s.msg(r, "ping.label.system_health"), "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": s.msg(r, "ping.schedule.5min")},
{"Label": s.msg(r, "ping.label.db_dump"), "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": s.msg(r, "ping.schedule.daily_at", s.cfg.Backup.DBDumpSchedule)},
{"Label": s.msg(r, "ping.label.backup"), "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": s.msg(r, "ping.schedule.daily_at", s.cfg.Backup.ResticSchedule)},
{"Label": s.msg(r, "ping.label.integrity"), "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": monitoringIntegritySchedule},
}
allConfigured := true
for _, p := range pings {
@@ -863,10 +868,10 @@ func (s *Server) backupsCommonData(page, title string, r *http.Request) map[stri
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
// Pass flash messages from query params (set by redirect handlers)
if flash := r.URL.Query().Get("flash"); flash != "" {
if flash := s.flashFrom(r, "flash"); flash != "" {
fullStatus.FlashSuccess = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
fullStatus.FlashError = flashErr
}
data["Backup"] = fullStatus
@@ -875,7 +880,7 @@ func (s *Server) backupsCommonData(page, title string, r *http.Request) map[stri
// backupsOffboxData adds the offbox target + per-app toggle state (the remote, apps and restore
// pages all render some of it: status card / toggle list / tier-3 rows / restore-to-verify).
func (s *Server) backupsOffboxData(data map[string]interface{}) {
func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
offboxTgt := s.settings.GetOffboxTarget()
data["Offbox"] = offboxTgt
data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured()
@@ -897,7 +902,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
data["OffboxToggledCount"] = offboxToggled
// Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation.
if offboxTgt != nil {
data["OffboxWarningDisplay"] = offboxWarningDisplay(offboxTgt.LastWarning, offboxTgt.LastWarningKind, offboxToggled)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(offboxTgt.LastWarning, offboxTgt.LastWarningKind, offboxToggled, lang)
} else {
data["OffboxWarningDisplay"] = ""
}
@@ -918,7 +923,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
// upgraded with that older text already persisted — see offboxWarningDisplay.
const (
offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás"
offboxSelectionChangedLine = "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza."
offboxSelectionChangedKey = "offbox.selection_changed"
)
// offboxWarningDisplay picks what the Távoli mentés page shows for the persisted
@@ -934,15 +939,15 @@ const (
// R-553 legacy: remove after every fleet box has completed one off-site run on ≥ 0.251.0 (row R-570).
// Localisation slice 2 (R-557) must not translate the producer at backup/offbox.go until that row is
// closed — translating it while this fallback is still needed would strand exactly those boxes.
func offboxWarningDisplay(lastWarning, kind string, toggledCount int) string {
func (s *Server) offboxWarningDisplay(lastWarning, kind string, toggledCount int, lang string) string {
if toggledCount < 1 {
return lastWarning
}
if kind == backup.OffboxWarnNoAppsSelected {
return offboxSelectionChangedLine
return s.msgLang(lang, offboxSelectionChangedKey)
}
if kind == "" && strings.Contains(lastWarning, offboxStaleWarningMarker) {
return offboxSelectionChangedLine
return s.msgLang(lang, offboxSelectionChangedKey)
}
return lastWarning
}
@@ -955,7 +960,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
// System info for storage overview bars
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
data["GuestBackup"] = s.loadGuestBackup(r.Context())
@@ -1007,7 +1012,7 @@ func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool)
func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r)
data["TitleKey"] = "page.title.backups_remote" // i18n: the Hungarian title above is what hu renders
s.backupsOffboxData(data)
s.backupsOffboxData(data, s.langFor(r))
// Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate.
data["EscrowStale"] = s.escrowStale()
agentVer := ""
@@ -1055,7 +1060,7 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r)
data["TitleKey"] = "page.title.backups_apps" // i18n: the Hungarian title above is what hu renders
s.backupsOffboxData(data) // the tier-3 rows render $.Offbox status
s.backupsOffboxData(data, s.langFor(r)) // the tier-3 rows render $.Offbox status
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
// Enrich AppDataInfo with storage labels
@@ -1076,7 +1081,7 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
// Build unified per-app backup rows for the app-data backup UI.
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, s.langFor(r))
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
}
@@ -1092,7 +1097,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil {
data["InterruptedRestores"] = s.backupMgr.InterruptedRestores()
}
s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps
s.backupsOffboxData(data, s.langFor(r)) // restore-to-verify lists the offbox-toggled apps
// Full-restore two-step reveal (§7.2): after the size+headroom prepare step, offboxRestoreHandler
// redirects here with the app + human size so the confirm section can show the size BEFORE starting.
if fp := strings.TrimSpace(r.URL.Query().Get("full_prep")); fp != "" {
@@ -1325,7 +1330,7 @@ func appDumpVerdict(dump *backup.DBDumpStatus, stackName string) string {
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
// reflects only the app-data backup (DB dumps + Docker-volume tars).
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string) []AppBackupRow {
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
@@ -1396,11 +1401,11 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
if hasDB {
base = append(base, "DB")
}
base = append(base, "Konfig")
base = append(base, s.msgLang(lang, "backup.contents.db"))
withData := func(add bool) string {
p := append([]string{}, base...)
if add {
p = append(p, "Adatok")
p = append(p, s.msgLang(lang, "backup.contents.data"))
}
return strings.Join(p, " + ")
}
@@ -1464,10 +1469,10 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// Status dot — app-data backup status
row.Status = "green"
row.StatusText = "Alkalmazás-adat mentés rendben"
row.StatusText = s.msgLang(lang, "backup.status.ok")
if hasDB && tier1DBStatus == "error" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
row.StatusText = s.msgLang(lang, "backup.status.db_failed")
}
// R-379/R-380: a HELD app must never read as healthy. Last, so it wins over both branches
// above — a warning beside a green tick is read as a success, and this is the one state where
@@ -1488,12 +1493,12 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
} else if cd.LastStatus == "no_target" {
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
row.Tier2Configured = false
row.Tier2StatusBadge = "Nincs 2. meghajtó"
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.no_drive")
row.Tier2LastError = cd.LastError
} else if cd.Enabled {
row.Tier2Configured = true
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
row.Tier2Schedule = "Naponta"
row.Tier2Dest = s.tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath, lang)
row.Tier2Schedule = s.msgLang(lang, "backup.tier2.schedule_daily")
row.Tier2LastRun = cd.LastRun
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastSuccess = cd.LastSuccess
@@ -1528,11 +1533,11 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
}
switch cd.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_ok")
case "error":
row.Tier2StatusBadge = "Hiba"
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_error")
case "running":
row.Tier2StatusBadge = "Fut..."
row.Tier2StatusBadge = s.msgLang(lang, "backup.tier2.badge_running")
default:
row.Tier2StatusBadge = "—"
}
@@ -1557,7 +1562,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
Slug: u.StackName,
StorageLabel: u.DriveLabel,
Status: "yellow",
StatusText: "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
StatusText: s.msgLang(lang, "backup.removed_app"),
Removed: true,
RemovedUnitTime: u.Time,
Tier1LastRun: u.Time,
@@ -1569,9 +1574,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
func tier2DestLabel(destPath, systemDataPath string) string {
func (s *Server) tier2DestLabel(destPath, systemDataPath, lang string) string {
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
return "belső SSD (csak DB/konfiguráció)"
return s.msgLang(lang, "backup.tier2.dest_ssd")
}
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
}
@@ -1620,7 +1625,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash_error", "flash.backup.not_configured"), http.StatusFound)
return
}
// Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer
@@ -1669,7 +1674,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// unitRestoreOutcomeMsg builds the OUTCOME sentence for a completed LOCAL recovery-unit restore. Pure,
@@ -1905,7 +1910,7 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", "flash.backup.not_configured"), http.StatusFound)
return
}
// Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine.
@@ -1968,7 +1973,7 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files, legs=%v)", stackName, n, cov.Legs)
s.backupMgr.EndRestoreOp(true, msg)
}()
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.file_started"), http.StatusFound)
}
// backupTier2UnitRestoreHandler (R-102/R-103) restores an app IN FULL from the recovery unit mirrored
@@ -1997,7 +2002,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
return
}
if s.backupMgr == nil {
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", "flash.backup.not_configured"), http.StatusFound)
return
}
// R-351b (Scenario H): a second press — by button or by a direct POST — must not start a second
@@ -2046,7 +2051,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
}
s.backupMgr.EndRestoreOp(true, msg)
}()
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Teljes visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound)
}
// settingsBaseData is the shared identity block used by every settings-family subpage
@@ -2268,7 +2273,7 @@ func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r
// Not an error: a box that never stored one has nothing to reveal, and saying so is not a
// leak. The page does not offer the button in that case (HasRetrievalPassword gates it).
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ezen a gépen nincs tárolt visszaállítási jelszó.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.no_retrieval_password"))
return
}
// The reveal is an event, and it was not one before: the same act on the hub's break-glass
@@ -2309,18 +2314,18 @@ func (s *Server) readInitialCreds(stackName string) (*stacks.ExtractedCreds, err
// and it is what stops this becoming "read me any value out of any app's config".
func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Request, stackName string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.stack_mgr_unavailable"))
return
}
stack, ok := s.stackMgr.GetStack(stackName)
if !ok {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
_ = r.ParseForm()
envVar := strings.TrimSpace(r.FormValue("env_var"))
if envVar == "" {
escrowJSON(w, http.StatusBadRequest, nil, "Hiányzó mező.")
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "escrow.missing_field"))
return
}
// AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata.
@@ -2333,18 +2338,18 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques
}
if !allowed {
s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar)
escrowJSON(w, http.StatusForbidden, nil, "Ez a mező nem kérhető le.")
escrowJSON(w, http.StatusForbidden, nil, s.msg(r, "escrow.field_not_revealable"))
return
}
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
if appCfg == nil {
escrowJSON(w, http.StatusNotFound, nil, "Az alkalmazás beállításai nem olvashatók.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.app_settings_unreadable"))
return
}
val := crypto.DecryptMap(s.encKey, appCfg.Env)[envVar]
if strings.TrimSpace(val) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ehhez a mezőhöz nincs mentett érték.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.no_stored_value"))
return
}
s.logger.Printf("[INFO] [web] auto-generated secret revealed for %s/%s from %s (value never logged)", stackName, envVar, clientIP(r))
@@ -2370,7 +2375,7 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques
// "your password is empty".
func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Request, slug string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.stack_mgr_unavailable"))
return
}
// Resolved EXACTLY as appDetailHandler resolves it — same loop, same field. A second definition
@@ -2384,7 +2389,7 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req
}
}
if found == nil {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
// R-513: the file manager's password is the controller's own stored value, not a container file.
@@ -2396,13 +2401,12 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req
if err != nil {
// Never swallowed, and never surfaced raw — the error can name a container/path.
s.logger.Printf("[WARN] [web] initial-creds reveal for %s: %v", found.Name, err)
escrowJSON(w, http.StatusBadGateway, nil, "A kezdeti jelszó beolvasása nem sikerült.")
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "escrow.initial_pw_read_failed"))
return
}
if creds == nil || !creds.Available || strings.TrimSpace(creds.Password) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil,
"A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.initial_pw_unavailable"))
return
}
s.logger.Printf("[INFO] [web] initial-credential password revealed for %s from %s (value never logged)", found.Name, clientIP(r))
@@ -2419,18 +2423,18 @@ const fileBrowserStack = "filebrowser"
func (s *Server) fileBrowserPasswordReveal(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if s.settings == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "A beállítások nem elérhetők.")
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.settings_unavailable"))
return
}
state, enc, _ := s.settings.GetFileBrowserAdmin()
if state != settings.FileBrowserAdminGenerated || enc == "" {
escrowJSON(w, http.StatusNotFound, nil, "A Fájlkezelő jelszavát nem a Felhom állította be ezen a gépen, ezért nem tudjuk megmutatni.")
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.filebrowser_not_ours"))
return
}
pw, err := crypto.Decrypt(s.encKey, enc)
if err != nil || strings.TrimSpace(pw) == "" {
s.logger.Printf("[ERROR] [web] filebrowser password reveal: decrypt failed: %v", err)
escrowJSON(w, http.StatusInternalServerError, nil, "A jelszó most nem olvasható ki.")
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "escrow.password_unreadable"))
return
}
s.logger.Printf("[INFO] [web] filebrowser admin password revealed from %s (value never logged)", clientIP(r))
@@ -2484,21 +2488,21 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
}
data["PasswordError"] = "Hibás jelenlegi jelszó"
data["PasswordError"] = s.msg(r, "settings.pw_wrong_current")
s.executeTemplate(w, r, "settings_security", data)
return
}
// Validate new password length
if len(newPassword) < 8 {
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
data["PasswordError"] = s.msg(r, "settings.pw_too_short")
s.executeTemplate(w, r, "settings_security", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
data["PasswordError"] = s.msg(r, "settings.pw_mismatch")
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2507,7 +2511,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
data["PasswordError"] = s.msg(r, "settings.pw_save_error")
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2515,7 +2519,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
// Save to settings.json
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err)
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
data["PasswordError"] = s.msg(r, "settings.pw_save_error")
s.executeTemplate(w, r, "settings_security", data)
return
}
@@ -2526,8 +2530,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
s.invalidateAllSessions()
// Redirect to login with flash message
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
http.Redirect(w, r, "/login?"+flashQuery("flash", "flash.login.password_changed"), http.StatusFound)
}
// sameEventSet reports whether two event lists hold the same keys, ignoring order and duplicates.
@@ -2676,7 +2679,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
EnabledEvents: enabledEvents,
CooldownHours: cooldownHours,
}
data["NotificationError"] = "Adj meg egy értesítési e-mail címet – bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket."
data["NotificationError"] = s.msg(r, "settings.notify_email_required")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2690,7 +2693,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err)
data := s.notificationsPageData()
data["NotificationError"] = "Hiba a beállítások mentésekor"
data["NotificationError"] = s.msg(r, "settings.notify_save_error")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2703,12 +2706,12 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
if s.notifier != nil && s.notifier.IsEnabled() {
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved_sync_failed", err)
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved")
}
} else {
data["NotificationSuccess"] = "Értesítési beállítások mentve."
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved")
}
s.executeTemplate(w, r, "settings_notifications", data)
}
@@ -2723,7 +2726,7 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
data := s.notificationsPageData()
if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
data["AppEmailError"] = s.msg(r, "settings.app_email_save_error")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2735,7 +2738,7 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
if err := s.mailShim.Apply(enabled); err != nil {
s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
data = s.notificationsPageData()
data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
data["AppEmailError"] = s.msg(r, "settings.app_email_shim_failed")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2743,9 +2746,9 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
data = s.notificationsPageData()
if enabled {
data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
data["AppEmailSuccess"] = s.msg(r, "settings.app_email_on")
} else {
data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
data["AppEmailSuccess"] = s.msg(r, "settings.app_email_off")
}
s.executeTemplate(w, r, "settings_notifications", data)
}
@@ -2754,7 +2757,7 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
data := s.notificationsPageData()
if s.notifier == nil {
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
data["NotificationError"] = s.msg(r, "settings.notify_disabled")
s.executeTemplate(w, r, "settings_notifications", data)
return
}
@@ -2762,12 +2765,12 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
err := s.notifier.SendTest()
if err != nil {
s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
data["NotificationError"] = s.msg(r, "settings.test_email_failed", err)
s.executeTemplate(w, r, "settings_notifications", data)
return
}
data["NotificationSuccess"] = "Teszt email elküldve."
data["NotificationSuccess"] = s.msg(r, "settings.test_email_sent")
s.executeTemplate(w, r, "settings_notifications", data)
}
@@ -3049,21 +3052,21 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
// 1. Exists and is directory
fi, err := os.Stat(path)
if err != nil || !fi.IsDir() {
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
data["StorageError"] = s.msg(r, "storage.err_path_missing")
s.executeTemplate(w, r, "storage", data)
return
}
// 2. Is mount point
if !system.IsMountPoint(path) {
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
data["StorageError"] = s.msg(r, "storage.err_not_separate")
s.executeTemplate(w, r, "storage", data)
return
}
// 3. Writable
if !system.IsWritable(path) {
data["StorageError"] = "Az útvonal nem írható."
data["StorageError"] = s.msg(r, "storage.err_not_writable")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3071,7 +3074,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
// 4. No overlap with existing paths
for _, existing := range s.settings.GetStoragePaths() {
if system.PathsOverlap(path, existing.Path) {
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
data["StorageError"] = s.msg(r, "storage.err_overlaps", existing.Path)
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3092,14 +3095,14 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
if err := s.settings.AddStoragePath(sp); err != nil {
s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
data["StorageError"] = "Hiba a mentés során."
data["StorageError"] = s.msg(r, "storage.err_save")
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
go s.SyncFileBrowserMounts()
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_added", path)), http.StatusFound)
}
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
@@ -3115,7 +3118,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: apps using this path
apps := s.appsUsingPath(path)
if len(apps) > 0 {
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
data["StorageError"] = s.msg(r, "storage.err_in_use", strings.Join(apps, ", "))
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3123,7 +3126,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: cannot remove default
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == path && sp.IsDefault {
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
data["StorageError"] = s.msg(r, "storage.err_default")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3131,13 +3134,13 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
// Check: last path
if len(s.settings.GetStoragePaths()) <= 1 {
data["StorageError"] = "Az utolsó adattároló nem törölhető."
data["StorageError"] = s.msg(r, "storage.err_last")
s.executeTemplate(w, r, "storage", data)
return
}
if err := s.settings.RemoveStoragePath(path); err != nil {
data["StorageError"] = "Hiba a törlés során."
data["StorageError"] = s.msg(r, "storage.err_delete")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3145,7 +3148,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
s.logger.Printf("[INFO] [web] Storage path removed: %s", path)
// Sync FileBrowser mounts after storage path removal
go s.SyncFileBrowserMounts()
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_removed", path)), http.StatusFound)
}
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
@@ -3162,7 +3165,7 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re
return
}
s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_default_set", path)), http.StatusFound)
}
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
@@ -3180,7 +3183,7 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt
return
}
s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_state_changed", path)), http.StatusFound)
}
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
@@ -3194,7 +3197,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
if label == "" || len(label) > 50 {
data := s.storagePageData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
data["StorageError"] = s.msg(r, "storage.err_label")
s.executeTemplate(w, r, "storage", data)
return
}
@@ -3202,13 +3205,13 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
if err := s.settings.SetStorageLabel(path, label); err != nil {
s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err)
data := s.storagePageData()
data["StorageError"] = "Hiba a megnevezés mentésekor."
data["StorageError"] = s.msg(r, "storage.err_label_save")
s.executeTemplate(w, r, "storage", data)
return
}
s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape(s.msg(r, "storage.msg_label_changed", label)), http.StatusFound)
}
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
+165
View File
@@ -0,0 +1,165 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Localisation slice 2 (R-557), scenario S1 — a flash follows the language of the request that READS
// it, and a link minted by an older controller still reads correctly.
//
// Why this is not obvious: a flash does not travel in the response that produced it. The handler
// redirects, the browser asks for the destination, and a SECOND request renders the line. Before
// v0.252.0 the sentence itself rode in the query string, so it was written in the language of the
// handler that redirected — which is the household's language only by luck. The value is now a bundle
// key; the reader renders it.
//
// The compatibility half is the load-bearing one. A customer's open tab, a bookmark, the browser's
// back-forward cache and a mail client can all replay a URL minted by 0.251.0, whose `flash` is
// Hungarian prose. Such a value is shown verbatim, never as a raw key and never dropped.
func flashServer(t *testing.T) *Server {
t.Helper()
b, err := i18n.Load()
if err != nil {
t.Fatalf("bundle: %v", err)
}
return &Server{i18n: b}
}
func flashRequest(t *testing.T, rawQuery string) *http.Request {
t.Helper()
u, err := url.Parse("/launcher?" + rawQuery)
if err != nil {
t.Fatalf("query %q: %v", rawQuery, err)
}
return httptest.NewRequest(http.MethodGet, u.String(), nil)
}
func TestFlashKeyRoundTrip(t *testing.T) {
s := flashServer(t)
// The writer's side: launcherShareRedirect builds this.
q := flashQuery("flash", "flash.share.enabled")
if got := flashRequest(t, q).URL.Query().Get("flash"); got != "flash.share.enabled" {
t.Fatalf("the key did not survive the URL: %q", got)
}
cases := []struct {
name, query, lang, want string
}{
{
name: "hungarian reads the Hungarian sentence, byte for byte",
query: q, lang: "hu",
want: "A megosztás bekapcsolva.",
},
{
name: "english reads English",
query: q, lang: "en",
want: "Sharing is on.",
},
{
// A URL minted by v0.251.0 or earlier. `+` is a space in a query string.
name: "a legacy link carries prose and is shown verbatim",
query: "flash=Sikeres+ment%C3%A9s", lang: "en",
want: "Sikeres mentés",
},
{
name: "an unknown key-shaped value is not invented into a sentence",
query: "flash=flash.share.no_such_key", lang: "en",
want: "flash.share.no_such_key",
},
{
// The escaping case. flashText returns the value; html/template escapes it at render, as
// it always did. What must NOT happen is the value being treated as a key or dropped.
name: "a hand-typed script tag survives as text, to be escaped at render",
query: "flash=%3Cscript%3Ealert(1)%3C%2Fscript%3E", lang: "en",
want: "<script>alert(1)</script>",
},
{
name: "no flash at all is empty, not a key",
query: "", lang: "en",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := flashRequest(t, tc.query+"&lang="+tc.lang)
if got := s.flashFrom(r, "flash"); got != tc.want {
t.Errorf("flash\n got %q\n want %q", got, tc.want)
}
})
}
}
// TestFlashKeyCarriesParameters — a flash whose sentence names something (an app, a drive) carries
// that name as a separate `fa` parameter, so English may put it somewhere else in the sentence.
func TestFlashKeyCarriesParameters(t *testing.T) {
s := flashServer(t)
// The parameter-carrying flash in the bundle today. Asserted through the real bundle rather than a
// fixture, and self-arming: if no flash message takes a parameter any more the test says so out
// loud instead of passing vacuously.
const key = "flash.offbox.config_invalid"
if !s.i18n.Has(i18n.Default, key) {
t.Fatalf("%s left the bundle — this test no longer covers a parameter-carrying flash", key)
}
if !strings.Contains(s.i18n.Msg(i18n.Default, key), "%s") {
t.Fatalf("%s no longer takes a parameter — point this test at one that does", key)
}
r := flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=hu")
got := s.flashFrom(r, "flash_error")
if want := "Érvénytelen beállítás: a port nem szam"; got != want {
t.Errorf("parameter did not reach the message\n got %q\n want %q", got, want)
}
// And the same key, same parameter, in English: the parameter survives a different word order.
r = flashRequest(t, flashQuery("flash_error", key, "a port nem szam")+"&lang=en")
if got := s.flashFrom(r, "flash_error"); !strings.Contains(got, "a port nem szam") {
t.Errorf("the parameter was lost in English: %q", got)
}
}
// TestFlashOnAServerWithNoBundleField — a Server built without going through loadTemplates still
// renders SENTENCES, never raw keys. s.bundle() falls back to the embedded i18n.Shared() for exactly
// this: a page that shows „flash.share.enabled" to a household is the failure mode §4 of the
// localisation design forbids, and it was reachable from any construction path but the real one.
//
// RED-PROOF (REPORT): make s.bundle() return s.i18n unchanged → this test reads back the key.
func TestFlashOnAServerWithNoBundleField(t *testing.T) {
s := &Server{} // i18n field never set
if got := s.flashFrom(flashRequest(t, "flash=flash.share.enabled"), "flash"); got != "A megosztás bekapcsolva." {
t.Errorf("a Server with no bundle field showed %q instead of the sentence", got)
}
// And a legacy sentence still passes through untouched.
if got := s.flashFrom(flashRequest(t, "flash=Sikeres+ment%C3%A9s"), "flash"); got != "Sikeres mentés" {
t.Errorf("got %q, want the legacy text verbatim", got)
}
}
// flashSentence resolves the flash a redirect Location carries, in Hungarian.
//
// It exists because v0.252.0 moved the SENTENCE out of the URL and put a bundle KEY there instead.
// A test that searched the Location header for „elindult" or „letét" was never asserting the URL —
// it was asserting what the customer is told — and that is still exactly checkable, one lookup
// later. Tests assert through this rather than against the key, so a key renamed with its message
// intact stays green and a message REWORDED still fails, which is the right way round.
func flashSentence(t *testing.T, location string) string {
t.Helper()
u, err := url.Parse(location)
if err != nil {
t.Fatalf("Location %q: %v", location, err)
}
s := flashServer(t)
r := httptest.NewRequest(http.MethodGet, location, nil)
for _, p := range []string{"flash", "flash_error"} {
if v := u.Query().Get(p); v != "" {
return s.flashText(r, v)
}
}
return ""
}
+144
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
@@ -18,6 +19,132 @@ import (
// was before this file existed — pinned by i18n_parity_test.go against fixtures captured from the
// unconverted templates.
// ── Go-side copy (v0.252.0, slice 2 — R-557) ───────────────────────────────────────────────────
//
// A handler builds sentences the template never sees: flash lines, page data, JSON answers. Those go
// through these three helpers, never through a Hungarian literal, so they follow the request's
// language exactly as the template does.
//
// The Hungarian text is the SAME BYTES the literal carried, which scripts/i18n_go_parity.py measures
// against a frozen capture of the base commit. So a handler that reads
//
// data["Msg"] = s.msg(r, "backup.window_updated")
//
// renders, for a Hungarian household, the literal that used to sit at that line and nothing else.
// bundle returns the message bundle for these helpers.
//
// It falls back to the process-wide i18n.Shared() when the Server has none. That is not defensive
// clutter: `s.i18n` is set by loadTemplates, so any Server built WITHOUT going through it — a test
// fixture, a future construction path — would otherwise render raw KEYS onto a page, which is the one
// outcome §4 of the localisation design forbids ("never a key, never a blank"). The bundle is
// embedded in the binary, so the fallback cannot fail for any reason a running box can reach; if it
// somehow does, the caller still gets the key rather than a panic.
func (s *Server) bundle() *i18n.Bundle {
if s.i18n != nil {
return s.i18n
}
b, err := i18n.Shared()
if err != nil {
return nil
}
return b
}
// msg returns a Go-side message in the request's language, with the message's own printf verbs
// filled in from a.
func (s *Server) msg(r *http.Request, key string, a ...interface{}) string {
return s.msgLang(s.langFor(r), key, a...)
}
// msgLang is msg for a language that is already known — a background run reading the box's setting
// (internal/settings GetLanguage), or a handler that resolved the language once for several lines.
func (s *Server) msgLang(lang, key string, a ...interface{}) string {
b := s.bundle()
if b == nil {
return key
}
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
// msgN returns a count-dependent message in the request's language. Hungarian carries one form under
// the key itself; English carries key+".one" and key+".other" (i18n.Bundle.Plural).
func (s *Server) msgN(r *http.Request, key string, n int) string {
b := s.bundle()
if b == nil {
return key
}
return b.Plural(s.langFor(r), key, n)
}
// ── Flash lines (v0.252.0) ─────────────────────────────────────────────────────────────────────
//
// A flash travels to the page INSIDE THE REDIRECT URL (`?flash=…`), so it is rendered by a DIFFERENT
// request from the one that wrote it — and until now it travelled as Hungarian text, which is the
// language of whoever redirected. It now travels as a bundle KEY plus its parameters, and the reader
// renders it in its own language.
//
// Backward compatibility is not a nicety here: a customer's open tab, a bookmark or a browser's
// back-forward cache can replay a URL minted by the previous version, and a mail client can carry
// one. So the rule is: a value the bundle knows as a key is a MESSAGE; anything else is TEXT and is
// shown verbatim, exactly as it was before. That also covers a hand-typed `?flash=<script>` — which
// html/template escapes, as it always did.
// flashArgParam is the repeated query parameter carrying a flash message's parameters, in order.
const flashArgParam = "fa"
// flashQuery builds the query fragment for a flash: the key, then one `fa` per parameter.
// It returns "flash=…" (or "flash_error=…") WITHOUT a leading separator, because the callers differ
// on whether the destination already carries a query.
func flashQuery(param, key string, args ...string) string {
q := url.Values{}
q.Set(param, key)
for _, a := range args {
q.Add(flashArgParam, a)
}
return q.Encode()
}
// flashText resolves a flash query value for display.
//
// A value that names a key in the bundle is rendered in lang, with the `fa` parameters filled in.
// Anything else — a sentence minted by an older controller, or something a person typed — is
// returned unchanged. It is never dropped and never shown as a raw key.
func (s *Server) flashText(r *http.Request, v string) string {
if v == "" {
return ""
}
b := s.bundle()
if b == nil {
return v
}
lang := s.langFor(r)
if !b.Has(i18n.Default, v) {
return v // legacy text, or not ours: verbatim
}
var args []interface{}
if r != nil {
for _, a := range r.URL.Query()[flashArgParam] {
args = append(args, a)
}
}
if len(args) == 0 {
return b.Msg(lang, v)
}
return b.Msgf(lang, v, args...)
}
// flashFrom reads one flash parameter off the request and resolves it. Empty when absent.
func (s *Server) flashFrom(r *http.Request, param string) string {
if r == nil {
return ""
}
return s.flashText(r, strings.TrimSpace(r.URL.Query().Get(param)))
}
// addLanguageData puts the request's language and the switch state into a page's data.
//
// The switch is on every dashboard page, in every language (v0.250.0, slice 1 release C). Until then it
@@ -30,11 +157,28 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
data["LangSwitch"] = true
data["LangSwitchBack"] = r.URL.Path
}
// The alert banners were stored by a background health cycle and put into the page data by
// baseData, which has no request and therefore no language. Re-rendered here, where the language
// is known. Idempotent: an Alert keeps its key, so rendering it twice is rendering it once.
if alerts, ok := data["Alerts"].([]Alert); ok {
for i := range alerts {
alerts[i] = alerts[i].rendered(lang)
}
}
// A page title is Go-side copy. The handler names its key; the Hungarian title the handler set is
// left untouched, so a Hungarian page cannot change here.
//
// TitleArgs (v0.252.0, R-566) carries the app name for the three titles built around one: „<app>
// — Naplók", „<app> — Telepítés"/„— Beállítások" and „2. mentés beállítása — <app>". Their page
// BODIES were English from slice 1 while the browser tab stayed Hungarian, because one static
// message cannot hold a name. The message now carries a `%s` and the handler supplies the name.
if key, ok := data["TitleKey"].(string); ok && lang != i18n.Default && s.i18n != nil {
if args, ok := data["TitleArgs"].([]interface{}); ok && len(args) > 0 {
data["Title"] = s.i18n.Msgf(lang, key, args...)
} else {
data["Title"] = s.i18n.Msg(lang, key)
}
}
}
// languageSwitchHandler stores the household's language (POST /settings/language) and goes back to
@@ -358,6 +358,13 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
// title, titleKey := "Title", "key"
regexp.MustCompile(`title, titleKey :?= "([^"]+)", "([^"]+)"`),
}
// R-566, v0.252.0 — the three titles built around an app name, plus the tier-2 one. Their
// Hungarian is a CONCATENATION, so there is no single literal to compare with; the pair is pinned
// instead by TestParameterisedPageTitles (the rendered title) and by scripts/i18n_go_parity.py
// (the key's text against the base-commit fragment). Collected here so the "every page.title.* key
// has a handler" half below still accounts for them.
withArgs := regexp.MustCompile(`data\["TitleKey"\], data\["TitleArgs"\] = (?:pageTitleKey|"([^"]+)")`)
argKeys := []string{"page.title.logs", "page.title.deploy", "page.title.app_settings", "page.title.tier2_config"}
files, _ := filepath.Glob("*.go")
seen := map[string]bool{}
for _, f := range files {
@@ -368,6 +375,11 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, m := range withArgs.FindAllStringSubmatch(string(src), -1) {
if m[1] != "" {
seen[m[1]] = true
}
}
for _, re := range pairs {
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
seen[m[2]] = true
@@ -377,6 +389,11 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
}
}
}
// The two keys a `pageTitleKey` variable carries cannot be read off the assignment line; they are
// named here and proven by TestParameterisedPageTitles.
for _, k := range argKeys {
seen[k] = true
}
for _, k := range b.Keys(i18n.Default) {
if strings.HasPrefix(k, "page.title.") && !seen[k] {
t.Errorf("%s has no handler setting it next to its Hungarian title", k)
@@ -499,3 +516,39 @@ func TestI18nDirectRenderPagesHaveNoAdminChrome(t *testing.T) {
}
}
}
// TestParameterisedPageTitles — R-566, v0.252.0. Three page titles are built in Go AROUND an app
// name („<app> — Naplók", „<app> — Telepítés" / „— Beállítások", „2. mentés beállítása — <app>").
// Slice 1 gave every STATIC title a key and left these three Hungarian in the browser tab while the
// page body was English, because one static message cannot hold a name.
//
// What this pins: rendering the key with the app name produces EXACTLY the string the handler's
// concatenation produced before, and English differs. The first half is the parity rule for a title;
// the second is the reason the row exists.
func TestParameterisedPageTitles(t *testing.T) {
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
const app = "PrivateBin"
cases := []struct {
key, wantHU string
}{
{"page.title.logs", app + " — Naplók"},
{"page.title.deploy", app + " — Telepítés"},
{"page.title.app_settings", app + " — Beállítások"},
{"page.title.tier2_config", "2. mentés beállítása — " + app},
}
for _, c := range cases {
if got := b.Msgf(i18n.Default, c.key, app); got != c.wantHU {
t.Errorf("%s in Hungarian:\n got %q\n want %q (the concatenation this replaced)", c.key, got, c.wantHU)
}
en := b.Msgf("en", c.key, app)
if !strings.Contains(en, app) {
t.Errorf("%s in English lost the app name: %q", c.key, en)
}
if en == c.wantHU {
t.Errorf("%s was never translated — English still reads %q", c.key, en)
}
}
}
+11 -3
View File
@@ -16,6 +16,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -30,7 +31,14 @@ func newOffboxWebServer(t *testing.T) (*Server, *settings.Settings, *backup.Mana
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
m := backup.NewManager(cfg, sett, lg)
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg}, sett, m
// The bundle is loaded here (v0.252.0, R-557) because the page view-model text now comes from it.
// Every Hungarian assertion in the tests below is therefore a measurement of hu.json against the
// sentence the page used to carry -- which is the parity property, checked where it is read.
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg, i18n: b}, sett, m
}
// The run handler refuses while escrow is pending, and confirm-escrow flips to escrowed + runnable.
@@ -51,7 +59,7 @@ func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
// run while pending → refused with the escrow-wait flash, no run launched
w := httptest.NewRecorder()
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(loc, "let%C3%A9t") {
if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(flashSentence(t, loc), "letétbe") {
t.Fatalf("pending run must redirect with the escrow-wait flash, got %d %q", w.Code, loc)
}
if m.OffboxRunnable() {
@@ -114,7 +122,7 @@ func TestOffboxRun_RefusedWhenOrphaned(t *testing.T) {
if w.Code != 302 {
t.Fatalf("orphaned run must redirect, got %d", w.Code)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "el%C3%A1rvult") {
if loc := w.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "elárvult") {
t.Fatalf("orphaned run must redirect to the orphan-card flash, got %q", loc)
}
}
+63 -53
View File
@@ -23,11 +23,17 @@ import (
// offboxRedirect sends the customer back to the Távoli mentés page with a flash (success or
// error) message (v0.124.0 IA split: the offbox controls live on /backups/remote; the
// restore-to-verify flow redirects to /backups/restore via offboxRedirectTo).
func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) {
offboxRedirectTo(w, r, "/backups/remote", msg, isErr)
func offboxRedirect(w http.ResponseWriter, r *http.Request, key string, isErr bool, args ...string) {
offboxRedirectTo(w, r, "/backups/remote", key, isErr, args...)
}
func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, isErr bool) {
// offboxRedirectTo sends the customer to `page` with a flash.
//
// `key` names a bundle message, not a sentence (v0.252.0, R-557) — the page that RENDERS the flash is
// reached by a second request, and only that request knows the household's language. `args` fill the
// message's printf verbs and ride as repeated `fa` parameters. A value the bundle does not know is
// shown verbatim, so a link minted by an older controller still reads correctly.
func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, key string, isErr bool, args ...string) {
q := "flash"
if isErr {
q = "flash_error"
@@ -39,13 +45,13 @@ func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string,
if strings.Contains(page, "?") {
sep = "&"
}
http.Redirect(w, r, page+sep+q+"="+url.QueryEscape(msg), http.StatusFound)
http.Redirect(w, r, page+sep+flashQuery(q, key, args...), http.StatusFound)
}
// offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts.
func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
offboxRedirect(w, r, "flash.offbox.mgr_unavailable", true)
return
}
_ = r.ParseForm()
@@ -60,30 +66,30 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
knownHosts := r.FormValue("known_hosts")
if host == "" || user == "" || repoPath == "" {
offboxRedirect(w, r, "A cél címe, a felhasználó és a tárhely útvonala kötelező.", true)
offboxRedirect(w, r, "flash.offbox.fields_required", true)
return
}
if !strings.HasPrefix(repoPath, "/") {
offboxRedirect(w, r, "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", true)
offboxRedirect(w, r, "flash.offbox.path_absolute", true)
return
}
// Validate BEFORE persisting — host/user/repo flow into the ssh command restic runs; reject anything
// that could inject an ssh option (leading '-') or a metacharacter (the security boundary).
if err := backup.ValidateOffboxTarget(&settings.OffboxTarget{Host: host, User: user, RepoPath: repoPath, Port: port}); err != nil {
offboxRedirect(w, r, "Érvénytelen beállítás: "+err.Error(), true)
offboxRedirect(w, r, "flash.offbox.config_invalid", true, err.Error())
return
}
// First-time config requires the SSH key + a pinned known-host line (no blind TOFU).
existing := s.backupMgr.OffboxConfigured()
if !existing && (strings.TrimSpace(sshKey) == "" || strings.TrimSpace(knownHosts) == "") {
offboxRedirect(w, r, "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.", true)
offboxRedirect(w, r, "flash.offbox.first_setup_needs_key", true)
return
}
// Write secrets out-of-band (0600 key/pw, 0644 known_hosts); never logged.
if err := s.backupMgr.WriteOffboxSecrets(sshKey, knownHosts); err != nil {
s.logger.Printf("[ERROR] [web] offbox secrets: %v", err)
offboxRedirect(w, r, "A hitelesítő adatok mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.creds_save_failed", true)
return
}
@@ -110,26 +116,30 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
// it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already
// escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push
// failure does NOT mark escrowed; it is surfaced (the run gate still protects data).
stageErr := ""
stageKey := ""
if tgt.Enabled {
if tgt.EscrowState != "escrowed" {
tgt.EscrowState = "pending"
}
if client, cerr := s.agentClient(); cerr != nil {
stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra."
stageKey = "flash.offbox.target_saved_escrow_agent_down"
s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr)
} else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil {
stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra."
stageKey = "flash.offbox.target_saved_escrow_failed"
s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret
}
}
if err := s.settings.SetOffboxTarget(tgt); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.save_failed", true)
return
}
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "")
savedKey := "flash.offbox.target_saved"
if stageKey != "" {
savedKey = stageKey
}
offboxRedirect(w, r, savedKey, stageKey != "")
}
// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4).
@@ -139,14 +149,14 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
// no restic_pw_sha256 and can never auto-confirm; the operator vouches by hand after a verified ceremony.
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
offboxRedirect(w, r, "flash.offbox.target_not_set", true)
return
}
if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.EscrowState = "escrowed"
o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
}); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.save_failed", true)
return
}
s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled")
@@ -156,7 +166,7 @@ func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Reque
if err := s.wipeStagedEscrow(r.Context()); err != nil {
s.logger.Printf("[ERROR] [web] escrow confirmed but the agent-staged secret was NOT wiped (re-confirm to retry): %v", err)
}
offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.", false)
offboxRedirect(w, r, "flash.offbox.escrow_confirmed", false)
}
// wipeStagedEscrow calls the injected seam (tests), else the agent's DELETE /escrow/stage-secret over the
@@ -179,22 +189,22 @@ func (s *Server) wipeStagedEscrow(ctx context.Context) error {
// is never logged. Body: {password, force?}.
func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
offboxRedirect(w, r, "flash.offbox.mgr_unavailable", true)
return
}
_ = r.ParseForm()
pw := r.FormValue("password")
force := r.FormValue("force") == "on" || r.FormValue("force") == "true"
if strings.TrimSpace(pw) == "" {
offboxRedirect(w, r, "A repo jelszó kötelező.", true)
offboxRedirect(w, r, "flash.offbox.repo_pw_required", true)
return
}
if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil {
offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true)
offboxRedirect(w, r, "flash.offbox.repo_pw_failed", true, err.Error())
return
}
s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force)
offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false)
offboxRedirect(w, r, "flash.offbox.repo_pw_set", false)
}
// offboxToggleHandler flips an app's off-box inclusion.
@@ -203,32 +213,32 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
app := strings.TrimSpace(r.FormValue("app"))
on := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
if app == "" {
offboxRedirect(w, r, "Hiányzó alkalmazás.", true)
offboxRedirect(w, r, "flash.offbox.app_missing", true)
return
}
if err := s.settings.SetAppOffbox(app, on); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
offboxRedirect(w, r, "flash.offbox.save_failed", true)
return
}
s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false)
offboxRedirect(w, r, "flash.offbox.app_setting_updated", false)
}
// offboxRunHandler triggers an off-box backup now (async — it can run for minutes).
func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
offboxRedirect(w, r, "flash.offbox.target_not_set", true)
return
}
// fork-4 atomicity: refuse the run until the repo password is escrowed under R.
if !s.backupMgr.OffboxRunnable() {
offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true)
offboxRedirect(w, r, "flash.offbox.waiting_for_escrow", true)
return
}
// Offsite-repo continuity (v0.142.0): an ORPHANED repo can't be written — route the customer to the
// orphan card's explanation/reset instead of attempting a doomed write.
if s.backupMgr.OffboxOrphaned() {
offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
offboxRedirect(w, r, "flash.offbox.repo_orphaned", true)
return
}
// R-234: the single-flight decision is taken SYNCHRONOUSLY, before the goroutine, so the customer
@@ -239,7 +249,7 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
// question is about. (The documented "use RestoreStatus for display" trap is a different question.)
if s.backupMgr.IsRunning() {
s.logger.Printf("[INFO] [web] manual off-box backup NOT started for this request: a run is already in flight")
offboxRedirect(w, r, "Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.", true)
offboxRedirect(w, r, "flash.offbox.run_already_going", true)
return
}
go func() {
@@ -256,7 +266,7 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
}
}()
offboxRedirect(w, r, "A távoli mentés elindult — az állapot itt frissül.", false)
offboxRedirect(w, r, "flash.offbox.run_started", false)
}
// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
@@ -264,15 +274,15 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
// explicitly confirmed (confirm=1, set by the reveal-then-confirm block on the orphan card).
func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
offboxRedirect(w, r, "flash.offbox.target_not_set", true)
return
}
if !s.backupMgr.OffboxOrphaned() {
offboxRedirect(w, r, "Az offsite tároló nincs elárvult állapotban.", true)
offboxRedirect(w, r, "flash.offbox.not_orphaned", true)
return
}
if r.FormValue("confirm") != "1" {
offboxRedirect(w, r, "A visszaállításhoz megerősítés szükséges.", true)
offboxRedirect(w, r, "flash.offbox.needs_confirmation", true)
return
}
go func() {
@@ -282,7 +292,7 @@ func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] offbox orphaned-repo reset failed: %v", err)
}
}()
offboxRedirect(w, r, "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", false)
offboxRedirect(w, r, "flash.offbox.restart_started", false)
}
// offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it
@@ -314,13 +324,13 @@ func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
// with a reveal cue; the revealed confirm POSTs mode=full&confirm=1, re-checked at execution).
func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.app_missing", true)
return
}
mode := strings.TrimSpace(r.FormValue("mode"))
@@ -377,7 +387,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
where := s.backupMgr.OffsiteRestoreScratchPath(app)
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.restore_started", false)
}
// restoreScratchOutcomeMsg builds the OUTCOME flash for a completed scratch restore. Pure, so the
@@ -420,18 +430,18 @@ func restoreScratchOutcomeMsg(app, where string, full bool) string {
// because that is the only part the customer can check against what they see in the app.
func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.app_missing", true)
return
}
// This one overwrites live files and replays a database — it must never happen on a stray click.
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_needs_confirmation", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -465,7 +475,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
}
// offsiteScratchIncompleteMsg (R-358) is the server-side refusal shown when a place or reconstitute is
@@ -535,17 +545,17 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
// It now refuses while ANY backup or restore op is running.
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true)
return
}
_ = r.ParseForm()
stack := strings.TrimSpace(r.FormValue("stack"))
if stack == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó ellenőrző másolat.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.scratch_missing", true)
return
}
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.delete_needs_confirmation", true)
return
}
// R-360: `restoreOpBlocked()` and not `IsRunning()`. No app-name comparison is added deliberately:
@@ -559,24 +569,24 @@ func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Re
}
if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
s.logger.Printf("[WARN] [web] verification-copy delete %s: %v", stack, err)
offboxRedirectTo(w, r, "/backups/restore", "A másolat törlése nem sikerült: "+err.Error(), true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.delete_failed", true, err.Error())
return
}
s.logger.Printf("[INFO] [web] verification copy deleted: %s", stack)
offboxRedirectTo(w, r, "/backups/restore", "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.", false)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.scratch_deleted", false)
}
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.app_missing", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -603,7 +613,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "A helyreállítás elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.recover_started", false)
}
// --- R-7b: „Megosztások" restore ------------------------------------------------------------------
@@ -617,7 +627,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action.
func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -636,14 +646,14 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[INFO] [web] shares restore completed (async)")
s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
}()
offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_restore_started", false)
}
// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the
// missing definitions and restores the household credential (POST /backup/shares/place).
func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.target_not_set", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
@@ -664,5 +674,5 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
res.FilesRestored, len(res.DefinitionsAdded))
s.backupMgr.EndRestoreOp(true, res.FlashMessage())
}()
offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false)
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_recover_started", false)
}
@@ -55,14 +55,14 @@ func TestOffboxRunHandler_InFlightRequestIsNotReportedAsStarted(t *testing.T) {
t.Fatalf("the handler redirects; got %d", w.Code)
}
loc := w.Header().Get("Location")
if strings.Contains(loc, "elind") {
if strings.Contains(flashSentence(t, loc), "elind") {
t.Errorf("a dropped request must NOT be reported as started — that is the defect. Location: %q", loc)
}
if !strings.Contains(loc, "flash_error") {
t.Errorf("it must reach the customer as a problem, not a success flash. Location: %q", loc)
}
// It must also say the visible result belongs to the EARLIER run — that is what was misread.
if !strings.Contains(loc, "kor%C3%A1bbi") {
if !strings.Contains(flashSentence(t, loc), "korábbi") {
t.Errorf("the message must say the shown result is the earlier run's. Location: %q", loc)
}
if !strings.Contains(logbuf.String(), "NOT started") {
@@ -19,35 +19,39 @@ import (
const staleZeroToggleWarning = "Sikeres — nincs mentésre jelölt alkalmazás volt a futáskor."
func TestOffboxWarningDisplay_Pick(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
// warning + ≥1 enabled → the replacement line
if got := offboxWarningDisplay(staleZeroToggleWarning, "", 1); got != offboxSelectionChangedLine {
if got := s.offboxWarningDisplay(staleZeroToggleWarning, "", 1, "hu"); got != selectionChanged {
t.Errorf("stale warning + 1 toggled: got %q, want the selection-changed line", got)
}
// warning + 0 enabled → the original line, verbatim (the v0.123.0 honesty stays)
if got := offboxWarningDisplay(staleZeroToggleWarning, "", 0); got != staleZeroToggleWarning {
if got := s.offboxWarningDisplay(staleZeroToggleWarning, "", 0, "hu"); got != staleZeroToggleWarning {
t.Errorf("stale warning + 0 toggled: got %q, want the original warning unchanged", got)
}
// any OTHER warning passes through regardless of toggles (quota, partial failure)
quota := "A tároló a keret 84%-át használja (42/50 GB)."
if got := offboxWarningDisplay(quota, "", 3); got != quota {
if got := s.offboxWarningDisplay(quota, "", 3, "hu"); got != quota {
t.Errorf("non-stale warning must pass through verbatim, got %q", got)
}
// no warning → no line
if got := offboxWarningDisplay("", "", 2); got != "" {
if got := s.offboxWarningDisplay("", "", 2, "hu"); got != "" {
t.Errorf("empty warning must stay empty, got %q", got)
}
}
func TestOffboxWarningDisplay_RemotePageRender(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
// ≥1 toggled: the page shows the replacement, NOT the stale line.
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: staleZeroToggleWarning,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(staleZeroToggleWarning, "", 1)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(staleZeroToggleWarning, "", 1, "hu")
html := renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, offboxSelectionChangedLine) {
if !strings.Contains(html, selectionChanged) {
t.Error("selection-changed note missing with 1 app toggled")
}
if strings.Contains(html, staleZeroToggleWarning) {
@@ -62,12 +66,12 @@ func TestOffboxWarningDisplay_RemotePageRender(t *testing.T) {
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: staleZeroToggleWarning,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(staleZeroToggleWarning, "", 0)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(staleZeroToggleWarning, "", 0, "hu")
html = renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, staleZeroToggleWarning) {
t.Error("0 toggled: the original run warning must render unchanged")
}
if strings.Contains(html, offboxSelectionChangedLine) {
if strings.Contains(html, selectionChanged) {
t.Error("0 toggled: the selection-changed note must NOT render")
}
if !strings.Contains(html, "Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.") {
@@ -392,7 +392,7 @@ func TestR103_UnitRestoreHandlerGuards(t *testing.T) {
}
}
rec := postTier2UnitRestore(t, s, "docmost")
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
if loc := rec.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "Mentés nincs beállítva") {
t.Errorf("nil backupMgr: redirect = %q", loc)
}
}
@@ -104,7 +104,7 @@ func TestR487_BuildAppBackupRowsAppendsRemovedApps(t *testing.T) {
if err := os.WriteFile(backup.UnitManifestFile(unit), []byte(`{"schema_version":2,"app_name":"gone-app","display_name":"Gone","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
t.Fatal(err)
}
rows := s.buildAppBackupRows(&backup.FullBackupStatus{})
rows := s.buildAppBackupRows(&backup.FullBackupStatus{}, "hu")
if len(rows) != 1 || rows[0].StackName != "gone-app" || !rows[0].Removed || rows[0].DisplayName != "Gone" || rows[0].RemovedUnitTime == "" {
t.Fatalf("want one removed row for gone-app, got %+v", rows)
}
@@ -44,7 +44,7 @@ func TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold(t *testing.T) {
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "nextcloud", DisplayName: "Nextcloud", HasHDDData: true, HasVolumeData: true},
{StackName: "privatebin", DisplayName: "PrivateBin", HasVolumeData: true},
}})
}}, "hu")
nc := findRow(rows, "nextcloud")
if nc == nil {
@@ -43,9 +43,9 @@ func TestR553_DiskWarningPlacementSurvivesWordingChange(t *testing.T) {
cfg.Hub.Enabled = false
am.Refresh(hr, cfg, nil, false, "")
var found *Alert
for i, a := range am.GetAlerts() {
for i, a := range am.GetAlerts("hu") {
if a.Message == c.text {
found = &am.GetAlerts()[i]
found = &am.GetAlerts("hu")[i]
}
}
if found == nil {
@@ -19,6 +19,10 @@ import (
// RED-PROOF (REPORT): make offboxWarningDisplay decide by the marker again (drop the kind arm) and
// the TRANSLATED row fails: the stale sentence is shown verbatim.
func TestR553_StaleNoteDecisionSurvivesWordingChange(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
// The replacement note is read from the bundle (v0.252.0, R-557): the same sentence the constant
// used to hold, now measured against hu.json rather than restated beside it.
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
const hu = "Sikeres — nincs mentésre jelölt alkalmazás"
const en = "Success — no app is selected for backup"
cases := []struct {
@@ -26,18 +30,18 @@ func TestR553_StaleNoteDecisionSurvivesWordingChange(t *testing.T) {
toggled int
want string
}{
{"as shipped, apps now selected", hu, backup.OffboxWarnNoAppsSelected, 1, offboxSelectionChangedLine},
{"TRANSLATED, apps now selected", en, backup.OffboxWarnNoAppsSelected, 2, offboxSelectionChangedLine},
{"as shipped, apps now selected", hu, backup.OffboxWarnNoAppsSelected, 1, selectionChanged},
{"TRANSLATED, apps now selected", en, backup.OffboxWarnNoAppsSelected, 2, selectionChanged},
{"still nothing selected → the honest note stays", hu, backup.OffboxWarnNoAppsSelected, 0, hu},
{"a quota note passes through", "A tároló a keret 84%-át használja (42/50 GB).", "", 3,
"A tároló a keret 84%-át használja (42/50 GB)."},
// LEGACY: a box upgraded to v0.251.0 still carries the OLD text with no kind until its next
// off-site run rewrites it. Until then the text test is what it has.
{"legacy persisted text, no kind", hu, "", 1, offboxSelectionChangedLine},
{"legacy persisted text, no kind", hu, "", 1, selectionChanged},
{"legacy: any other text without a kind is shown verbatim", "Valami más történt.", "", 1, "Valami más történt."},
}
for _, c := range cases {
if got := offboxWarningDisplay(c.warning, c.kind, c.toggled); got != c.want {
if got := s.offboxWarningDisplay(c.warning, c.kind, c.toggled, "hu"); got != c.want {
t.Errorf("%s:\n got %q\nwant %q", c.name, got, c.want)
}
}
@@ -46,15 +50,17 @@ func TestR553_StaleNoteDecisionSurvivesWordingChange(t *testing.T) {
// The consequence on the page itself: with a TRANSLATED warning that carries the kind, the rendered
// Távoli mentés page shows the selection-changed note and not the stale sentence.
func TestR553_StaleNoteOnTheRenderedPage(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
selectionChanged := s.msgLang("hu", offboxSelectionChangedKey)
const en = "Success — no app is selected for backup"
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: en, LastWarningKind: backup.OffboxWarnNoAppsSelected,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(en, backup.OffboxWarnNoAppsSelected, 1)
data["OffboxWarningDisplay"] = s.offboxWarningDisplay(en, backup.OffboxWarnNoAppsSelected, 1, "hu")
html := renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, offboxSelectionChangedLine) {
if !strings.Contains(html, selectionChanged) {
t.Error("the selection-changed note is missing for a translated warning that carries its kind")
}
if strings.Contains(html, en) {
+1 -1
View File
@@ -495,7 +495,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
case path == "/claim" && r.Method == http.MethodGet:
s.handleClaimPage(w, r, "", r.URL.Query().Get("flash"))
s.handleClaimPage(w, r, "", s.flashFrom(r, "flash"))
case path == "/claim" && r.Method == http.MethodPost:
s.handleClaimSubmit(w, r)
case path == "/claim/request-new-code" && r.Method == http.MethodPost:
+25 -20
View File
@@ -171,9 +171,14 @@ func (s *Server) renderSharePasswordPage(w http.ResponseWriter, r *http.Request,
// ── admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect) ─────────
// launcherShareRedirect returns to /launcher with a Hungarian flash (reuses urlQueryEscape).
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, flash string) {
http.Redirect(w, r, "/launcher?flash="+urlQueryEscape(flash), http.StatusSeeOther)
// launcherShareRedirect returns to /launcher with a flash.
//
// `key` is a bundle KEY, not a sentence (v0.252.0, R-557): the redirect is read by a DIFFERENT
// request, and only that request knows the household's language. The launcher resolves it with
// s.flashText, which shows a sentence minted by an older controller verbatim — so a link already in
// a customer's tab still reads correctly.
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, key string) {
http.Redirect(w, r, "/launcher?"+flashQuery("flash", key), http.StatusSeeOther)
}
// launcherShareQRHandler serves the share link as a ~256px PNG QR code (admin-authed; not exempted, so
@@ -199,44 +204,44 @@ func (s *Server) launcherShareQRHandler(w http.ResponseWriter, r *http.Request)
// launcherShareEnableHandler mints the first token (POST /launcher/share/enable).
func (s *Server) launcherShareEnableHandler(w http.ResponseWriter, r *http.Request) {
if s.settings.GetLauncherShareToken() != "" {
s.launcherShareRedirect(w, r, "A megosztás már be van kapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.already_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link enabled")
s.launcherShareRedirect(w, r, "A megosztás bekapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.enabled")
}
// launcherShareRotateHandler mints a fresh token (POST /launcher/share/rotate). The old link 404s and
// every outstanding guest cookie is invalidated (both are bound to the token).
func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Request) {
if s.settings.GetLauncherShareToken() == "" {
s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.not_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link rotated")
s.launcherShareRedirect(w, r, "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.")
s.launcherShareRedirect(w, r, "flash.share.relinked")
}
// launcherShareDisableHandler clears the token AND the share password (POST /launcher/share/disable) —
@@ -244,14 +249,14 @@ func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Reque
func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Request) {
if err := s.settings.SetLauncherShareToken(""); err != nil {
s.logger.Printf("[ERROR] [web] share: clearing token failed: %v", err)
s.launcherShareRedirect(w, r, "A megosztás kikapcsolása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.disable_failed")
return
}
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
s.logger.Printf("[WARN] [web] share: clearing share password on disable failed: %v", err)
}
s.logger.Printf("[INFO] [web] launcher share link disabled")
s.launcherShareRedirect(w, r, "A megosztás kikapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.disabled")
}
// launcherSharePasswordHandler sets or clears the OPTIONAL per-share password (POST
@@ -260,35 +265,35 @@ func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Requ
func (s *Server) launcherSharePasswordHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.settings.GetLauncherShareToken() == "" {
s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.")
s.launcherShareRedirect(w, r, "flash.share.not_on")
return
}
if r.FormValue("action") == "clear" {
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
s.logger.Printf("[ERROR] [web] share: clearing share password failed: %v", err)
s.launcherShareRedirect(w, r, "A jelszó törlése nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.pw_clear_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password cleared")
s.launcherShareRedirect(w, r, "A megosztási jelszó törölve.")
s.launcherShareRedirect(w, r, "flash.share.pw_cleared")
return
}
pw := r.FormValue("password")
if len(pw) < shareMinPassword {
s.launcherShareRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
s.launcherShareRedirect(w, r, "flash.share.pw_too_short")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
if err != nil {
s.logger.Printf("[ERROR] [web] share: hashing share password failed: %v", err)
s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.pw_set_failed")
return
}
if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil {
s.logger.Printf("[ERROR] [web] share: saving share password failed: %v", err)
s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.")
s.launcherShareRedirect(w, r, "flash.share.pw_set_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password set")
s.launcherShareRedirect(w, r, "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.")
s.launcherShareRedirect(w, r, "flash.share.pw_set")
}
+17 -17
View File
@@ -238,7 +238,7 @@ func (s *Server) guestNetSnapshot() stacks.GuestNetSnapshot {
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
data := s.sharingPageData()
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
if f := s.flashFrom(r, "flash"); f != "" {
data["Flash"] = f
}
s.executeTemplate(w, r, "sharing", data)
@@ -246,7 +246,7 @@ func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
// sharingRedirect returns to the page with a Hungarian flash.
func sharingRedirect(w http.ResponseWriter, r *http.Request, flash string) {
http.Redirect(w, r, "/sharing?flash="+urlQueryEscape(flash), http.StatusSeeOther)
http.Redirect(w, r, "/sharing?"+flashQuery("flash", flash), http.StatusSeeOther)
}
func urlQueryEscape(s string) string {
@@ -265,19 +265,19 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.settings.SetSMBServerName(name); err != nil {
sharingRedirect(w, r, "A mentés nem sikerült.")
sharingRedirect(w, r, "flash.sharing.save_failed")
return
}
}
if err := s.settings.SetSMBEnabled(enable); err != nil {
sharingRedirect(w, r, "A mentés nem sikerült.")
sharingRedirect(w, r, "flash.sharing.save_failed")
return
}
if !enable {
if err := s.stackMgr.DisableSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] disable failed: %v", err)
}
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
sharingRedirect(w, r, "flash.sharing.disabled")
return
}
// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
@@ -292,10 +292,10 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
// regardless of whether anything actually came up.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "A megosztási szolgáltatás előkészítése már folyamatban van.")
sharingRedirect(w, r, "flash.sharing.prepare_running")
return
}
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
sharingRedirect(w, r, "flash.sharing.saved_preparing")
}
// sharingStatusHandler is the 4b poll target (GET /sharing/status). It carries TWO independent
@@ -333,26 +333,26 @@ func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request)
pw := r.FormValue("smb_password")
pw2 := r.FormValue("smb_password_confirm")
if len(pw) < 8 {
sharingRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
sharingRedirect(w, r, "flash.sharing.pw_too_short")
return
}
if pw != pw2 {
sharingRedirect(w, r, "A két jelszó nem egyezik.")
sharingRedirect(w, r, "flash.sharing.pw_mismatch")
return
}
if err := s.stackMgr.SetSMBPassword(pw); err != nil {
s.logger.Printf("[ERROR] [sharing] password apply failed: %v", err)
sharingRedirect(w, r, "A jelszó beállítása nem sikerült.")
sharingRedirect(w, r, "flash.sharing.pw_set_failed")
return
}
// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
// where the image pull happens. Same detached job, same card.
if !s.startSambaEnsure() {
sharingRedirect(w, r, "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.")
sharingRedirect(w, r, "flash.sharing.pw_set_preparing_already")
return
}
sharingRedirect(w, r, "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…")
sharingRedirect(w, r, "flash.sharing.pw_set_preparing")
}
// sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under
@@ -385,7 +385,7 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
}
if err := os.MkdirAll(dir, 0o775); err != nil {
s.logger.Printf("[ERROR] [sharing] mkdir failed: %v", err)
sharingRedirect(w, r, "A mappa létrehozása nem sikerült.")
sharingRedirect(w, r, "flash.sharing.folder_failed")
return
}
// uid:gid 1000 so apps and both backup tiers see the same ownership as SMB writes.
@@ -419,7 +419,7 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after share create failed: %v", err)
}
sharingRedirect(w, r, "A megosztás létrehozva.")
sharingRedirect(w, r, "flash.sharing.created")
}
// sharingShareDeleteHandler removes a share (POST /sharing/shares/delete). CONFIG ONLY — by
@@ -432,7 +432,7 @@ func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Reques
// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
for _, sh := range s.settings.GetSMBShares() {
if strings.EqualFold(sh.Name, name) && sh.System {
sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.")
sharingRedirect(w, r, "flash.sharing.protected")
return
}
}
@@ -443,7 +443,7 @@ func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Reques
if err := s.stackMgr.ReconcileSamba(); err != nil {
s.logger.Printf("[WARN] [sharing] reconcile after share delete failed: %v", err)
}
sharingRedirect(w, r, "A megosztás törölve — a mappa és a fájlok megmaradtak.")
sharingRedirect(w, r, "flash.sharing.deleted")
}
// sharingShareOffsiteHandler flips a share's „Felhőmentés" toggle (POST /sharing/shares/offsite).
@@ -455,7 +455,7 @@ func (s *Server) sharingShareOffsiteHandler(w http.ResponseWriter, r *http.Reque
sharingRedirect(w, r, err.Error())
return
}
sharingRedirect(w, r, "Beállítás mentve.")
sharingRedirect(w, r, "flash.sharing.saved")
}
// ServeSharingAPI dispatches the /api/sharing/* XHR endpoints. Registered on the mux in main.go
@@ -41,7 +41,7 @@ func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) {
t.Fatal(err)
}
wantDate, wantStale := cov.UnitRestoreDate()
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "app", DisplayName: "App"}}})
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "app", DisplayName: "App"}}}, "hu")
row := findRow(rows, "app")
if row == nil {
t.Fatal("no row")
+26 -26
View File
@@ -420,7 +420,7 @@ func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
if s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Source)) || s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Target)) {
@@ -441,7 +441,7 @@ func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request)
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
// R-108: the TARGET may not be network storage. Its whole-namespace sibling
@@ -477,12 +477,12 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
MountName string `json:"mount_name"` // type-to-confirm for mode=anyway
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
if s.refuseNetworkLifecycle(w, req.Where) {
@@ -492,7 +492,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
switch req.Mode {
case "migrate":
if strings.TrimSpace(req.Target) == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "céltároló kötelező az áthelyezéshez", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.target_required"), nil)
return
}
// R-108: the refuseNetworkLifecycle above guards `req.Where` — the SOURCE. The TARGET was
@@ -515,7 +515,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
case "anyway":
// Type-to-confirm: the typed name must match the mount basename exactly (mirrors wipe).
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil)
return
}
// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the
@@ -545,7 +545,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})
default:
writeDiskJSON(w, http.StatusBadRequest, false, "ismeretlen mód (migrate vagy anyway)", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.unknown_mode"), nil)
}
}
@@ -624,7 +624,7 @@ type storageProvReq struct {
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
where, err := mountWhere(req.MountName)
@@ -633,7 +633,7 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
return
}
agent, err := s.agentClient()
@@ -649,7 +649,7 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
setDefault: req.SetDefault, confirmed: req.Confirmed, durableID: req.DurableID,
})
if !started {
writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy meghajtó-inicializálás", nil)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.init_in_progress"), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "phase": storageInitPhaseFormatting})
@@ -675,7 +675,7 @@ func (s *Server) handleStorageInitStatus(w http.ResponseWriter, r *http.Request)
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
apps := s.appsUsingPath(where)
@@ -734,16 +734,16 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
FSType string `json:"fstype"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
if s.refuseNetworkLifecycle(w, req.Where) {
@@ -751,7 +751,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
}
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil)
return
}
fstype := req.FSType
@@ -778,12 +778,12 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
if errors.Is(perr, agentapi.ErrFormatRefused) {
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.protected"), probe)
return
}
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
if perr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", perr.Error()), nil)
return
}
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
@@ -792,7 +792,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
}
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
if ferr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", ferr.Error()), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
@@ -838,12 +838,12 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
// req.Where is the RAW /mnt/<name> host mount the agent reports; in the intermediary model the drive
@@ -888,7 +888,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
want := path.Clean(strings.TrimSpace(req.Path))
@@ -903,7 +903,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
if match == nil {
// Names a reason the customer can act on, and a route — never a bare refusal.
writeDiskJSON(w, http.StatusBadRequest, false,
"Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát.", nil)
s.msg(r, "disk.err.attach_unavailable"), nil)
return
}
if err := s.registerStoragePath(match.Path, req.Label, req.SetDefault); err != nil {
@@ -918,7 +918,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
where, err := mountWhere(req.MountName)
@@ -927,7 +927,7 @@ func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.device_required"), nil)
return
}
agent, err := s.agentClient()
@@ -950,12 +950,12 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
Where string `json:"where"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_request"), nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.bad_mountpoint"), nil)
return
}
if s.refuseNetworkLifecycle(w, req.Where) {
@@ -34,10 +34,10 @@ func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request,
data["StackName"] = name
data["DisplayName"] = stack.Meta.DisplayName
data["Tier2"] = info
if flash := r.URL.Query().Get("flash"); flash != "" {
if flash := s.flashFrom(r, "flash"); flash != "" {
data["Flash"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
s.executeTemplate(w, r, "tier2_config", data)
@@ -69,14 +69,14 @@ func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request,
}
}
if !valid {
s.redirectTier2(w, r, name, "", "A választott cél meghajtó nem érvényes.")
s.redirectTier2(w, r, name, "", "flash.tier2.target_invalid")
return
}
}
if err := s.settings.SetTier2Preference(name, !enabled, target); err != nil {
s.logger.Printf("[ERROR] [web] save Tier 2 preference for %s: %v", name, err)
s.redirectTier2(w, r, name, "", "A beállítás mentése nem sikerült.")
s.redirectTier2(w, r, name, "", "flash.tier2.save_failed")
return
}
s.logger.Printf("[INFO] [web] Tier 2 preference saved for %s: enabled=%v target=%q", name, enabled, target)
@@ -90,7 +90,7 @@ func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request,
}()
}
s.redirectTier2(w, r, name, "A 2. mentés beállítása elmentve.", "")
s.redirectTier2(w, r, name, "flash.tier2.saved", "")
}
// appEmailToggleHandler flips the per-app email toggle (only for apps with an smtp_mapping)
@@ -111,11 +111,11 @@ func (s *Server) appEmailToggleHandler(w http.ResponseWriter, r *http.Request, n
return
}
s.logger.Printf("[INFO] [web] App-email for %s set to %v", name, enabled)
msg := "Email-küldés kikapcsolva ennél az alkalmazásnál."
msg := "flash.tier2.app_email_off"
if enabled {
msg = "Email-küldés bekapcsolva ennél az alkalmazásnál."
msg = "flash.tier2.app_email_on"
}
http.Redirect(w, r, dest+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
http.Redirect(w, r, dest+"?"+flashQuery("flash", msg), http.StatusSeeOther)
}
// redirectTier2 sends the customer back to the panel with a flash message.
@@ -44,7 +44,7 @@ func TestTier2RestoreHandler_Guards(t *testing.T) {
// Valid name but no backup manager → the not-configured flash (still no panic, no work).
rec := postTier2Restore(t, s, "nextcloud")
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
if loc := rec.Header().Get("Location"); !strings.Contains(flashSentence(t, loc), "Mentés nincs beállítva") {
t.Errorf("nil backupMgr: redirect = %q", loc)
}
}
+4
View File
@@ -89,6 +89,10 @@ GATES = [
# v0.247.0 — the i18n bundles: keys exist, no orphans, the English gap and the formal-form debt
# only shrink (ratchets), no pleading English. Fast: stdlib file reads.
("i18n", os.path.join(SCRIPTS, "i18n_missing_gate.py"), [], True, True),
# R-557 slice 2 — a Go-side message key may only carry text that existed at the base commit,
# byte for byte. The template half of parity is proved by rendered fixtures; Go-side copy cannot
# be, so it is proved structurally here. Fast: stdlib file reads.
("go-parity", os.path.join(SCRIPTS, "i18n_go_parity.py"), [], True, True),
# R-404 — ADVISORY. Reports the golden debt where it is created; never refuses.
("golden-notice", GOLDEN_NOTICE, [REPO], True, False),
]
File diff suppressed because one or more lines are too long
+507
View File
@@ -0,0 +1,507 @@
{
"_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.",
"_preexisting": {
"func.state.running": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.starting": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.deploying": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.unhealthy": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.degraded": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.stopped": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.restarting": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.not_deployed": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.paused": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.state.unknown": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.now": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.minutes_ago": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.hours_ago": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.days_ago": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.yesterday": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.today_at": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"func.time.tomorrow_at": "slice 0 -- pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
"page.title.backups": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.backups_apps": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.backups_remote": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.backups_restore": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.dashboard": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.escrow": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.launcher": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.monitoring": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.settings": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.settings_notifications": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.settings_security": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.sharing": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.stacks": "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_init": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle",
"page.title.storage_network": "slice 1 -- pinned by TestHandlerTitleKeysMatchHungarianTitle"
},
"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.enabled": "A megosztás bekapcsolva.",
"flash.share.not_on": "A megosztás nincs bekapcsolva.",
"flash.share.relink_failed": "Az új link készítése nem sikerült.",
"flash.share.relinked": "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.",
"flash.share.disable_failed": "A megosztás kikapcsolása nem sikerült.",
"flash.share.disabled": "A megosztás kikapcsolva.",
"flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.",
"flash.share.pw_cleared": "A megosztási jelszó törölve.",
"flash.share.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie.",
"flash.share.pw_set_failed": "A jelszó beállítása nem sikerült.",
"flash.share.pw_set": "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.",
"flash.restore.started": "Visszaállítás elindult — az állapot itt frissül.",
"flash.restore.file_started": "Fájl-visszaállítás elindult — az állapot itt frissül.",
"flash.restore.full_started": "Teljes visszaállítás elindult — az állapot itt frissül.",
"flash.login.password_changed": "Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.",
"flash.backup.window_invalid_time": "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).",
"flash.backup.window_save_failed": "A mentési időablak mentése nem sikerült.",
"flash.backup.window_updated": "Mentési időablak frissítve.",
"flash.sharing.save_failed": "A mentés nem sikerült.",
"flash.sharing.disabled": "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.",
"flash.sharing.prepare_running": "A megosztási szolgáltatás előkészítése már folyamatban van.",
"flash.sharing.saved_preparing": "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…",
"flash.sharing.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie.",
"flash.sharing.pw_mismatch": "A két jelszó nem egyezik.",
"flash.sharing.pw_set_failed": "A jelszó beállítása nem sikerült.",
"flash.sharing.pw_set_preparing_already": "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.",
"flash.sharing.pw_set_preparing": "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…",
"flash.sharing.folder_failed": "A mappa létrehozása nem sikerült.",
"flash.sharing.created": "A megosztás létrehozva.",
"flash.sharing.protected": "Ez a megosztás a rendszer része, nem törölhető.",
"flash.sharing.deleted": "A megosztás törölve — a mappa és a fájlok megmaradtak.",
"flash.sharing.saved": "Beállítás mentve.",
"flash.tier2.target_invalid": "A választott cél meghajtó nem érvényes.",
"flash.tier2.save_failed": "A beállítás mentése nem sikerült.",
"flash.tier2.saved": "A 2. mentés beállítása elmentve.",
"flash.tier2.app_email_off": "Email-küldés kikapcsolva ennél az alkalmazásnál.",
"flash.tier2.app_email_on": "Email-küldés bekapcsolva ennél az alkalmazásnál.",
"flash.offbox.mgr_unavailable": "A mentéskezelő nem elérhető.",
"flash.offbox.fields_required": "A cél címe, a felhasználó és a tárhely útvonala kötelező.",
"flash.offbox.path_absolute": "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).",
"flash.offbox.config_invalid": [
"Érvénytelen beállítás: "
],
"flash.offbox.first_setup_needs_key": "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.",
"flash.offbox.creds_save_failed": "A hitelesítő adatok mentése sikertelen.",
"flash.offbox.save_failed": "A beállítás mentése sikertelen.",
"flash.offbox.target_saved": "A távoli mentési cél elmentve.",
"flash.offbox.target_saved_escrow_agent_down": [
"A távoli mentési cél elmentve.",
" — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra."
],
"flash.offbox.target_saved_escrow_failed": [
"A távoli mentési cél elmentve.",
" — a kulcs letéti előkészítése nem sikerült; próbáld újra."
],
"flash.offbox.target_not_set": "A távoli mentési cél nincs beállítva.",
"flash.offbox.escrow_confirmed": "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.",
"flash.offbox.repo_pw_required": "A repo jelszó kötelező.",
"flash.offbox.repo_pw_failed": [
"A jelszó beállítása sikertelen: "
],
"flash.offbox.repo_pw_set": "A helyreállított repo jelszó beállítva.",
"flash.offbox.app_missing": "Hiányzó alkalmazás.",
"flash.offbox.app_setting_updated": "A távoli mentés beállítása frissítve.",
"flash.offbox.waiting_for_escrow": "A távoli mentés a kulcs letétbe helyezésére vár.",
"flash.offbox.repo_orphaned": "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.",
"flash.offbox.run_started": "A távoli mentés elindult — az állapot itt frissül.",
"flash.offbox.not_orphaned": "Az offsite tároló nincs elárvult állapotban.",
"flash.offbox.needs_confirmation": "A visszaállításhoz megerősítés szükséges.",
"flash.offbox.restart_started": "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).",
"flash.offbox.restore_started": "A távoli visszaállítás elindult — az állapot itt frissül.",
"flash.offbox.full_needs_confirmation": "A teljes visszaállítás megerősítés nélkül nem hajtható végre.",
"flash.offbox.full_restore_started": "A teljes visszaállítás elindult — az állapot itt frissül.",
"flash.offbox.mgr_unreachable": "A mentéskezelő nem érhető el.",
"flash.offbox.scratch_missing": "Hiányzó ellenőrző másolat.",
"flash.offbox.delete_needs_confirmation": "A törlés megerősítés nélkül nem hajtható végre.",
"flash.offbox.delete_failed": [
"A másolat törlése nem sikerült: "
],
"flash.offbox.scratch_deleted": "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.",
"flash.offbox.recover_started": "A helyreállítás elindult — az állapot itt frissül.",
"flash.offbox.shares_restore_started": "A megosztások visszaállítása elindult — az állapot itt frissül.",
"flash.offbox.shares_recover_started": "A megosztások helyreállítása elindult — az állapot itt frissül.",
"flash.offbox.run_already_going": "Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.",
"page.title.logs": [
" — Naplók"
],
"page.title.deploy": [
" — Telepítés"
],
"page.title.app_settings": [
" — Beállítások"
],
"page.title.tier2_config": [
"2. mentés beállítása — "
],
"logs.fetch_failed": "Hiba a naplók lekérésekor: %v",
"storage.bar_purpose": "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak.",
"deploy.path_not_allowed": "hálózati tárhely — alkalmazáshoz nem választható",
"creds.filebrowser_note": "A Fájlkezelő belépése. A jelszót a Felhom állította be ezen a gépen.",
"ping.label.heartbeat": "Eletjel (Heartbeat)",
"ping.label.system_health": "Rendszer allapot",
"ping.label.db_dump": "Adatbazis mentes",
"ping.label.backup": "Biztonsagi mentes",
"ping.label.integrity": "Mentes integritas",
"ping.schedule.5min": "5 percenkent",
"ping.schedule.daily_at": [
"Naponta "
],
"offbox.selection_changed": "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza.",
"backup.contents.db": "Konfig",
"backup.contents.data": "Adatok",
"backup.status.ok": "Alkalmazás-adat mentés rendben",
"backup.status.db_failed": "Adatbázis mentés sikertelen",
"backup.tier2.no_drive": "Nincs 2. meghajtó",
"backup.tier2.badge_ok": "Sikeres",
"backup.tier2.badge_error": "Hiba",
"backup.tier2.badge_running": "Fut...",
"backup.tier2.schedule_daily": "Naponta",
"backup.removed_app": "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
"backup.tier2.dest_ssd": "belső SSD (csak DB/konfiguráció)",
"flash.backup.not_configured": {
"inside": "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva"
},
"escrow.no_retrieval_password": "Ezen a gépen nincs tárolt visszaállítási jelszó.",
"escrow.stack_mgr_unavailable": "Az alkalmazáskezelő nem elérhető.",
"escrow.unknown_app": "Ismeretlen alkalmazás.",
"escrow.missing_field": "Hiányzó mező.",
"escrow.field_not_revealable": "Ez a mező nem kérhető le.",
"escrow.app_settings_unreadable": "Az alkalmazás beállításai nem olvashatók.",
"escrow.no_stored_value": "Ehhez a mezőhöz nincs mentett érték.",
"escrow.initial_pw_read_failed": "A kezdeti jelszó beolvasása nem sikerült.",
"escrow.initial_pw_unavailable": "A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.",
"escrow.settings_unavailable": "A beállítások nem elérhetők.",
"escrow.filebrowser_not_ours": "A Fájlkezelő jelszavát nem a Felhom állította be ezen a gépen, ezért nem tudjuk megmutatni.",
"escrow.password_unreadable": "A jelszó most nem olvasható ki.",
"settings.pw_wrong_current": "Hibás jelenlegi jelszó",
"settings.pw_too_short": "A jelszónak legalább 8 karakter hosszúnak kell lennie",
"settings.pw_mismatch": "A két jelszó nem egyezik",
"settings.pw_save_error": "Belső hiba a jelszó mentésekor",
"settings.notify_email_required": "Adj meg egy értesítési e-mail címet – bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket.",
"settings.notify_save_error": "Hiba a beállítások mentésekor",
"settings.notify_saved_sync_failed": "Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v",
"settings.notify_saved": "Értesítési beállítások mentve.",
"settings.app_email_save_error": "Hiba az alkalmazás-email beállítás mentésekor",
"settings.app_email_shim_failed": "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült.",
"settings.app_email_on": "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél.",
"settings.app_email_off": "Alkalmazás-email kikapcsolva.",
"settings.notify_disabled": "Az értesítések nincsenek bekapcsolva",
"settings.test_email_failed": "Teszt email küldése sikertelen: %v",
"settings.test_email_sent": "Teszt email elküldve.",
"storage.err_path_missing": "Az útvonal nem létezik vagy nem mappa.",
"storage.err_not_separate": "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!",
"storage.err_not_writable": "Az útvonal nem írható.",
"storage.err_overlaps": "Az útvonal átfedi a már regisztrált %s útvonalat.",
"storage.err_save": "Hiba a mentés során.",
"storage.err_in_use": "Nem törölhető: az alábbi alkalmazások használják: %s",
"storage.err_default": "Az alapértelmezett adattároló nem törölhető.",
"storage.err_last": "Az utolsó adattároló nem törölhető.",
"storage.err_delete": "Hiba a törlés során.",
"storage.err_label": "A megnevezés nem lehet üres és legfeljebb 50 karakter.",
"storage.err_label_save": "Hiba a megnevezés mentésekor.",
"storage.msg_added": [
"Adattároló sikeresen hozzáadva: "
],
"storage.msg_removed": [
"Adattároló eltávolítva: "
],
"storage.msg_default_set": [
"Alapértelmezett adattároló beállítva: "
],
"storage.msg_state_changed": [
"Adattároló állapot módosítva: "
],
"storage.msg_label_changed": [
"Megnevezés módosítva: "
],
"country.AF": "Afganisztán",
"country.AL": "Albánia",
"country.DZ": "Algéria",
"country.AS": "Amerikai Szamoa",
"country.AD": "Andorra",
"country.AO": "Angola",
"country.AI": "Anguilla",
"country.AQ": "Antarktisz",
"country.AG": "Antigua és Barbuda",
"country.AR": "Argentína",
"country.AM": "Örményország",
"country.AW": "Aruba",
"country.AU": "Ausztrália",
"country.AT": "Ausztria",
"country.AZ": "Azerbajdzsán",
"country.BS": "Bahama-szigetek",
"country.BH": "Bahrein",
"country.BD": "Banglades",
"country.BB": "Barbados",
"country.BY": "Fehéroroszország",
"country.BE": "Belgium",
"country.BZ": "Belize",
"country.BJ": "Benin",
"country.BM": "Bermuda",
"country.BT": "Bhután",
"country.BO": "Bolívia",
"country.BA": "Bosznia-Hercegovina",
"country.BW": "Botswana",
"country.BR": "Brazília",
"country.BN": "Brunei",
"country.BG": "Bulgária",
"country.BF": "Burkina Faso",
"country.BI": "Burundi",
"country.CV": "Cabo Verde",
"country.KH": "Kambodzsa",
"country.CM": "Kamerun",
"country.CA": "Kanada",
"country.KY": "Kajmán-szigetek",
"country.CF": "Közép-afrikai Köztársaság",
"country.TD": "Csád",
"country.CL": "Chile",
"country.CN": "Kína",
"country.CO": "Kolumbia",
"country.KM": "Comore-szigetek",
"country.CG": "Kongó",
"country.CD": "Kongói Demokratikus Köztársaság",
"country.CK": "Cook-szigetek",
"country.CR": "Costa Rica",
"country.CI": "Elefántcsontpart",
"country.HR": "Horvátország",
"country.CU": "Kuba",
"country.CW": "Curaçao",
"country.CY": "Ciprus",
"country.CZ": "Csehország",
"country.DK": "Dánia",
"country.DJ": "Dzsibuti",
"country.DM": "Dominika",
"country.DO": "Dominikai Köztársaság",
"country.EC": "Ecuador",
"country.EG": "Egyiptom",
"country.SV": "Salvador",
"country.GQ": "Egyenlítői-Guinea",
"country.ER": "Eritrea",
"country.EE": "Észtország",
"country.SZ": "Eswatini",
"country.ET": "Etiópia",
"country.FK": "Falkland-szigetek",
"country.FO": "Feröer-szigetek",
"country.FJ": "Fidzsi-szigetek",
"country.FI": "Finnország",
"country.FR": "Franciaország",
"country.GF": "Francia Guyana",
"country.PF": "Francia Polinézia",
"country.GA": "Gabon",
"country.GM": "Gambia",
"country.GE": "Grúzia",
"country.DE": "Németország",
"country.GH": "Ghána",
"country.GI": "Gibraltár",
"country.GR": "Görögország",
"country.GL": "Grönland",
"country.GD": "Grenada",
"country.GP": "Guadeloupe",
"country.GU": "Guam",
"country.GT": "Guatemala",
"country.GG": "Guernsey",
"country.GN": "Guinea",
"country.GW": "Bissau-Guinea",
"country.GY": "Guyana",
"country.HT": "Haiti",
"country.HN": "Honduras",
"country.HK": "Hongkong",
"country.HU": "Magyarország",
"country.IS": "Izland",
"country.IN": "India",
"country.ID": "Indonézia",
"country.IR": "Irán",
"country.IQ": "Irak",
"country.IE": "Írország",
"country.IM": "Man-sziget",
"country.IL": "Izrael",
"country.IT": "Olaszország",
"country.JM": "Jamaica",
"country.JP": "Japán",
"country.JE": "Jersey",
"country.JO": "Jordánia",
"country.KZ": "Kazahsztán",
"country.KE": "Kenya",
"country.KI": "Kiribati",
"country.KP": "Észak-Korea",
"country.KR": "Dél-Korea",
"country.KW": "Kuvait",
"country.KG": "Kirgizisztán",
"country.LA": "Laosz",
"country.LV": "Lettország",
"country.LB": "Libanon",
"country.LS": "Lesotho",
"country.LR": "Libéria",
"country.LY": "Líbia",
"country.LI": "Liechtenstein",
"country.LT": "Litvánia",
"country.LU": "Luxemburg",
"country.MO": "Makaó",
"country.MG": "Madagaszkár",
"country.MW": "Malawi",
"country.MY": "Malajzia",
"country.MV": "Maldív-szigetek",
"country.ML": "Mali",
"country.MT": "Málta",
"country.MH": "Marshall-szigetek",
"country.MQ": "Martinique",
"country.MR": "Mauritánia",
"country.MU": "Mauritius",
"country.YT": "Mayotte",
"country.MX": "Mexikó",
"country.FM": "Mikronézia",
"country.MD": "Moldova",
"country.MC": "Monaco",
"country.MN": "Mongólia",
"country.ME": "Montenegró",
"country.MS": "Montserrat",
"country.MA": "Marokkó",
"country.MZ": "Mozambik",
"country.MM": "Mianmar",
"country.NA": "Namíbia",
"country.NR": "Nauru",
"country.NP": "Nepál",
"country.NL": "Hollandia",
"country.NC": "Új-Kaledónia",
"country.NZ": "Új-Zéland",
"country.NI": "Nicaragua",
"country.NE": "Niger",
"country.NG": "Nigéria",
"country.NU": "Niue",
"country.NF": "Norfolk-sziget",
"country.MK": "Észak-Macedónia",
"country.MP": "Északi-Mariana-szigetek",
"country.NO": "Norvégia",
"country.OM": "Omán",
"country.PK": "Pakisztán",
"country.PW": "Palau",
"country.PS": "Palesztina",
"country.PA": "Panama",
"country.PG": "Pápua Új-Guinea",
"country.PY": "Paraguay",
"country.PE": "Peru",
"country.PH": "Fülöp-szigetek",
"country.PL": "Lengyelország",
"country.PT": "Portugália",
"country.PR": "Puerto Rico",
"country.QA": "Katar",
"country.RE": "Réunion",
"country.RO": "Románia",
"country.RU": "Oroszország",
"country.RW": "Ruanda",
"country.BL": "Saint-Barthélemy",
"country.SH": "Szent Ilona",
"country.KN": "Saint Kitts és Nevis",
"country.LC": "Saint Lucia",
"country.MF": "Saint-Martin",
"country.PM": "Saint-Pierre és Miquelon",
"country.VC": "Saint Vincent és a Grenadine-szigetek",
"country.WS": "Szamoa",
"country.SM": "San Marino",
"country.ST": "São Tomé és Príncipe",
"country.SA": "Szaúd-Arábia",
"country.SN": "Szenegál",
"country.RS": "Szerbia",
"country.SC": "Seychelle-szigetek",
"country.SL": "Sierra Leone",
"country.SG": "Szingapúr",
"country.SX": "Sint Maarten",
"country.SK": "Szlovákia",
"country.SI": "Szlovénia",
"country.SB": "Salamon-szigetek",
"country.SO": "Szomália",
"country.ZA": "Dél-afrikai Köztársaság",
"country.SS": "Dél-Szudán",
"country.ES": "Spanyolország",
"country.LK": "Srí Lanka",
"country.SD": "Szudán",
"country.SR": "Suriname",
"country.SE": "Svédország",
"country.CH": "Svájc",
"country.SY": "Szíria",
"country.TW": "Tajvan",
"country.TJ": "Tádzsikisztán",
"country.TZ": "Tanzánia",
"country.TH": "Thaiföld",
"country.TL": "Kelet-Timor",
"country.TG": "Togo",
"country.TK": "Tokelau",
"country.TO": "Tonga",
"country.TT": "Trinidad és Tobago",
"country.TN": "Tunézia",
"country.TR": "Törökország",
"country.TM": "Türkmenisztán",
"country.TC": "Turks- és Caicos-szigetek",
"country.TV": "Tuvalu",
"country.UG": "Uganda",
"country.UA": "Ukrajna",
"country.AE": "Egyesült Arab Emírségek",
"country.GB": "Egyesült Királyság",
"country.US": "Egyesült Államok",
"country.UY": "Uruguay",
"country.UZ": "Üzbegisztán",
"country.VU": "Vanuatu",
"country.VA": "Vatikán",
"country.VE": "Venezuela",
"country.VN": "Vietnám",
"country.VG": "Brit Virgin-szigetek",
"country.VI": "Amerikai Virgin-szigetek",
"country.WF": "Wallis és Futuna",
"country.EH": "Nyugat-Szahara",
"country.YE": "Jemen",
"country.ZM": "Zambia",
"country.ZW": "Zimbabwe",
"api.deploy.netstorage_unreachable": "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek.",
"api.deploy.not_installable": "Ez az alkalmazás jelenleg nem telepíthető.",
"api.deploy.no_space": "Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
"api.deploy.started": "Telepítés elindítva – az állapot a kártyán követhető",
"api.start.drive_missing": "A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
"api.start.not_enough_memory": "Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)",
"api.action.state_not_saved": "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
"api.update.started": "Frissítés elindult – az állapot a kártyán követhető",
"api.settings.updated": "Beállítások frissítve",
"api.integration.enabled": "Integráció engedélyezve",
"api.integration.disabled": "Integráció kikapcsolva",
"api.backup.already_running": "Mentés már folyamatban",
"api.backup.started": "Mentés elindítva",
"api.backup.tier2_started": "2. mentés elindítva",
"api.update.kicked_off": "Frissítés elindítva",
"api.config.unchanged": "A konfiguráció változatlan — nincs szükség újraindításra.",
"api.config.applied": "Konfiguráció alkalmazva — a vezérlő újraindul.",
"api.geo.set": "Geo-korlátozás beállítva",
"api.geo.no_cloudflare": "Cloudflare API nincs konfigurálva",
"api.geo.sync_started": "Szinkronizálás elindítva",
"api.geo.app_set": "Alkalmazás geo-korlátozás beállítva",
"api.geo.app_removed": "Alkalmazás geo-korlátozás eltávolítva",
"alert.link.settings": "Beállítások",
"alert.link.monitoring": "Rendszermonitor",
"alert.link.update": "Frissítés",
"alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort",
"alert.deadapp.one": [
"Telepített alkalmazás nem fut: "
],
"alert.deadapp.one_state": [
"Telepített alkalmazás nem fut: ",
" (",
")"
],
"alert.storage.disconnected": "Meghajtó leválasztva: %s (%s)",
"alert.hub.disabled": "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
"alert.hub.unreachable": "Hub nem elérhető — utolsó hiba: %s",
"alert.backup.disabled": "A biztonsági mentés nincs bekapcsolva",
"alert.update.available": "Új controller verzió elérhető: %s",
"alert.overflow": "+ %d további figyelmeztetés",
"disk.err.bad_request": "érvénytelen kérés",
"disk.err.bad_mountpoint": "érvénytelen csatlakoztatási pont",
"disk.err.target_required": "céltároló kötelező az áthelyezéshez",
"disk.err.name_mismatch": "a beírt név nem egyezik a csatlakoztatási névvel",
"disk.err.unknown_mode": "ismeretlen mód (migrate vagy anyway)",
"disk.err.device_required": "eszköz kötelező",
"disk.err.init_in_progress": "már folyamatban van egy meghajtó-inicializálás",
"disk.err.protected": "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással",
"disk.err.wipe_failed": [
"törlés sikertelen: "
],
"disk.err.attach_unavailable": "Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát."
}
+351
View File
@@ -0,0 +1,351 @@
# -*- coding: utf-8 -*-
"""i18n_go_parity.py -- a Go-side message key may only carry text that ALREADY existed.
Run from controller/: python3 scripts/i18n_go_parity.py
Exit 0 clean * 1 convicted * 2 inconclusive (base capture or bundles missing).
Design: felhom.eu/documentation/architecture/10-localisation.md s1 -- the Hungarian product must
render byte-for-byte the same before and after every slice. Slice 1 proved that for TEMPLATES by
rendering fixtures captured from unconverted templates. Slice 2 moves GO literals into the bundle,
and a rendered fixture cannot cover them all (a flash, an API error, a country name each need their
own request). This gate proves the same property structurally, at push time:
Every key slice 2 introduced maps to a Go string literal that existed at the BASE COMMIT, byte
for byte. A literal that was split, joined, reworded or re-punctuated on the way into hu.json
fails here and names both sides.
Two files carry it:
scripts/i18n_go_base.json -- EVERY Go string literal at the base commit, captured ONCE with
`--capture` from a clean worktree of that commit. FROZEN: it is the
measurement, and regenerating it to make a conversion pass is the
one thing that would make this gate a wish. Slice 2's three
releases all measure against the same capture.
scripts/i18n_go_keys.json -- `key -> from`, written by the conversion. `from` is the base
literal the key replaced, or the ORDERED list of literals a
concatenation joined.
Checks:
1. LISTED. Every hu.json key named by Go code (s.msg / msgN / Msgf / MsgErrorf / b.Msg / b.Plural
with a literal key) appears in i18n_go_keys.json. Forgetting to list a converted key is the
hole this closes.
2. REAL. Every `from` literal appears in the base capture. A key cannot cite text nobody wrote.
3. BYTE-EQUAL. A single-literal key: hu.json[key] == from, exactly. A joined key: hu.json[key]
with its printf verbs removed == the `from` fragments concatenated in order, with their verbs
removed too. Plural keys compare their `.one`/`.other`-less base form.
The Go literal walker is the inventory's (felhom.eu/scripts/i18n_inventory.py `go_literals`),
copied rather than imported: this gate runs from a controller clone that may not sit beside
felhom.eu, and a gate that can fail to import its own scanner is a gate that can silently skip.
This source is ASCII-only: it holds no Hungarian, so there is nothing for a locale to mangle.
"""
from __future__ import annotations
import argparse
import io
import json
import os
import re
import sys
import urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(HERE)
LOCALES = os.path.join(CTRL, "internal", "i18n", "locales")
BASE_FILE = os.path.join(HERE, "i18n_go_base.json")
KEYS_FILE = os.path.join(HERE, "i18n_go_keys.json")
# Directories whose Go literals are IN SCOPE for the capture. cmd/ and internal/ minus the
# first-boot wizard, which is out of scope and slated for deletion (R-554, 10-localisation.md s6).
SCOPE_ROOTS = ["internal", "cmd"]
SCOPE_SKIP = [os.path.join("internal", "setup")]
# A string literal SHAPED like a bundle key: lower-case dotted segments, no spaces. Every such
# literal that hu.json actually knows is treated as "this key is named in Go".
#
# It deliberately does NOT look for `s.msg(` and its siblings. The first version did, and a decoy
# caught it: a key reaches the bundle through many shapes that are not a call to a message helper --
# `offboxRedirect(w, r, "flash.offbox.app_missing", true)` puts one in a redirect URL, an Alert
# carries one in a struct field, a handler stores one in `data["TitleKey"]`. Every one of those was
# invisible, so a converted key could be dropped from the map and nothing would say so. Matching the
# SHAPE and then requiring the bundle to know it cannot miss a delivery mechanism, because it does
# not model one.
KEY_SHAPE_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_\-]+)+$")
# printf verbs, including explicit argument indexes (%[2]s -- how English reorders while the
# Hungarian format string keeps the plain verbs it always had).
VERB_RE = re.compile(r"%(?:\[\d+\])?[-+# 0-9.*]*[a-zA-Z]")
def go_literals(src: str):
"""Yield (line, literal_body, raw) for every string literal outside comments.
Copied from felhom.eu/scripts/i18n_inventory.py `go_literals` (2026-09-17).
"""
i, n, line = 0, len(src), 1
while i < n:
c = src[i]
if c == "\n":
line += 1
i += 1
elif src.startswith("//", i):
j = src.find("\n", i)
i = n if j < 0 else j
elif src.startswith("/*", i):
j = src.find("*/", i + 2)
j = n if j < 0 else j + 2
line += src.count("\n", i, j)
i = j
elif c == "'":
j = i + 1
while j < n and src[j] != "'":
j += 2 if src[j] == "\\" else 1
i = j + 1
elif c == '"':
j = i + 1
while j < n and src[j] != '"' and src[j] != "\n":
j += 2 if src[j] == "\\" else 1
yield line, src[i + 1:j], False
i = j + 1
elif c == "`":
j = src.find("`", i + 1)
j = n if j < 0 else j
yield line, src[i + 1:j], True
line += src.count("\n", i, j)
i = j + 1
else:
i += 1
def go_files(root: str):
out = []
for d in SCOPE_ROOTS:
base = os.path.join(root, d)
if not os.path.isdir(base):
continue
for dirpath, _dirs, names in os.walk(base):
rel = os.path.relpath(dirpath, root)
if any(rel == s or rel.startswith(s + os.sep) for s in SCOPE_SKIP):
continue
for n in sorted(names):
if n.endswith(".go") and not n.endswith("_test.go"):
out.append(os.path.join(dirpath, n))
return sorted(out)
def read(p: str) -> str:
with io.open(p, encoding="utf-8", errors="replace") as fh:
return fh.read()
def unescape(body: str) -> str:
"""Turn a Go interpreted-literal BODY into the runtime string.
Only the escapes this codebase uses appear here; anything unknown is left alone rather than
guessed, which keeps the comparison honest (an unrecognised escape can only make a key FAIL
to match, never falsely match).
"""
out, i, n = [], 0, len(body)
simple = {"n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"', "'": "'"}
while i < n:
if body[i] == "\\" and i + 1 < n:
c = body[i + 1]
if c in simple:
out.append(simple[c])
i += 2
continue
if c == "u" and i + 6 <= n:
try:
out.append(chr(int(body[i + 2:i + 6], 16)))
i += 6
continue
except ValueError:
pass
out.append(body[i])
i += 1
return "".join(out)
def capture(root: str) -> dict:
"""Index EVERY Go string literal at this commit, not only the ones a word list calls Hungarian.
The first version filtered by `has_hu`, and the filter was wrong in exactly the way R-565 named:
a Hungarian string spelled without accents ("Naponta", "5 percenkent", "Eletjel (Heartbeat)",
"Adatbazis mentes") is invisible to a letter search, and a word list is a list -- it is short by
construction and nobody knows which words are missing. Seven such literals were silently absent
from the first capture and the gate correctly refused the keys that cited them.
Filtering is not needed here at all. This index answers ONE question -- "did the base commit
contain this exact text?" -- and an unfiltered index answers it for every string. It cannot be
fooled by an English literal that happens to equal a Hungarian sentence, because no such literal
exists. So the word list is gone and the blind spot with it.
"""
lits = {}
for p in go_files(root):
rel = os.path.relpath(p, root)
src = read(p)
for line, body, raw in go_literals(src):
text = body if raw else unescape(body)
if text == "":
continue
lits.setdefault(text, []).append("%s:%d" % (rel, line))
return lits
def strip_verbs(s: str) -> str:
"""Remove printf verbs, keeping a literal %% as a single percent sign."""
s = s.replace("%%", "\x00")
s = VERB_RE.sub("", s)
return s.replace("\x00", "%")
def load_json(p: str):
if not os.path.exists(p):
return None
with io.open(p, encoding="utf-8") as fh:
return json.load(fh)
def keys_named_in_go(root: str, hu):
"""Every bundle key a Go literal names -> the sites that name it."""
out = {}
for p in go_files(root):
rel = os.path.relpath(p, root)
src = read(p)
for i, line in enumerate(src.splitlines(), 1):
if line.lstrip().startswith("//"):
continue
for _line, body, raw in go_literals(line):
text = body if raw else unescape(body)
if hu is not None and text in hu and KEY_SHAPE_RE.match(text):
out.setdefault(text, []).append("%s:%d" % (rel, i))
return out
def base_form(hu: dict, key: str):
"""The Hungarian text for key, accepting the plural split (key, key.one, key.other)."""
if key in hu:
return hu[key]
for suffix in (".other", ".one"):
if key + suffix in hu:
return hu[key + suffix]
return None
def main(argv) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--capture", action="store_true",
help="rewrite i18n_go_base.json (run once, from a CLEAN worktree of the base commit)")
ap.add_argument("--root", default=CTRL,
help="controller root to capture FROM -- point it at a clean worktree of the base "
"commit, never at a tree that has already been converted")
ap.add_argument("--commit", default="", help="the commit being captured, recorded in the file")
args = ap.parse_args(argv[1:])
if args.capture:
lits = capture(args.root)
payload = {
"_comment": (
"FROZEN measurement -- every Hungarian Go string literal at the base commit of "
"localisation slice 2. Regenerating this to make a conversion pass is the one thing "
"that would turn i18n_go_parity.py into a wish. See its docstring."
),
"commit": args.commit,
"count": len(lits),
# One site per literal: enough to name where the text came from in a failure, and it
# keeps a frozen measurement small enough to read in a review.
"literals": {k: sorted(set(v))[0] for k, v in sorted(lits.items())},
}
with io.open(BASE_FILE, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=1, sort_keys=False)
fh.write("\n")
print("captured %d Go string literals from %s -> %s"
% (len(lits), args.root, os.path.relpath(BASE_FILE, CTRL)))
return 0
base = load_json(BASE_FILE)
keys = load_json(KEYS_FILE)
hu = load_json(os.path.join(LOCALES, "hu.json"))
if base is None or keys is None or hu is None:
print("go-parity gate INCONCLUSIVE: base capture, key map or hu.json not found under %s" % CTRL)
return 2
base_lits = base.get("literals", {})
named = keys_named_in_go(CTRL, hu)
# Keys that predate slice 2 (the spike's and slice 1's copy-producing funcs). Each is pinned by
# its own Go test, named in the map; this gate accounts for them so a NEW Go key cannot hide
# among them. They are not measured against the base capture: their Hungarian was moved by
# slice 0/1 and proved by rendered fixtures, which is the stronger measurement.
preexisting = keys.pop("_preexisting", {}) or {}
keys.pop("_comment", None)
bad = []
# 1. LISTED -- a key a Go call site names must be accounted for.
for key in sorted(named):
if key not in keys and key not in preexisting:
bad.append("UNLISTED %s named at %s but absent from %s"
% (key, named[key][0], os.path.basename(KEYS_FILE)))
# 2 + 3. REAL and BYTE-EQUAL.
for key in sorted(keys):
spec = keys[key]
value = base_form(hu, key)
if value is None:
bad.append("NO-KEY %s listed but absent from hu.json" % key)
continue
# A third spec shape, for the handful of sites where the Hungarian was written ALREADY
# URL-ESCAPED inside a redirect target: {"inside": "<the base literal>"}. The literal is
# unescaped and the key's text must occur in it exactly once. Explicit and rare on purpose --
# a substring rule applied everywhere would let a reworded sentence pass.
if isinstance(spec, dict):
lit = spec.get("inside", "")
if lit not in base_lits:
bad.append("INVENTED %s cites a base literal that does not exist: %r" % (key, lit))
continue
decoded = urllib.parse.unquote_plus(lit)
n = decoded.count(value)
if n != 1:
bad.append("CHANGED %s occurs %d times (want 1) in the unescaped literal\n"
" hu.json : %r\n"
" unescaped: %r" % (key, n, value, decoded))
continue
froms = [spec] if isinstance(spec, str) else list(spec)
missing = [f for f in froms if f not in base_lits]
if missing:
bad.append("INVENTED %s cites text that is in no base-commit literal: %r"
% (key, missing[0]))
continue
# A STRING `from` means "this key replaced exactly that literal" -- compared byte for byte, so
# even a verb swapped from %s to %v convicts. A LIST means "this key joined these literals",
# which is how a concatenation or a trailing parameter becomes one message; there the printf
# verbs are what the join replaced, so both sides are compared with the verbs removed.
if isinstance(spec, str):
if value != froms[0]:
bad.append("CHANGED %s\n hu.json: %r\n base : %r (%s)"
% (key, value, froms[0], base_lits[froms[0]]))
else:
got, want = strip_verbs(value), strip_verbs("".join(froms))
if got != want:
bad.append("CHANGED %s (joined from %d literals)\n"
" hu.json (verbs removed): %r\n"
" base (verbs removed): %r"
% (key, len(froms), got, want))
print("go-parity: base capture %s (%d literals), %d slice-2 keys listed, %d pre-existing, "
"%d named in Go"
% (base.get("commit", "?")[:12] or "?", len(base_lits), len(keys), len(preexisting),
len(named)))
if bad:
print("\ngo-parity gate CONVICTS (%d):" % len(bad))
for b in bad:
print(" " + b)
return 1
print("go-parity gate OK: every Go-side key carries base-commit text, byte for byte.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+6 -1
View File
@@ -38,7 +38,12 @@ TEMPLATE_ROOTS = [os.path.join(CTRL, "internal", "web", "templates")]
MARKER = re.compile(r'\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}')
EN_MISSING_CEILING = 0
HU_FORMAL_CEILING = 16 # 6 -> 10 (release A) -> 12 (release B) -> 16 (release C): converted copy carries more „ön" forms (R-516), unchanged by rule
HU_FORMAL_CEILING = 18 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A): the count follows the
# conversion, it is not a judgement. Moving a Go literal into hu.json makes an „ön" form the gate could
# not see before VISIBLE to it -- the two that arrived are `flash.login.password_changed` („kérjük")
# and `alert.deadapp.group` („nézze meg"), both word-for-word what the Go code already said. Slice 2 may
# not reword a Hungarian sentence (parity, 10-localisation.md s1), so the ceiling rises with the
# measurement and R-516 still owns the words.
EN_FORBIDDEN = re.compile(r"\b(please|kindly)\b", re.I)
+21
View File
@@ -45,6 +45,8 @@ COVERS = {
"golden-notice": "R-410 in the other direction: an empty dir must not count as a bake",
"minagent-header": "the word MinAgent in prose / a code span, not the header line (test_minagent_header_gate.py)",
"i18n": "an undefined marker key, a pleading English value, a shrinking/growing gap (v0.247.0)",
"go-parity": "a Go-side key REWORDED, a key citing text no base literal has, and a converted "
"key left out of the map (v0.252.0, R-557)",
}
fails = []
@@ -173,6 +175,25 @@ swapped("retrieval-promise/en-ok", "retrieval_promise_gate.py", EN_JSON,
swapped("secret-markup/bundle", "secret_in_markup_gate.py", EN_JSON,
_one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Copy {{.RetrievalPassword}}"'))
# --- go-parity (v0.252.0, R-557): a Go-side message key may only carry base-commit text. ---
# --- Three shapes, because the gate makes three different claims. ---
GO_KEYS = os.path.join(HERE, "i18n_go_keys.json")
_A_KEY = '"flash.share.enabled": "A megosztás bekapcsolva."'
_A_HU = '"flash.share.enabled": "A megosztás bekapcsolva."'
# 1. name-for-fact: the KEY is still listed and still named in Go, and its text was reworded by one
# word. A gate that only checked "is this key accounted for" passes this.
swapped("go-parity/reworded", "i18n_go_parity.py", HU_JSON,
_one(_A_HU, '"flash.share.enabled": "A megosztás most bekapcsolva."'))
# 2. constant-for-measurement: the key map cites text nobody ever wrote. A gate that compared
# hu.json against the MAP alone (rather than against the frozen capture) passes this.
swapped("go-parity/invented", "i18n_go_parity.py", GO_KEYS,
_one(_A_KEY, '"flash.share.enabled": "Ez a mondat sosem létezett."'))
# 3. declaration-for-reachability: a converted key is NAMED by Go and quietly dropped from the map.
# A gate that only walked the map passes this -- which is how a conversion escapes review.
swapped("go-parity/unlisted", "i18n_go_parity.py", GO_KEYS,
_one(_A_KEY + ",\n", ""))
# --- golden-notice: R-410's decoy, in the other direction. It is ADVISORY, so rc is never the ---
# --- question — what it COUNTED is. ---
ran += 1