From fd28d70bff4559740a602bd4f861c17813b33699 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 12 Oct 2026 15:13:59 +0200 Subject: [PATCH] =?UTF-8?q?v0.308.0:=20controller=20batch=202026-10-12=20?= =?UTF-8?q?=E2=80=94=20clear-address=20warning=20(R-922),=20secret=20sweep?= =?UTF-8?q?=20over=20every=20page=20(R-255),=20read=5Ffailed=20(R-266),=20?= =?UTF-8?q?no=20NVMe=20heat=20band=20(R-333),=20share=20globe=20(R-577),?= =?UTF-8?q?=20Hungarian=20dates=20and=20numbers=20(R-562),=20lost-drive=20?= =?UTF-8?q?option=20pinned=20(R-521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged. R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 44 ++ REUSE.md | 1 + controller/README.md | 34 +- controller/internal/agentapi/diskverdict.go | 28 +- .../agentapi/diskverdict_nvme_heat_test.go | 36 + controller/internal/i18n/format.go | 86 +++ controller/internal/i18n/format_test.go | 44 ++ controller/internal/i18n/locales/en.json | 9 +- controller/internal/i18n/locales/hu.json | 9 +- controller/internal/report/builder.go | 13 +- .../report/storage_readfailed_test.go | 37 + controller/internal/report/types.go | 5 + .../internal/web/a_version_travel_test.go | 2 +- .../web/backups_remote_statsknown_test.go | 2 +- .../web/dashboard_backup_card_test.go | 2 +- .../internal/web/dashboard_diskknown_test.go | 3 +- controller/internal/web/disk_health_state.go | 2 +- controller/internal/web/funcmap.go | 182 ++--- controller/internal/web/handlers.go | 76 +- controller/internal/web/i18n_cases_a_test.go | 8 + controller/internal/web/i18n_web.go | 2 +- controller/internal/web/kept_handlers.go | 9 +- controller/internal/web/lang_visitor_test.go | 73 +- .../internal/web/meta_copy_allowlist_test.go | 3 + .../internal/web/notifications_guard_test.go | 1 + controller/internal/web/offbox_handlers.go | 2 +- .../internal/web/r103_tier2_action_test.go | 4 +- .../internal/web/r255_secret_sweep_test.go | 263 +++++++ .../internal/web/r365_abandon_overdue_test.go | 2 +- .../internal/web/r365_banner_overdue_test.go | 2 +- controller/internal/web/r403_surface_test.go | 14 +- controller/internal/web/r516_items_test.go | 2 +- .../r521_storage_disconnected_option_test.go | 69 ++ .../internal/web/r562_locale_format_test.go | 72 ++ .../internal/web/r922_email_cleared_test.go | 60 +- controller/internal/web/recovery_handlers.go | 3 +- controller/internal/web/server.go | 3 + controller/internal/web/share_handlers.go | 2 +- .../internal/web/templates/app_export.html | 6 +- .../internal/web/templates/app_import.html | 14 +- .../internal/web/templates/app_info.html | 2 +- .../internal/web/templates/backups_apps.html | 4 +- .../web/templates/backups_remote.html | 4 +- .../web/templates/backups_restore.html | 4 +- controller/internal/web/templates/deploy.html | 27 +- .../templates/launcher_share_password.html | 2 + .../web/templates/launcher_shared.html | 2 + controller/internal/web/templates/layout.html | 8 + .../internal/web/templates/monitoring.html | 14 +- .../web/templates/settings_notifications.html | 6 +- .../internal/web/templates/sharing.html | 4 +- .../internal/web/templates/storage.html | 8 +- .../web/templates/storage_attach.html | 2 +- .../internal/web/templates/storage_init.html | 2 +- .../web/testdata/i18n_parity/app_export.html | 11 +- .../i18n_parity/app_import_bundles.html | 19 +- .../i18n_parity/app_import_empty.html | 15 +- .../i18n_parity/app_info_available.html | 5 + .../i18n_parity/app_info_close_signup.html | 5 + .../app_info_close_signup_native.html | 5 + .../i18n_parity/app_info_deployed.html | 7 +- .../i18n_parity/app_info_hold_log.html | 5 + .../i18n_parity/app_info_install_hold.html | 5 + .../i18n_parity/app_info_installable.html | 5 + .../app_info_known_login_changed.html | 5 + .../i18n_parity/app_info_ladder_steps.html | 5 + .../i18n_parity/app_info_not_checked.html | 5 + .../i18n_parity/app_info_operator_creds.html | 5 + .../app_info_setup_gate_button.html | 5 + .../app_info_setup_gate_native.html | 5 + .../app_info_setup_gate_probe.html | 5 + .../i18n_parity/app_info_signup_closed.html | 5 + .../i18n_parity/app_info_signup_native.html | 5 + .../app_info_signup_native_open_failed.html | 5 + .../i18n_parity/app_info_signup_window.html | 5 + .../i18n_parity/app_info_updating.html | 5 + .../i18n_parity/backups_apps_empty.html | 5 + .../i18n_parity/backups_apps_error.html | 27 +- .../backups_apps_interrupted_run.html | 31 +- .../backups_apps_offsite_offer.html | 29 +- .../testdata/i18n_parity/backups_apps_ok.html | 29 +- .../i18n_parity/backups_apps_other.html | 15 +- .../i18n_parity/backups_apps_running.html | 15 +- .../i18n_parity/backups_apps_stale_copy.html | 29 +- .../i18n_parity/backups_degraded.html | 5 + .../testdata/i18n_parity/backups_empty.html | 5 + .../testdata/i18n_parity/backups_full.html | 17 +- .../i18n_parity/backups_interrupted_run.html | 7 +- .../i18n_parity/backups_nobackup_yet.html | 5 + .../backups_remote_abandon_overdue.html | 5 + .../i18n_parity/backups_remote_empty.html | 5 + .../i18n_parity/backups_remote_error.html | 5 + .../i18n_parity/backups_remote_escrowed.html | 5 + .../i18n_parity/backups_remote_full.html | 5 + .../backups_remote_incomplete.html | 5 + .../i18n_parity/backups_remote_notconf.html | 5 + .../backups_remote_notconf_hub.html | 5 + .../backups_remote_offsite_offer_fit.html | 5 + .../backups_remote_pending_agent.html | 5 + .../backups_remote_pending_old.html | 5 + .../i18n_parity/backups_remote_running.html | 5 + .../i18n_parity/backups_remote_stale.html | 5 + .../i18n_parity/backups_remote_stale_old.html | 5 + .../i18n_parity/backups_restore_empty.html | 7 +- .../i18n_parity/backups_restore_full.html | 9 +- .../backups_restore_known_empty.html | 7 +- .../i18n_parity/backups_restore_notarget.html | 7 +- .../i18n_parity/backups_tier_due.html | 11 +- .../i18n_parity/dashboard_diskcrit.html | 11 +- .../testdata/i18n_parity/dashboard_full.html | 13 +- .../i18n_parity/dashboard_nobackup.html | 5 + .../i18n_parity/dashboard_sparse.html | 9 +- .../i18n_parity/dashboard_tunnel_offline.html | 5 + .../web/testdata/i18n_parity/debug.html | 5 + .../deploy_deployed_restored_login.html | 32 +- .../i18n_parity/deploy_deployed_running.html | 32 +- .../i18n_parity/deploy_deployed_stopped.html | 32 +- .../web/testdata/i18n_parity/deploy_new.html | 32 +- .../i18n_parity/deploy_new_blocked.html | 32 +- .../deploy_new_memory_blocked.html | 32 +- .../i18n_parity/escrow_agent_old.html | 5 + .../testdata/i18n_parity/escrow_first.html | 5 + .../i18n_parity/escrow_not_ready.html | 5 + .../i18n_parity/escrow_receremony.html | 5 + .../testdata/i18n_parity/kept_data_empty.html | 5 + .../testdata/i18n_parity/kept_data_full.html | 5 + .../i18n_parity/launcher_abandon_overdue.html | 5 + .../launcher_abandon_overdue_offered.html | 5 + .../testdata/i18n_parity/launcher_empty.html | 5 + .../testdata/i18n_parity/launcher_full.html | 5 + .../i18n_parity/launcher_missed_backup.html | 5 + .../i18n_parity/launcher_nopassword.html | 5 + .../i18n_parity/launcher_reminder_tier0.html | 5 + .../i18n_parity/launcher_reminder_tier3.html | 5 + .../i18n_parity/launcher_reminder_tiers.html | 5 + .../i18n_parity/launcher_share_password.html | 8 + .../i18n_parity/launcher_shared_apps.html | 8 + .../i18n_parity/launcher_shared_empty.html | 8 + .../web/testdata/i18n_parity/logs.html | 5 + .../i18n_parity/monitoring_hub_down.html | 21 +- .../i18n_parity/monitoring_hub_off.html | 21 +- .../i18n_parity/monitoring_hub_ok.html | 21 +- .../i18n_parity/recovery_locked_confirm.html | 2 +- .../i18n_parity/recovery_unlocked_apps.html | 6 +- .../i18n_parity/restore_wizard_blocked.html | 5 + .../i18n_parity/restore_wizard_exec.html | 7 +- .../restore_wizard_nodumptime.html | 5 + .../restore_wizard_prepare_confirm.html | 7 +- .../i18n_parity/restore_wizard_ready.html | 11 +- .../settings_notifications_clear_confirm.html | 649 ++++++++++++++++++ .../settings_notifications_hub.html | 5 + .../settings_notifications_nohub.html | 5 + .../i18n_parity/settings_security_full.html | 7 +- .../i18n_parity/settings_security_noauth.html | 5 + .../i18n_parity/settings_system_failed.html | 5 + .../i18n_parity/settings_system_full.html | 5 + .../i18n_parity/settings_system_nomemory.html | 5 + .../i18n_parity/settings_system_pending.html | 5 + .../i18n_parity/sharing_error_skipped.html | 5 + .../testdata/i18n_parity/sharing_full.html | 9 +- .../i18n_parity/sharing_no_offsite_yet.html | 5 + .../i18n_parity/sharing_notarget_blocked.html | 5 + .../web/testdata/i18n_parity/sharing_off.html | 5 + .../i18n_parity/sharing_other_none.html | 5 + .../web/testdata/i18n_parity/stacks_full.html | 5 + .../stacks_install_interrupted.html | 5 + .../i18n_parity/stacks_unhealthy_stop.html | 5 + .../testdata/i18n_parity/storage_attach.html | 7 +- .../testdata/i18n_parity/storage_empty.html | 9 +- .../testdata/i18n_parity/storage_full.html | 13 +- .../testdata/i18n_parity/storage_init.html | 7 +- .../i18n_parity/storage_network_full.html | 5 + .../storage_network_nosupport.html | 5 + .../testdata/i18n_parity/tier2_not_hdd.html | 5 + .../i18n_parity/tier2_not_hdd_absent.html | 5 + .../i18n_parity/tier2_not_hdd_protected.html | 5 + .../i18n_parity/tier2_not_hdd_same_disk.html | 5 + .../i18n_parity/tier2_notarget_disabled.html | 5 + .../i18n_parity/tier2_target_auto.html | 5 + .../i18n_parity/tier2_target_ssd.html | 5 + controller/internal/web/tier2_honesty_test.go | 6 +- controller/scripts/i18n_go_keys.json | 1 + controller/scripts/secret_in_markup_gate.py | 14 +- 183 files changed, 2671 insertions(+), 411 deletions(-) create mode 100644 controller/internal/agentapi/diskverdict_nvme_heat_test.go create mode 100644 controller/internal/i18n/format.go create mode 100644 controller/internal/i18n/format_test.go create mode 100644 controller/internal/report/storage_readfailed_test.go create mode 100644 controller/internal/web/r255_secret_sweep_test.go create mode 100644 controller/internal/web/r521_storage_disconnected_option_test.go create mode 100644 controller/internal/web/r562_locale_format_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/settings_notifications_clear_confirm.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c47bdb..58b464e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +## v0.308.0 — the controller batch of 2026-10-12: clear-address warning, a secret sweep over every page, NVMe heat, the share globe, Hungarian dates and numbers (2026-10-12) + +**MinAgent: 0.131.0** (unchanged — no agent call changed). **R-266 needs the hub of the same day** to act on the new +`read_failed` field (an older hub ignores an unknown JSON field and bands the zeros, today's behaviour). Operator rulings +2026-10-12, `09` §3 decisions 196, 204–207, 223, 227. + +- **R-922 (S1) — clearing the notification address is warned first.** An empty address over a stored one saves nothing + and sends nothing on the first press; the page says „Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő + levelet." / "If you remove the notification e-mail address, you will get no more notification e-mails." with its own + confirm button. Only the confirmed save clears it and sends `email_cleared` (v0.305.0). Server-side (no script), so it + holds for any client. What is sent to the hub is unchanged. `TestR922_ClearWarnsFirst` (RED without the warning block: + „the address was cleared before the warning"); a new parity case `settings_notifications_clear_confirm`. +- **R-255 (S9) — one test renders EVERY page with planted secrets.** `TestR255_NoPlantedSecretReachesAnyPage` renders + all 142 parity cases of all 35 page templates, in both languages, with a unique value in every secret-named field of + their data (any depth) plus the keys handlers really set (`RetrievalPassword`, `InitialCreds.Password`, …), and fails + on any that reaches the HTML. Pages are DERIVED from the templates, so a new page without a case fails it. **It found + one leak, fixed here:** an app's optional-config `secret_input` (a key the household typed in, e.g. a metadata + provider's API key) was rendered into `value="…"` of its settings page on every open. It is now fetched on demand + through the existing reveal endpoint (authorised for `secret_input` fields only), and a blank field never revealed is + not sent, so saving other fields keeps the key. RED: the old `deploy.html` → „a planted secret reached the HTML — from + deploy_new.CurrentValues.API_KEY"; a planted `{{.RetrievalPassword}}` in settings_security → caught. + `TestR255_OptionalSecretIsRevealedOnDemand` (RED without the new reveal branch: 403). +- **R-266 (S10, controller half) — a failed root statfs says so on the wire.** `report.StorageReport` gains + `read_failed` (omitempty); the `/` entry carries `true` when `SystemInfo.DiskKnown` is false, the numbers stay zeros. + `TestRootStorageEntry_ReadFailed` (RED without the assignment). +- **R-333 (S11) — no heat band for NVMe.** An NVMe drive's temperature alone never raises Figyelmeztetés or Hiba; its own + `critical_warning` (incl. the temperature bit) still makes it Hiba; spinning/SATA bands unchanged. The alert text no + longer names heat for an NVMe. `TestDiskVerdict_NVMeHasNoHeatBand` (RED with the guard removed: a 72 °C NVMe was Hiba). +- **R-577 (S12) — the language globe on the guest share pages**, the visitor's own (`/lang`, their cookie; the household's + setting is the default as on the sign-in page). The share's „Hibás jelszó" line now follows the visitor's language + (`flash.share.wrong_password`). `TestGuestSharePagesHaveGlobe` replaces `TestGuestSharePagesHaveNoGlobe` (RED without + the globe line); `TestGuestSharePage_HonoursVisitorCookie` drives `/s/` through the full mux. +- **R-562 (S28) — Hungarian dates and numbers in Hungarian, ISO-like in English.** One set of layouts in + `internal/i18n/format.go`; every template date/size func is per-language (`formatFuncs`), the Go-side page data in + `internal/web` follows, page scripts use `fhNum`/`fhDateTime` (layout.html), precomputed sizes go through `numText`. + Raw RFC3339 stamps that reached pages (geo sync, drive retirement, share-backup runs, sealed-at) now render as local + dates. **The Hungarian parity fixtures were re-captured for this release only**: 107 changed by date/number format + only (machine-classified), the rest by the edited page scripts, the share globe and the deploy fix — named in + REPORT.md. `TestR562_BackupsPageFormatsPerLanguage`, `TestR562_FormatFuncsPerLanguage`, `TestFormat_*` (RED with the + formatters ignoring the language). +- **R-521 (S32, controller half) — the lost-drive option.** The household's „Meghajtó leválasztva" option already exists, + saves and is sent in `enabled_events`; `TestR521_StorageDisconnectedOptionRoundTrips` now pins it both ways (RED with + the option dropped from the handler). It has been in `DefaultEnabledEvents` since v0.17.0 — left as it is (see REPORT). + ## v0.307.0 — after a restart the Databases table lists each database once, not every undo copy (2026-10-10) **MinAgent: 0.131.0** (unchanged — no agent call changed). Carries v0.306.0 (R-570). Released 2026-10-10 alone (no hub diff --git a/REUSE.md b/REUSE.md index e84670e..1eeeaec 100644 --- a/REUSE.md +++ b/REUSE.md @@ -281,6 +281,7 @@ | `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass | | `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion | | `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` | +| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` | | `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) | | `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` | | `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) | diff --git a/controller/README.md b/controller/README.md index 00530b6..c6eee62 100644 --- a/controller/README.md +++ b/controller/README.md @@ -298,10 +298,15 @@ self-reported failure gets: - **Rendben** — clean. - **Figyelmeztetés** — a first sighting of unreadable sectors below the bar, reallocated sectors, NVMe - media errors, `percentage_used ≥ 90`, or `temperature_c ≥ 55`. + media errors, `percentage_used ≥ 90`, or `temperature_c ≥ 55` (spinning/SATA drives only). - **Hiba** — the drive reports FAILING, **or** (v0.215.0) unreadable sectors are **sustained** across two consecutive checks, unreadable sectors sit alongside reallocation, the count reaches **64**, - `temperature_c ≥ 60`, NVMe's `critical_warning` is set, or `percentage_used ≥ 100`. + `temperature_c ≥ 60` (spinning/SATA drives only), NVMe's `critical_warning` is set, or `percentage_used ≥ 100`. +- **No heat band for NVMe (v0.308.0, R-333, `09` §3 decision 206).** The 55/60 °C bands are spinning-disk bands; + a healthy NVMe idles at 53 °C and passes 60 °C under load. An NVMe drive (`agentapi.IsNVMe`: the summary + carries `critical_warning`, `percentage_used` or `media_errors`) never reaches Figyelmeztetés/Hiba from its + temperature; its own `critical_warning` (bit 1 is its temperature alarm) still makes it Hiba. Pinned by + `TestDiskVerdict_NVMeHasNoHeatBand`. - **Nincs adat** — nil/UNKNOWN/old agent. Never alarms, and never erases a disk's stored history. `DiskVerdictFor(smart, prior)` takes an `agentapi.DiskPrior`; a zero prior is the fail-safe, so a first @@ -2721,7 +2726,7 @@ The controller pushes structured events to the Hub's `/api/v1/event` endpoint. T | `health_recovered` | info | Health status recovers (fail/warn→ok) | | `disk_warning` | warning | Disk usage crosses 90% | | `disk_critical` | **critical** | Disk usage crosses 95% (this row read `error` until v0.223.0; the emitter is `fillwatch.Band.Severity()` and it has always sent `critical`) | -| `storage_disconnected` | error | Storage drive physically removed | +| `storage_disconnected` | error | Storage drive physically removed. The household's „Meghajtó leválasztva" option (R-521, `09` §3 decision 227) carries it in `enabled_events`; the hub mails a household a lost drive only when that option is on. Pinned by `TestR521_StorageDisconnectedOptionRoundTrips` | | `storage_reconnected` | info | Storage drive reconnected | | `controller_started` | info | Controller process starts | | `controller_updated` | info/error | Self-update success or failure | @@ -2921,6 +2926,12 @@ provisioning-seeded alert delivery). The form re-renders with a Hungarian error ticked events preserved; no save, no sync. Clearing the e-mail with **zero** events enabled is allowed (an intentional turn-everything-off). +**Clearing the address is warned first (v0.308.0, R-922, `09` §3 decision 196):** an empty box over a +STORED address saves nothing and sends nothing on the first press; the page says „you will get no more +notification mails" (both languages) with its own confirm button (`confirm_clear=1`). Only the confirmed +save clears the address and sends `email_cleared` (v0.305.0). Server-side, so it holds without JavaScript. +The hub's registered address is not the box's and is not touched. Pinned by `TestR922_ClearWarnsFirst`. + --- ### 7. Update Management @@ -3265,6 +3276,10 @@ starts a countdown or shortens one (`TestOpActions_ClosedList`). The box's log r Periodic JSON push (default every 15 min) to the central felhom-hub service: - System: hostname, OS, CPU, memory, disk usage, uptime +- Storage: one entry per mount (`/` first). **`read_failed: true` (v0.308.0, R-266)** marks the root entry when + its statfs failed (`SystemInfo.DiskKnown` false); the numbers stay the zeros the failed read left and the hub + skips banding that entry instead of reading it as an empty disk. Absent on a measured entry. Pinned by + `TestRootStorageEntry_ReadFailed`. - Containers: running/stopped counts, per-container CPU/memory - Backup: last DB-dump run, success (disk-tier backup is the host agent's; restic password is no longer reported — removed v0.69.0) - Health: current status, issues, warnings @@ -4097,8 +4112,17 @@ the apps' own UIs (not ours). never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**. `safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM - carries the cookie into the household setting (§16). **No globe on the guest share pages or the - not-found page** — R-577. + carries the cookie into the household setting (§16). **The guest share pages carry the globe too (v0.308.0, + R-577, `09` §3 decision 207)** — the visitor's own: it posts to `/lang`, their cookie decides, the household's + setting is the default as on the sign-in page, and the share's wrong-password line follows it. The not-found + page has none. Pinned by `TestGuestSharePagesHaveGlobe`, `TestGuestSharePage_HonoursVisitorCookie`. +- **Dates and numbers per language (v0.308.0, R-562, `09` §3 decision 223):** Hungarian „2026. 10. 10. 15:04" and + a decimal comma, English „2026-10-10 15:04" and a decimal point — in every template func that prints a date + or a size (`formatFuncs(lang)` in `funcmap.go`, layouts in `internal/i18n/format.go`), in the Go-side page + data built in `internal/web`, and in page scripts (`fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` in + `layout.html`). A size another package already formatted („1.5 GB") goes through `numText` / + `i18n.LocalizeDecimals`. CSS widths keep their point. The Hungarian parity fixtures were re-captured ONCE for + this release (the change is classified date/number-only, see REPORT.md). - **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 diff --git a/controller/internal/agentapi/diskverdict.go b/controller/internal/agentapi/diskverdict.go index fc2912e..02b4326 100644 --- a/controller/internal/agentapi/diskverdict.go +++ b/controller/internal/agentapi/diskverdict.go @@ -59,7 +59,7 @@ type DiskPrior struct { // // 1. nil / "" / UNKNOWN -> Nincs adat // 2. Health == FAILING -> Hiba (drive self-reports) -// 3. temperature_c >= 60 -> Hiba +// 3. temperature_c >= 60 (NOT for NVMe — R-333) -> Hiba // 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba // 5. percentage_used >= 100 -> Hiba // 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED) @@ -69,7 +69,7 @@ type DiskPrior struct { // 10. reallocated > 0 -> Figyelmeztetés // 11. media_errors > 0 -> Figyelmeztetés // 12. percentage_used >= 90 -> Figyelmeztetés -// 13. temperature_c >= 55 -> Figyelmeztetés +// 13. temperature_c >= 55 (NOT for NVMe — R-333) -> Figyelmeztetés // 14. otherwise -> Rendben // // WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187, @@ -82,6 +82,12 @@ type DiskPrior struct { // drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for // a box that was powered off or restarted across the sustain window and so has no prior. // +// WHY rows 3 and 13 skip an NVMe drive (R-333, `09` §3 decision 206, v0.308.0): the 55/60 °C bands are +// SPINNING-DISK bands. A healthy NVMe idles at 53 °C (measured on demo-hp) and routinely passes 60 °C +// under load with no fault, so the bands would tell a household "Hiba" about a working drive. An NVMe +// drive judges its own heat: critical_warning bit 1 is the device's temperature alarm, and row 4 still +// turns any critical_warning bit into Hiba. Pinned by TestDiskVerdict_NVMeHasNoHeatBand. +// // Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument. func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict { // 1 — no data. Never alarms. @@ -94,8 +100,9 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict { } // Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters, // because the overall verdict is structurally unable to report this class of fault. + nvme := IsNVMe(s) switch { - case atLeast(s.TemperatureC, TemperatureFailC): // 3 + case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3 return DiskVerdictFail case positive(s.CriticalWarning): // 4 return DiskVerdictFail @@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict { return DiskVerdictWarn case atLeast(s.PercentageUsed, percentageUsedWarn): // 12 return DiskVerdictWarn - case atLeast(s.TemperatureC, temperatureWarnC): // 13 + case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13 return DiskVerdictWarn } return DiskVerdictOK // 14 @@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string { if atLeast(s.PercentageUsed, percentageUsedWarn) { out = append(out, "elhasználódás") } - // Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. - if atLeast(s.TemperatureC, temperatureWarnC) { + // Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an + // NVMe drive (R-333): its heat never triggers a verdict, so naming it would give a wrong reason. + if !IsNVMe(s) && atLeast(s.TemperatureC, temperatureWarnC) { out = append(out, "hőmérséklet") } return out } +// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning, +// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log +// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive +// carries none of them. +func IsNVMe(s *SmartSummary) bool { + return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil) +} + func positive(p *int) bool { return p != nil && *p > 0 } func atLeast(p *int, n int) bool { return p != nil && *p >= n } diff --git a/controller/internal/agentapi/diskverdict_nvme_heat_test.go b/controller/internal/agentapi/diskverdict_nvme_heat_test.go new file mode 100644 index 0000000..c969fc3 --- /dev/null +++ b/controller/internal/agentapi/diskverdict_nvme_heat_test.go @@ -0,0 +1,36 @@ +package agentapi + +import "testing" + +// R-333 (`09` §3 decision 206): no heat band for an NVMe drive. Both directions, because "fixed" means +// one kind of drive stopped alarming on heat and the other did not, and the drive's own alarm stays. +func TestDiskVerdict_NVMeHasNoHeatBand(t *testing.T) { + noPrior := DiskPrior{} + cases := []struct { + name string + in *SmartSummary + want DiskVerdict + }{ + // A healthy NVMe under load: 72 °C, the device raises no flag. No heat alarm. + {"hot NVMe, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(0), PercentageUsed: ip(3), MediaErrors: ip(0)}, DiskVerdictOK}, + {"warm NVMe 56 °C, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), CriticalWarning: ip(0)}, DiskVerdictOK}, + // The same drive with its own temperature bit set (critical_warning bit 1 = 0x02): Hiba. + {"hot NVMe with critical_warning temperature bit → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}, DiskVerdictFail}, + {"cool NVMe with critical_warning → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(40), CriticalWarning: ip(1)}, DiskVerdictFail}, + // Spinning/SATA drives keep their 55/60 °C bands, unchanged. + {"SATA 56 °C → warn", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), ReallocatedSectors: ip(0)}, DiskVerdictWarn}, + {"SATA 60 °C → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(60), ReallocatedSectors: ip(0)}, DiskVerdictFail}, + {"SATA 54 °C → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(54)}, DiskVerdictOK}, + } + for _, c := range cases { + if got := DiskVerdictFor(c.in, noPrior); got != c.want { + t.Errorf("%s: DiskVerdictFor = %s, want %s", c.name, got.Label(), c.want.Label()) + } + } + // The alert body must not name heat as the reason for an NVMe drive. + for _, a := range DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}) { + if a == "hőmérséklet" { + t.Error("an NVMe alert names temperature as an attribute; the verdict fired on the drive's own warning") + } + } +} diff --git a/controller/internal/i18n/format.go b/controller/internal/i18n/format.go new file mode 100644 index 0000000..afb440f --- /dev/null +++ b/controller/internal/i18n/format.go @@ -0,0 +1,86 @@ +package i18n + +import ( + "strconv" + "strings" + "time" +) + +// ── Dates and numbers per language (R-562, v0.308.0, `09` §3 decision 223) ──────────────────────── +// +// The ONE place the dashboard decides how a date, a time or a decimal number looks: +// +// Hungarian 2026. 10. 10. 15:04 1,5 GB (the Hungarian date order with its dots; decimal COMMA) +// English 2026-10-10 15:04 1.5 GB (ISO-like; decimal point) +// +// Every function takes the time ALREADY in the zone it is shown in (the dashboard shows Budapest +// local time; the callers convert). An unknown language formats as Hungarian, like every other +// fallback in this package. Pinned by TestFormat_* in format_test.go. + +// FormatDateTime renders a date with hour and minute. +func FormatDateTime(lang string, t time.Time) string { + if Normalize(lang) == "en" { + return t.Format("2006-01-02 15:04") + } + return t.Format("2006. 01. 02. 15:04") +} + +// FormatDate renders a date alone. +func FormatDate(lang string, t time.Time) string { + if Normalize(lang) == "en" { + return t.Format("2006-01-02") + } + return t.Format("2006. 01. 02.") +} + +// FormatMonthDayTime renders a date in the current year: month, day, hour and minute. +func FormatMonthDayTime(lang string, t time.Time) string { + if Normalize(lang) == "en" { + return t.Format("01-02 15:04") + } + return t.Format("01. 02. 15:04") +} + +// FormatDecimal renders v with prec digits after the decimal separator of lang. +func FormatDecimal(lang string, v float64, prec int) string { + s := strconv.FormatFloat(v, 'f', prec, 64) + if Normalize(lang) == "en" { + return s + } + return strings.Replace(s, ".", ",", 1) +} + +// LocalizeDecimals rewrites the decimal POINT of every number in an already formatted short string +// ("1.5 GB", "Immich (95.0 GB)") into lang's separator. Only a point with a digit on BOTH sides is +// touched, so a sentence's full stop, a version ("0.307.0" — see below) or an address is not a target +// by accident: a dot preceded by a digit and followed by a digit is rewritten only when the number it +// belongs to has exactly ONE dot. English returns s unchanged. +// +// For strings another package formatted with %.1f (appbackup.HumanizeBytes and its siblings) and +// handed to a page as data. A new formatter should call FormatDecimal instead. +func LocalizeDecimals(lang, s string) string { + if Normalize(lang) == "en" || !strings.Contains(s, ".") { + return s + } + b := []byte(s) + isDigit := func(c byte) bool { return c >= '0' && c <= '9' } + for i := 1; i < len(b)-1; i++ { + if b[i] != '.' || !isDigit(b[i-1]) || !isDigit(b[i+1]) { + continue + } + // The whole digit-and-dot run around this dot: more than one dot is a version or an address. + start, end := i, i + for start > 0 && (isDigit(b[start-1]) || b[start-1] == '.') { + start-- + } + for end < len(b)-1 && (isDigit(b[end+1]) || b[end+1] == '.') { + end++ + } + if strings.Count(string(b[start:end+1]), ".") != 1 { + i = end + continue + } + b[i] = ',' + } + return string(b) +} diff --git a/controller/internal/i18n/format_test.go b/controller/internal/i18n/format_test.go new file mode 100644 index 0000000..6c0f1a5 --- /dev/null +++ b/controller/internal/i18n/format_test.go @@ -0,0 +1,44 @@ +package i18n + +import ( + "testing" + "time" +) + +// R-562 (`09` §3 decision 223): Hungarian dates and numbers in Hungarian, ISO-like in English. +func TestFormat_DatesPerLanguage(t *testing.T) { + at := time.Date(2026, 10, 10, 15, 4, 0, 0, time.UTC) + cases := []struct{ name, got, want string }{ + {"hu datetime", FormatDateTime("hu", at), "2026. 10. 10. 15:04"}, + {"en datetime", FormatDateTime("en", at), "2026-10-10 15:04"}, + {"hu date", FormatDate("hu", at), "2026. 10. 10."}, + {"en date", FormatDate("en", at), "2026-10-10"}, + {"hu month-day", FormatMonthDayTime("hu", at), "10. 10. 15:04"}, + {"en month-day", FormatMonthDayTime("en", at), "10-10 15:04"}, + {"unknown language falls back to Hungarian", FormatDateTime("xx", at), "2026. 10. 10. 15:04"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s: got %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestFormat_NumbersPerLanguage(t *testing.T) { + cases := []struct{ name, got, want string }{ + {"hu decimal comma", FormatDecimal("hu", 1.53, 1), "1,5"}, + {"en decimal point", FormatDecimal("en", 1.53, 1), "1.5"}, + {"hu integer has no separator", FormatDecimal("hu", 12, 0), "12"}, + {"hu size string", LocalizeDecimals("hu", "1.5 GB"), "1,5 GB"}, + {"hu list of sizes", LocalizeDecimals("hu", "Immich (95.0 GB), Nextcloud (34.0 GB)"), "Immich (95,0 GB), Nextcloud (34,0 GB)"}, + {"en untouched", LocalizeDecimals("en", "1.5 GB"), "1.5 GB"}, + {"hu sentence full stop untouched", LocalizeDecimals("hu", "Kész. 3 elem."), "Kész. 3 elem."}, + {"hu version untouched", LocalizeDecimals("hu", "v0.307.0 és 1.5 GB"), "v0.307.0 és 1,5 GB"}, + {"hu address untouched", LocalizeDecimals("hu", "192.168.0.180"), "192.168.0.180"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s: got %q, want %q", c.name, c.got, c.want) + } + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index e1a9d95..042495a 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1508,6 +1508,7 @@ "flash.share.disabled": "Sharing is off.", "flash.share.enable_failed": "Sharing could not be turned on.", "flash.share.enabled": "Sharing is on.", + "flash.share.wrong_password": "Wrong password", "flash.share.not_on": "Sharing is not on.", "flash.share.pw_clear_failed": "The password could not be removed.", "flash.share.pw_cleared": "The share password is removed.", @@ -1885,7 +1886,7 @@ "recovery.adatok": "Retrieve", "recovery.adatok_visszaszerzese_felhom": "Retrieve data — Felhom", "recovery.alkalmazas": "App", - "recovery.amelyet_zartunk_le": ", which we\n sealed on {{.}}", + "recovery.amelyet_zartunk_le": ", which we\n sealed on {{fmtTimeStr .}}", "recovery.biztosan_nem_kered_vissza_a": "Are you sure you do not want the earlier backups back?", "recovery.elerhetok": "are available", "recovery.ez_a_lehetoseg_akkor_valik": "this option becomes available once the machine has reconnected to the off-site\n storage. Until then your backups stay untouched.", @@ -1943,6 +1944,8 @@ "settings_notifications.beallitasok_ertesitesek": "Settings — Notifications", "settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "After turning it on, allow sending e-mail for each app too (on the app’s page).", "settings_notifications.biztonsagi_mentes_sikertelen": "Backup failed", + "settings_notifications.clear_confirm": "Yes, remove the address", + "settings_notifications.clear_warning": "If you remove the notification e-mail address, you will get no more notification e-mails. Nothing is saved until you confirm.", "settings_notifications.e_mail_cim": "E-mail address", "settings_notifications.elvart_adatbazismentes_elmaradt": "Expected database backup missed", "settings_notifications.elvart_fajlmentes_elmaradt": "Expected file backup missed", @@ -2010,7 +2013,7 @@ "settings_security.uj_jelszo": "New password", "settings_security.uj_jelszo_megerositese": "Confirm new password", "settings_security.utolso": "Last:", - "settings_security.utolso_szinkronizalas": "Last sync: {{.GeoLastSync}}", + "settings_security.utolso_szinkronizalas": "Last sync: {{fmtTimeStr .GeoLastSync}}", "settings_security.veszhelyzeti_informaciok": "Emergency information", "settings_security.visszaallitasi_jelszo": "Retrieval password", "settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "The host agent is not reachable right now — try again later.", @@ -2251,7 +2254,7 @@ "storage.hiba": "Error:", "storage.host_tarhely_rendszer_sablonok_iso": "Host storage — system templates, ISOs, host-level backups. It holds no app data.", "storage.hozzaadas": "Add", - "storage.idopont": "Time: {{.DecommissionedAt}}", + "storage.idopont": "Time: {{fmtTimeStr .DecommissionedAt}}", "storage.inaktiv": "Inactive", "storage.ismeretlen": "unknown", "storage.kesz": "Done", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 5fad0fe..80681cb 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1499,6 +1499,7 @@ "flash.share.disabled": "A megosztás kikapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", "flash.share.enabled": "A megosztás bekapcsolva.", + "flash.share.wrong_password": "Hibás jelszó", "flash.share.not_on": "A megosztás nincs bekapcsolva.", "flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.", "flash.share.pw_cleared": "A megosztási jelszó törölve.", @@ -1873,7 +1874,7 @@ "recovery.adatok": "Adatok", "recovery.adatok_visszaszerzese_felhom": "Adatok visszaszerzése — Felhom", "recovery.alkalmazas": "Alkalmazás", - "recovery.amelyet_zartunk_le": ", amelyet\n {{.}} zártunk le", + "recovery.amelyet_zartunk_le": ", amelyet\n {{fmtTimeStr .}} zártunk le", "recovery.biztosan_nem_kered_vissza_a": "Biztosan nem kéred vissza a korábbi mentéseket?", "recovery.elerhetok": "elérhetők", "recovery.ez_a_lehetoseg_akkor_valik": "ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli\n tárhelyhez. Addig a mentéseid érintetlenül megmaradnak.", @@ -1931,6 +1932,8 @@ "settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések", "settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "Bekapcsolás után az egyes alkalmazásoknál is engedélyezni kell az email-küldést (az alkalmazás oldalán).", "settings_notifications.biztonsagi_mentes_sikertelen": "Biztonsági mentés sikertelen", + "settings_notifications.clear_confirm": "Igen, törlöm a címet", + "settings_notifications.clear_warning": "Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő levelet. A mentés csak akkor történik meg, ha megerősíted.", "settings_notifications.e_mail_cim": "E-mail cím", "settings_notifications.elvart_adatbazismentes_elmaradt": "Elvárt adatbázismentés elmaradt", "settings_notifications.elvart_fajlmentes_elmaradt": "Elvárt fájlmentés elmaradt", @@ -1998,7 +2001,7 @@ "settings_security.uj_jelszo": "Új jelszó", "settings_security.uj_jelszo_megerositese": "Új jelszó megerősítése", "settings_security.utolso": "Utolsó:", - "settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{.GeoLastSync}}", + "settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{fmtTimeStr .GeoLastSync}}", "settings_security.veszhelyzeti_informaciok": "Vészhelyzeti információk", "settings_security.visszaallitasi_jelszo": "Visszaállítási jelszó", "settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később.", @@ -2239,7 +2242,7 @@ "storage.hiba": "Hiba:", "storage.host_tarhely_rendszer_sablonok_iso": "Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.", "storage.hozzaadas": "Hozzáadás", - "storage.idopont": "Időpont: {{.DecommissionedAt}}", + "storage.idopont": "Időpont: {{fmtTimeStr .DecommissionedAt}}", "storage.inaktiv": "Inaktív", "storage.ismeretlen": "ismeretlen", "storage.kesz": "Kész", diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index 0754b5b..cfc0756 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -91,7 +91,7 @@ func BuildReport( // Storage — root filesystem + all registered storage paths r.Storage = []StorageReport{ - {Mount: "/", Label: "SSD", TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent}, + rootStorageEntry(sysInfo), } for _, sp := range storagePaths { if sp.Decommissioned { @@ -328,3 +328,14 @@ func buildStacksReport(stackMgr *stacks.Manager) StacksReport { return sr } + +// rootStorageEntry is the report's "/" entry. When the root statfs failed (DiskKnown false) the +// zeros it carries are not a measurement, so the entry says so (R-266): the hub skips banding it +// instead of reading a failed read as an empty disk. +func rootStorageEntry(sysInfo system.SystemInfo) StorageReport { + return StorageReport{ + Mount: "/", Label: "SSD", + TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent, + ReadFailed: !sysInfo.DiskKnown, + } +} diff --git a/controller/internal/report/storage_readfailed_test.go b/controller/internal/report/storage_readfailed_test.go new file mode 100644 index 0000000..fba688d --- /dev/null +++ b/controller/internal/report/storage_readfailed_test.go @@ -0,0 +1,37 @@ +package report + +import ( + "encoding/json" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// R-266: a failed root statfs reaches the hub as read_failed, not as a 0-of-0 disk the hub would band +// as "0 % used". A measured disk carries no read_failed at all (omitempty), so an old hub sees no change. +func TestRootStorageEntry_ReadFailed(t *testing.T) { + failed := rootStorageEntry(system.SystemInfo{DiskKnown: false}) + if !failed.ReadFailed { + t.Fatal("root statfs failed (DiskKnown false) but the storage entry does not say read_failed") + } + if failed.TotalGB != 0 || failed.UsedGB != 0 || failed.Percent != 0 || failed.Mount != "/" { + t.Errorf("failed entry changed its numbers or mount: %+v", failed) + } + b, _ := json.Marshal(failed) + if !strings.Contains(string(b), `"read_failed":true`) { + t.Errorf("wire form lacks read_failed: %s", b) + } + + ok := rootStorageEntry(system.SystemInfo{DiskKnown: true, DiskTotalGB: 100, DiskUsedGB: 40, DiskPercent: 40}) + if ok.ReadFailed { + t.Error("a measured root disk is marked read_failed") + } + b, _ = json.Marshal(ok) + if strings.Contains(string(b), "read_failed") { + t.Errorf("a measured disk must not carry read_failed on the wire: %s", b) + } + if ok.TotalGB != 100 || ok.Percent != 40 { + t.Errorf("measured numbers lost: %+v", ok) + } +} diff --git a/controller/internal/report/types.go b/controller/internal/report/types.go index f20ff7c..5ad5391 100644 --- a/controller/internal/report/types.go +++ b/controller/internal/report/types.go @@ -94,6 +94,11 @@ type StorageReport struct { Disconnected bool `json:"disconnected,omitempty"` Decommissioned bool `json:"decommissioned,omitempty"` MigratedTo string `json:"migrated_to,omitempty"` + // ReadFailed (R-266, v0.308.0) — the measurement behind this entry FAILED (today: the root "/" + // statfs, SystemInfo.DiskKnown false). The numbers stay the zeros the failed read left; the hub + // must not band them as "0 % used". Absent (false) means the numbers were measured. Pinned by + // TestRootStorageEntry_ReadFailed. + ReadFailed bool `json:"read_failed,omitempty"` } // ContainerReport holds aggregate and per-container status. diff --git a/controller/internal/web/a_version_travel_test.go b/controller/internal/web/a_version_travel_test.go index 2d80534..ab92a9d 100644 --- a/controller/internal/web/a_version_travel_test.go +++ b/controller/internal/web/a_version_travel_test.go @@ -16,7 +16,7 @@ func TestA3_RestoredVersionSentence(t *testing.T) { s.versionPosition = func(string) (bool, bool) { return true, true } got := s.restoredVersionPrefix("docmost", true, pins, at) - for _, want := range []string{"A(z) docmost visszaállt a(z) 2026-09-26 ", "-i mentésből", "docmost:0.96.0, postgres:16-alpine, redis:7-alpine verzióra", "lépésenként hozza naprakészre"} { + for _, want := range []string{"A(z) docmost visszaállt a(z) 2026. 09. 26. ", "-i mentésből", "docmost:0.96.0, postgres:16-alpine, redis:7-alpine verzióra", "lépésenként hozza naprakészre"} { if !strings.Contains(got, want) { t.Errorf("hu: missing %q in %q", want, got) } diff --git a/controller/internal/web/backups_remote_statsknown_test.go b/controller/internal/web/backups_remote_statsknown_test.go index 45d6534..01e3463 100644 --- a/controller/internal/web/backups_remote_statsknown_test.go +++ b/controller/internal/web/backups_remote_statsknown_test.go @@ -81,7 +81,7 @@ func TestBackupsRemote_KnownNonEmptyStoreRendersTheNumbers(t *testing.T) { if !strings.Contains(html, "2 pillanatkép") { t.Fatalf("a measured count must render") } - if !strings.Contains(html, "12.0 MB / 50 GB") { + if !strings.Contains(html, "12,0 MB / 50 GB") { // R-562: decimal comma on a Hungarian page t.Fatalf("a measured size must render against the quota") } if strings.Contains(html, "még ismeretlen") || strings.Contains(html, "még nem tudjuk") { diff --git a/controller/internal/web/dashboard_backup_card_test.go b/controller/internal/web/dashboard_backup_card_test.go index a6aeb52..01228f9 100644 --- a/controller/internal/web/dashboard_backup_card_test.go +++ b/controller/internal/web/dashboard_backup_card_test.go @@ -113,7 +113,7 @@ func TestDashboardBackupCard_ShowsLastRun(t *testing.T) { } card := backupCard(t, rec.Body.String()) - want := dumpAt.Format("2006-01-02 15:04") + want := dumpAt.Format("2006. 01. 02. 15:04") // R-562: the Hungarian date if !strings.Contains(card, want) { t.Errorf("card must show the real last run %q, got:\n%s", want, card) } diff --git a/controller/internal/web/dashboard_diskknown_test.go b/controller/internal/web/dashboard_diskknown_test.go index ab4c45b..9ad554e 100644 --- a/controller/internal/web/dashboard_diskknown_test.go +++ b/controller/internal/web/dashboard_diskknown_test.go @@ -90,7 +90,8 @@ func TestDiskMeter_KnownIsUnchanged(t *testing.T) { out := renderDiskMeter(t, healthyDisk()) // fmtGB: >=100 renders whole, >=10 renders one decimal (funcmap.go:208-215) - for _, want := range []string{"42.0 GB", "100 GB", "(42%)", "meter-fill", "nominal"} { + for _, want := range []string{"42,0 GB", "100 GB", // R-562: decimal comma on a Hungarian page + "(42%)", "meter-fill", "nominal"} { if !strings.Contains(out, want) { t.Errorf("a healthy box lost %q from its meter — this change must be invisible on a "+ "machine that is fine.\n%s", want, out) diff --git a/controller/internal/web/disk_health_state.go b/controller/internal/web/disk_health_state.go index c6ca57b..dd20ad1 100644 --- a/controller/internal/web/disk_health_state.go +++ b/controller/internal/web/disk_health_state.go @@ -238,7 +238,7 @@ func diskAlertKindFor(sm *agentapi.SmartSummary, v agentapi.DiskVerdict, worsene switch { case sm != nil && sm.Health == agentapi.SmartFailing: return notify.DiskAlertFailSelfReported - case sm != nil && sm.TemperatureC != nil && *sm.TemperatureC >= agentapi.TemperatureFailC: + case sm != nil && !agentapi.IsNVMe(sm) && sm.TemperatureC != nil && *sm.TemperatureC >= agentapi.TemperatureFailC: return notify.DiskAlertFailTemperature case agentapi.UncorrectableSectors(sm) > 0 && worsened: return notify.DiskAlertFailWorsened diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index 107f392..fd32750 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -11,6 +11,7 @@ import ( "unicode" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -99,7 +100,7 @@ func isOperationalState(state stacks.ContainerState) bool { func (s *Server) templateFuncMap() template.FuncMap { loc := getTimezone() - return template.FuncMap{ + fm := template.FuncMap{ // stateColor maps container states to design-system-v2 semantic tokens, // consumed as class suffixes (tag-run, stack-state-run, state-text-run...). // Exception-color principle: a customer-stopped app is neutral, NOT red — @@ -200,25 +201,6 @@ func (s *Server) templateFuncMap() template.FuncMap { } return "nominal" }, - "fmtMB": func(mb uint64) string { - if mb >= 1024 { - gb := float64(mb) / 1024.0 - if gb >= 10 { - return fmt.Sprintf("%.0f GB", gb) - } - return fmt.Sprintf("%.1f GB", gb) - } - return fmt.Sprintf("%d MB", mb) - }, - "fmtGB": func(gb float64) string { - if gb >= 100 { - return fmt.Sprintf("%.0f GB", gb) - } - if gb >= 10 { - return fmt.Sprintf("%.1f GB", gb) - } - return fmt.Sprintf("%.2f GB", gb) - }, "subtract": func(a, b int) int { r := a - b if r < 0 { @@ -249,9 +231,6 @@ func (s *Server) templateFuncMap() template.FuncMap { "fmtTemp": func(celsius float64) string { return fmt.Sprintf("%.0f°C", celsius) }, - "fmtLoad": func(load float64) string { - return fmt.Sprintf("%.2f", load) - }, "filterCategory": func(state stacks.ContainerState, deployed bool) string { switch state { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: @@ -309,37 +288,8 @@ func (s *Server) templateFuncMap() template.FuncMap { return fmt.Sprintf("%d napja", int(d.Hours()/24)) } }, - // fmtTimeStr renders an RFC3339 STRING as an absolute Budapest-local date-time. R-101: the - // Tier-2 restore confirm dialog printed a raw RFC3339 stamp ("2026-07-28T01:30:00Z") at the - // moment the customer decides whether to restore — a UTC machine timestamp is not something a - // customer can reason about. Absolute rather than relative here on purpose: "3 napja" is fine on - // a status card, but a restore decision deserves the actual date. - // - // R-102: it delegates to the package-level fmtRFC3339Local so a HANDLER can render the same - // date the same way. The Tier-2 unit restore names the copy's date in its confirm (template) - // and again in its outcome (Go); two renderings of one decision that could disagree is how a - // customer ends up confirming one date and being told another. - "fmtTimeStr": fmtRFC3339Local, - "fmtTime": func(t time.Time) string { - if t.IsZero() { - return "–" - } - return t.In(loc).Format("2006-01-02 15:04") - }, - // humanBytes (v0.200.0, R-193) renders a size the same way every other surface does — the - // recovery page's listing shares the backup package's formatter rather than growing a second one. - "humanBytes": backup.HumanizeBytes, - "fmtTimeShort": func(t time.Time) string { - if t.IsZero() { - return "–" - } - lt := t.In(loc) - now := time.Now().In(loc) - if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() { - return lt.Format("15:04") - } - return lt.Format("01-02 15:04") - }, + // fmtTime / fmtTimeStr / fmtTimeShort / fmtMB / fmtGB / fmtBytes / fmtLoad / humanBytes: + // formatFuncs below (R-562 — per language). "dbTypeLabel": func(t backup.DBType) string { switch t { case backup.DBTypePostgres: @@ -363,7 +313,7 @@ func (s *Server) templateFuncMap() template.FuncMap { if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 { return "holnap " + timeStr } - return lt.Format("2006-01-02") + " " + timeStr + return i18n.FormatDate(i18n.Default, lt) + " " + timeStr }, // R-572 (v0.258.0) — `pruneLabel` and `nextPruneLabel` WERE HERE, and they are deleted // rather than translated. The row said "a template calling either renders Hungarian on an @@ -384,23 +334,6 @@ func (s *Server) templateFuncMap() template.FuncMap { } return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) }, - "fmtBytes": func(b int64) string { - const ( - kb = 1024 - mb = 1024 * kb - gb = 1024 * mb - ) - switch { - case b >= int64(gb): - return fmt.Sprintf("%.1f GB", float64(b)/float64(gb)) - case b >= int64(mb): - return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) - case b >= int64(kb): - return fmt.Sprintf("%.1f KB", float64(b)/float64(kb)) - default: - return fmt.Sprintf("%d B", b) - } - }, "shortID": func(id string) string { if len(id) > 8 { return id[:8] @@ -485,17 +418,112 @@ func (s *Server) templateFuncMap() template.FuncMap { return false }, } + // R-562: the date, size and number formatters, in Hungarian here; loadTemplates swaps in each + // language's own set (formatFuncs). + for name, fn := range formatFuncs(i18n.Default) { + fm[name] = fn + } + return fm } -// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time, returning the -// input unchanged when it does not parse. The ONE implementation behind the `fmtTimeStr` template -// helper and every Go-side caller. See the comment on the funcmap entry for why it is shared. -func fmtRFC3339Local(s string) string { +// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time in lang (R-562), +// returning the input unchanged when it does not parse. The ONE implementation behind the +// `fmtTimeStr` template helper and every Go-side caller. +// +// R-101: the Tier-2 restore confirm dialog printed a raw RFC3339 stamp at the moment the customer +// decides whether to restore — a UTC machine timestamp is not something a customer can reason about. +// R-102: a HANDLER renders the same date the same way (the confirm in the template and the outcome in +// Go name one copy's date; two renderings that could disagree is how a customer confirms one date and +// is told another). +func fmtRFC3339Local(lang, s string) string { t, err := time.Parse(time.RFC3339, s) if err != nil { + // A date another package already wrote as "2006-01-02 15:04" (appexport's bundle list) is + // re-laid in lang, its clock untouched. + if lt, err2 := time.Parse("2006-01-02 15:04", s); err2 == nil { + return i18n.FormatDateTime(lang, lt) + } return s } - return t.In(getTimezone()).Format("2006-01-02 15:04") + return i18n.FormatDateTime(lang, t.In(getTimezone())) +} + +// fmtLocalTime renders a time as a Budapest-local date-time in lang (R-562). "–" for a zero time. +func fmtLocalTime(lang string, t time.Time) string { + if t.IsZero() { + return "–" + } + return i18n.FormatDateTime(lang, t.In(getTimezone())) +} + +// fmtSizeBytes is the dashboard's B/KB/MB/GB size, one decimal, in lang's decimal separator. +func fmtSizeBytes(lang string, b int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case b >= int64(gb): + return i18n.FormatDecimal(lang, float64(b)/float64(gb), 1) + " GB" + case b >= int64(mb): + return i18n.FormatDecimal(lang, float64(b)/float64(mb), 1) + " MB" + case b >= int64(kb): + return i18n.FormatDecimal(lang, float64(b)/float64(kb), 1) + " KB" + default: + return fmt.Sprintf("%d B", b) + } +} + +// formatFuncs (R-562, `09` §3 decision 223) — every template func that prints a date, a size or a +// decimal number, for ONE language: Hungarian „2026. 10. 10. 15:04" and a decimal comma, English +// „2026-10-10 15:04" and a decimal point. The layouts live in internal/i18n (FormatDateTime and +// siblings); this map only binds them to the names the templates call. Pinned by +// TestFormatFuncs_PerLanguage. +func formatFuncs(lang string) template.FuncMap { + loc := getTimezone() + return template.FuncMap{ + "fmtMB": func(mb uint64) string { + if mb >= 1024 { + gb := float64(mb) / 1024.0 + if gb >= 10 { + return i18n.FormatDecimal(lang, gb, 0) + " GB" + } + return i18n.FormatDecimal(lang, gb, 1) + " GB" + } + return fmt.Sprintf("%d MB", mb) + }, + "fmtGB": func(gb float64) string { + if gb >= 100 { + return i18n.FormatDecimal(lang, gb, 0) + " GB" + } + if gb >= 10 { + return i18n.FormatDecimal(lang, gb, 1) + " GB" + } + return i18n.FormatDecimal(lang, gb, 2) + " GB" + }, + "fmtLoad": func(load float64) string { return i18n.FormatDecimal(lang, load, 2) }, + "fmtTimeStr": func(s string) string { return fmtRFC3339Local(lang, s) }, + "fmtTime": func(t time.Time) string { return fmtLocalTime(lang, t) }, + // humanBytes (v0.200.0, R-193) shares the backup package's formatter so the recovery page's + // listing reads like every other size; its decimal point follows the language here. + "humanBytes": func(n int64) string { return i18n.LocalizeDecimals(lang, backup.HumanizeBytes(n)) }, + "fmtTimeShort": func(t time.Time) string { + if t.IsZero() { + return "–" + } + lt := t.In(loc) + now := time.Now().In(loc) + if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() { + return lt.Format("15:04") + } + return i18n.FormatMonthDayTime(lang, lt) + }, + "fmtBytes": func(b int64) string { return fmtSizeBytes(lang, b) }, + // numText rewrites the decimal point of a size another package already formatted („1.5 GB", + // the *Human fields) into this language's separator. + "numText": func(s string) string { return i18n.LocalizeDecimals(lang, s) }, + } } // stackOf lets the update-path template funcs take a stack the way each page holds it (app_info: a diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7c9521c..48218b4 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -567,7 +567,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri dp.NotAllowedNote = s.msg(r, "deploy.path_not_allowed") } if di := system.GetDiskUsage(sp.Path); di != nil { - dp.FreeHuman = formatFreeSpace(di.AvailGB) + dp.FreeHuman = formatFreeSpace(s.langFor(r), di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = 100 - system.DFUsedPercent(di.UsedGB, di.AvailGB) // R-889: df's number } @@ -601,8 +601,8 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri if !alreadyDeployed { if hr := system.GetDockerVolumeHeadroom(); hr.OK { data["DockerBelowReserve"] = hr.BelowReserve - data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB) - data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB) + data["DockerFreeHuman"] = formatFreeSpace(s.langFor(r), hr.AvailGB) + data["DockerReserveHuman"] = formatFreeSpace(s.langFor(r), hr.ReserveGB) } } @@ -889,7 +889,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // in the REQUEST's language (a finished sentence passed as page data must be rendered per request, // never taken from a Hungarian store — the composed-sentence class, R-573/R-590/R-596/R-598). if found.Deployed && found.AppConfig != nil && found.AppConfig.LastUpdateUndone != nil { - data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(found.AppConfig.LastUpdateUndone.At)) + data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(s.langFor(r), found.AppConfig.LastUpdateUndone.At)) } // v0.271.0 (`09` §6.4 part 7): a SUCCESSFUL automatic update is told on the page only — no mail. // An undone or held automatic step keeps the lines above (they already say it), so this line is for @@ -897,7 +897,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // a restore makes it history, and the page stops saying it). if found.Deployed && found.AppConfig != nil && found.AppConfig.LastAutoUpdate != nil && found.AppConfig.LastAutoUpdate.Outcome == stacks.LegOutcomeDone && sameImageMap(found.AppConfig.LastAutoUpdate.To, found.AppConfig.PinnedImages) { - data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(found.AppConfig.LastAutoUpdate.At)) + data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(s.langFor(r), found.AppConfig.LastAutoUpdate.At)) } // R-621: a held app's own log, kept by the hold before its containers were removed, is reachable @@ -1037,7 +1037,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) { } if s.backupMgr != nil && offboxTgt != nil && offboxTgt.QuotaGB > 0 && s.backupMgr.OffboxConfigured() { if f := s.backupMgr.OffsiteFitForPage(); !f.At.IsZero() && !f.Fits { - data["OffsiteFitTotal"] = appbackup.HumanizeBytes(f.TotalBytes) + data["OffsiteFitTotal"] = i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(f.TotalBytes)) data["OffsiteFitQuota"] = offboxTgt.QuotaGB var top []string for i, a := range f.Apps { @@ -1048,7 +1048,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) { if st, ok := s.stackMgr.GetStack(a.Stack); ok && st.Meta.DisplayName != "" { name = st.Meta.DisplayName } - top = append(top, name+" ("+appbackup.HumanizeBytes(a.Bytes)+")") + top = append(top, name+" ("+i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(a.Bytes))+")") } data["OffsiteFitTop"] = strings.Join(top, ", ") } @@ -1164,7 +1164,7 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { if st := s.backupMgr.AbandonStatus(); st.Active { data["AbandonActive"] = true data["AbandonDaysLeft"] = st.DaysLeft - data["AbandonDate"] = st.DueAt.Format("2006-01-02") + data["AbandonDate"] = i18n.FormatDate(s.langFor(r), st.DueAt) // R-365: DaysLeft is a ceiling, so 0 means the due moment has PASSED (the terminal step waits // for the next daily sweep, up to ~29 h). The future-tense „… napján véglegesen töröljük" then // named a date in the past; the overdue sentence says the deletion is due and when it runs. @@ -1685,7 +1685,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale) if stale && pkgDate != "" { - row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate)) + row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(lang, pkgDate)) } } switch cd.LastStatus { @@ -1927,7 +1927,7 @@ func (s *Server) restoredVersionPrefix(app string, changed bool, pins []string, if !climbable { key = restoredOlderVersionNoStepKey } - return s.note(key, app, at.In(getTimezone()).Format("2006-01-02 15:04"), backup.PinsVersion(pins)) + " " + return s.note(key, app, fmtLocalTime(s.boxLang(), at), backup.PinsVersion(pins)) + " " } const ( @@ -2065,9 +2065,9 @@ func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, sta msg := s.note(tier2UnitConfirmBaseKey) if copyDate != "" { if proven { - msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate)) + msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(s.boxLang(), copyDate)) } else { - msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate)) + msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(s.boxLang(), copyDate)) } } msg += s.note(tier2UnitConfirmContrastKey) @@ -2093,9 +2093,9 @@ func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string { return "" } if proven { - return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date)) + return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(s.boxLang(), date)) } - return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date)) + return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(s.boxLang(), date)) } // backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its @@ -2330,7 +2330,7 @@ func (s *Server) systemPageData() map[string]interface{} { // R-724: the box's local time, not a raw RFC3339 stamp. data["LastCheckTime"] = status.LastCheck.CheckedAt if t, err := time.Parse(time.RFC3339, status.LastCheck.CheckedAt); err == nil { - data["LastCheckTime"] = t.In(getTimezone()).Format("2006-01-02 15:04") + data["LastCheckTime"] = fmtLocalTime(s.boxLang(), t) } data["LastCheckError"] = status.LastCheck.Error } @@ -2593,6 +2593,18 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques } } } + // R-255 (v0.308.0): an optional-config `secret_input` (a key the household typed in for the app, e.g. a + // metadata provider's API key) is no longer rendered into the settings page; it is revealed here on demand, + // behind the same session + CSRF as the auto-generated secrets. + if !allowed { + for _, g := range stack.Meta.OptionalConfig { + for _, f := range g.Fields { + if f.EnvVar == envVar && f.Type == "secret_input" { + allowed = true + } + } + } + } 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, s.msg(r, "escrow.field_not_revealable")) @@ -2945,6 +2957,24 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req return } + // R-922 warning (operator ruling 2026-10-12, S1, `09` §3 decision 196): clearing a STORED address is said + // first — „you will get no more notification mails" — and nothing is saved or sent until the household + // confirms with the page's own button (confirm_clear=1). Server-side, so it holds without JavaScript and + // for any client. A box whose address is already empty has nothing to warn about. Pinned by + // TestR922_ClearWarnsFirst. + if cur := s.settings.GetNotificationPrefs(); email == "" && cur != nil && cur.Email != "" && r.FormValue("confirm_clear") != "1" { + s.logger.Printf("[INFO] [web] Notification address clear: asking the household to confirm first (nothing saved)") + data := s.notificationsPageData() + data["NotificationPrefs"] = &settings.NotificationPrefs{ + Email: email, + EnabledEvents: enabledEvents, + CooldownHours: cooldownHours, + } + data["NotificationClearConfirm"] = true + s.executeTemplate(w, r, "settings_notifications", data) + return + } + // R-922 (operator ruling 2026-10-09, option A): an empty address where one was stored is the household's // deliberate clear — persisted, so this push AND every later one carry `email_cleared` while the address // stays empty, and the hub deletes the address it holds. A box that never had one does not set it. @@ -3278,7 +3308,7 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail { } } if any { - detail.SizeHuman = humanizeDirBytes(total) + detail.SizeHuman = fmtSizeBytes(s.boxLang(), total) } details = append(details, detail) } @@ -3322,11 +3352,11 @@ func dirSizeHuman(path string) string { return humanizeDirBytes(dirSizeBytesWalk(path)) } -func formatFreeSpace(gb float64) string { +func formatFreeSpace(lang string, gb float64) string { if gb >= 1000 { - return fmt.Sprintf("%.1f TB", gb/1024) + return i18n.FormatDecimal(lang, gb/1024, 1) + " TB" } - return fmt.Sprintf("%.1f GB", gb) + return i18n.FormatDecimal(lang, gb, 1) + " GB" } func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) { @@ -3819,10 +3849,6 @@ func sameImageMap(a, b map[string]string) bool { return true } -func undoneWhen(rfc3339 string) string { - t, err := time.Parse(time.RFC3339, rfc3339) - if err != nil { - return rfc3339 - } - return t.In(getTimezone()).Format("2006-01-02 15:04") +func undoneWhen(lang, rfc3339 string) string { + return fmtRFC3339Local(lang, rfc3339) } diff --git a/controller/internal/web/i18n_cases_a_test.go b/controller/internal/web/i18n_cases_a_test.go index e6f4915..dbd9126 100644 --- a/controller/internal/web/i18n_cases_a_test.go +++ b/controller/internal/web/i18n_cases_a_test.go @@ -267,6 +267,14 @@ func i18nCasesA() []i18nCase { d["AppEmailSuccess"], d["AppEmailError"] = "Az e-mail beállítás mentve.", "Az e-mail beállítás hibás." return d }}, + // v0.308.0 (R-922 warning, decision 196): the clear-address warning and its confirm button, captured when born. + {"settings_notifications_clear_confirm", "settings_notifications", func() map[string]interface{} { + d := i18nLayoutData("settings-notifications", "Értesítések") + d["HubEnabled"] = true + d["NotificationClearConfirm"] = true + d["NotificationPrefs"] = map[string]interface{}{"Email": "", "EnabledEvents": []string{}, "CooldownHours": 6} + return d + }}, {"settings_notifications_nohub", "settings_notifications", func() map[string]interface{} { d := i18nLayoutData("settings-notifications", "Értesítések") d["HubEnabled"] = false diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index 0d5f169..1c870bf 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -356,7 +356,7 @@ func (s *Server) localeFuncs(lang string) template.FuncMap { if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 { return fmt.Sprintf(b.Msg(lang, "func.time.tomorrow_at"), hm) } - return lt.Format("2006-01-02") + " " + hm + return i18n.FormatDate(lang, lt) + " " + hm }, // infraMeta: the protected infra stacks' curated identity (inframeta.go). Names that are product // names stay; descriptions — and samba's display name — come from the bundle. diff --git a/controller/internal/web/kept_handlers.go b/controller/internal/web/kept_handlers.go index 6e7cb13..a2a3ee1 100644 --- a/controller/internal/web/kept_handlers.go +++ b/controller/internal/web/kept_handlers.go @@ -13,6 +13,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/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -79,7 +80,7 @@ func (s *Server) keptBackupFor(lang string, it stacks.KeptItem, off map[string]b // A dated folder's own unit is the copy taken with those files. if it.UnitDir != "" { if c, ok := s.backupMgr.KeptCopyAt(it.UnitDir, it.Drive, 1); ok { - return s.msgLang(lang, "kept.backup.with", c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c, true + return s.msgLang(lang, "kept.backup.with", fmtLocalTime(lang, c.Time)), c, true } } } else { @@ -90,7 +91,7 @@ func (s *Server) keptBackupFor(lang string, it stacks.KeptItem, off map[string]b if !ok { return none, backup.KeptCopy{}, false } - return s.msgLang(lang, backup.KeptCopyKey(c.Tier), c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c, true + return s.msgLang(lang, backup.KeptCopyKey(c.Tier), fmtLocalTime(lang, c.Time)), c, true } // keptOffsite asks the off-site repository ONCE for every listed app. @@ -117,8 +118,8 @@ func (s *Server) keptPageHandler(w http.ResponseWriter, r *http.Request) { backupName, _, can := s.keptBackupFor(lang, it, off) row := keptRow{ DisplayName: it.DisplayName, App: it.App, Path: it.Path, Drive: it.Drive, - Date: it.Date.In(getTimezone()).Format("2006-01-02 15:04"), - Size: appbackup.HumanizeBytes(it.SizeBytes), Backup: backupName, CanLoad: can && it.App != "", + Date: fmtLocalTime(lang, it.Date), + Size: i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(it.SizeBytes)), Backup: backupName, CanLoad: can && it.App != "", LookURL: fileBrowserLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)), } row.DeleteText = s.msgLang(lang, "kept.delete.confirm", it.DisplayName, row.Size) diff --git a/controller/internal/web/lang_visitor_test.go b/controller/internal/web/lang_visitor_test.go index 00afc14..ef5d2d9 100644 --- a/controller/internal/web/lang_visitor_test.go +++ b/controller/internal/web/lang_visitor_test.go @@ -254,27 +254,80 @@ func TestGlobeOnAnonymousShells(t *testing.T) { } } -// TestGuestSharePagesHaveNoGlobe — the pages a STRANGER meets. Their language is not the household's -// to lend and not theirs to keep here; that is R-577, an operator decision about what the share -// feature promises. Pinned so it stays a decision rather than an oversight. -func TestGuestSharePagesHaveNoGlobe(t *testing.T) { +// TestGuestSharePagesHaveGlobe — R-577 (operator ruling 2026-10-12, `09` §3 decision 207): the pages a +// STRANGER meets through a share link carry the language globe, and it is the VISITOR's: it posts to /lang +// (their own display cookie, no CSRF, never the household's setting). The catch-all page is not a share +// page and stays without one. +// +// RED-PROOF (REPORT.md): remove the globe line from launcher_shared.html → the launcher_shared_apps case +// fails with "carries no language globe". +func TestGuestSharePagesHaveGlobe(t *testing.T) { cases := map[string]i18nCase{} for _, c := range i18nCases() { cases[c.name] = c } - for _, name := range []string{"launcher_shared_apps", "launcher_share_password", "catchall_unknown"} { - c, ok := cases[name] + for _, tc := range []struct { + name string + globe bool + }{{"launcher_shared_apps", true}, {"launcher_share_password", true}, {"catchall_unknown", false}} { + c, ok := cases[tc.name] if !ok { - t.Fatalf("no parity case %q — this test no longer covers what it names", name) + t.Fatalf("no parity case %q — this test no longer covers what it names", tc.name) } s := i18nTestServer(t) var b strings.Builder - if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/x"), c.tmpl, c.data()); err != nil { + if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/s/tok"), c.tmpl, c.data()); err != nil { t.Fatal(err) } - if strings.Contains(b.String(), "lang-globe") { - t.Errorf("%s carries a language globe — R-577 is the decision that would put one there", name) + got := b.String() + if !tc.globe { + if strings.Contains(got, "lang-globe") { + t.Errorf("%s carries a language globe — it is not a share page", tc.name) + } + continue } + if !strings.Contains(got, "lang-globe") { + t.Errorf("%s carries no language globe (R-577)", tc.name) + continue + } + if !strings.Contains(got, `action="/lang"`) || strings.Contains(got, `action="/settings/language"`) { + t.Errorf("%s: the globe does not post to the visitor's own switch (/lang)", tc.name) + } + if !strings.Contains(got, `name="back" value="/s/tok"`) { + t.Errorf("%s: the globe does not bring the visitor back to the share page", tc.name) + } + } +} + +// R-577: a share visitor's own cookie decides the page's language, over the household's setting, on the +// real route (/s/ through the full mux). A Hungarian household; the visitor picked English. +func TestGuestSharePage_HonoursVisitorCookie(t *testing.T) { + s := shareTestServer(t) + if err := s.settings.SetLauncherShareToken(testShareToken); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + cookie, wantLang string + }{{"", "hu"}, {"en", "en"}, {"hu", "hu"}} { + req := httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil) + if tc.cookie != "" { + req.AddCookie(langCookie(tc.cookie)) + } + rr := httptest.NewRecorder() + s.fullMux().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /s/ = %d", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `{{.RetrievalPassword}}` into settings_security.html → +// this fails naming the page and the planted field. + +var secretNameRe = regexp.MustCompile(`(?i)(pass(word|phrase)|secret|token|api_?key|private_?key|recovery_?code|credential|_key$)`) + +// notSecretNames are names the pattern matches that carry no secret: the CSRF tokens a form must carry +// (session-bound, useless without the session) and a password-change ERROR MESSAGE. +var notSecretNames = map[string]bool{ + "CSRFToken": true, "CSRFField": true, "FormToken": true, "LangCSRF": true, "PasswordError": true, +} + +const plantedPrefix = "PLANTEDSECRET" + +type planter struct { + n int + where map[string]string // sentinel -> field path +} + +func (p *planter) next(path string) string { + p.n++ + v := fmt.Sprintf("%s%03dX", plantedPrefix, p.n) + p.where[v] = path + return v +} + +func isSecretName(name string) bool { + return !notSecretNames[name] && secretNameRe.MatchString(name) +} + +// plant returns a copy of v in which every string under a secret-named field or key is a sentinel. +func (p *planter) plant(v reflect.Value, path string, secretCtx bool) reflect.Value { + if !v.IsValid() { + return v + } + switch v.Kind() { + case reflect.String: + if secretCtx { + out := reflect.New(v.Type()).Elem() + out.SetString(p.next(path)) + return out + } + return v + case reflect.Interface: + if v.IsNil() { + return v + } + inner := p.plant(v.Elem(), path, secretCtx) + out := reflect.New(v.Type()).Elem() + out.Set(inner) + return out + case reflect.Ptr: + if v.IsNil() { + return v + } + inner := p.plant(v.Elem(), path, secretCtx) + out := reflect.New(v.Type().Elem()) + out.Elem().Set(inner) + return out + case reflect.Map: + if v.IsNil() { + return v + } + out := reflect.MakeMapWithSize(v.Type(), v.Len()) + for _, k := range v.MapKeys() { + name := fmt.Sprint(k.Interface()) + out.SetMapIndex(k, p.plant(v.MapIndex(k), path+"."+name, isSecretName(name))) + } + return out + case reflect.Slice: + if v.IsNil() { + return v + } + out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) + for i := 0; i < v.Len(); i++ { + out.Index(i).Set(p.plant(v.Index(i), fmt.Sprintf("%s[%d]", path, i), secretCtx)) + } + return out + case reflect.Struct: + out := reflect.New(v.Type()).Elem() + out.Set(v) + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + if f.PkgPath != "" { // unexported: cannot be set, and a template cannot read it either + continue + } + out.Field(i).Set(p.plant(v.Field(i), path+"."+f.Name, isSecretName(f.Name))) + } + return out + } + return v +} + +// pageTemplates lists every template file that defines a template carrying its own base name. +func pageTemplates(t *testing.T) []string { + t.Helper() + names, err := fs.Glob(templateFS, "templates/*.html") + if err != nil { + t.Fatal(err) + } + var pages []string + for _, n := range names { + raw, err := templateFS.ReadFile(n) + if err != nil { + t.Fatal(err) + } + base := strings.TrimSuffix(path.Base(n), ".html") + src := string(raw) + // A PAGE is a whole document: its own shell, or the dashboard chrome. A partial that + // happens to carry its file's name (lang_globe, meta_badge) is rendered inside pages, not alone. + whole := strings.Contains(src, "`) + shown := regexp.MustCompile(``) + return strings.Count(html, sentinel) == len(hidden.FindAllString(html, -1))+len(shown.FindAllString(html, -1)) +} + +// R-255's finding, fixed in the same session: an optional-config `secret_input` (a key the household typed in +// for its app) was rendered into `value="…"` of its settings page. It is now revealed on demand. The reveal +// endpoint serves it to the household's session and still refuses a field that is not a secret_input. +// +// COMPANION RED-PROOF (REPORT.md): drop the secret_input branch in appAutoFieldRevealHandler → the first case +// 403s, and the household can no longer see the key it saved. +func TestR255_OptionalSecretIsRevealedOnDemand(t *testing.T) { + s := gateHarness(t) + app := filepath.Join(s.cfg.Paths.StacksDir, "gapp") + meta := "display_name: G\nslug: gapp\noptional_config:\n - group: Kulso\n fields:\n - env_var: META_API_KEY\n type: secret_input\n - env_var: META_REGION\n type: text\n" + if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + const key = "TESTONLY-optional-api-key-Q7" + if err := stacks.SaveAppConfig(app, &stacks.AppConfig{Deployed: true, Env: map[string]string{"META_API_KEY": key, "META_REGION": "eu"}}, nil, nil); err != nil { + t.Fatal(err) + } + if err := s.stackMgr.ScanStacks(); err != nil { + t.Fatal(err) + } + ask := func(field string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/stacks/gapp/auto-field/reveal", strings.NewReader("env_var="+field)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.appAutoFieldRevealHandler(w, r, "gapp") + return w + } + if w := ask("META_API_KEY"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), key) { + t.Fatalf("the saved optional key is not revealable: %d", w.Code) + } + if w := ask("META_REGION"); w.Code != http.StatusForbidden { + t.Fatalf("a non-secret optional field went through the secret reveal: %d", w.Code) + } + if w := ask("NOPE"); w.Code != http.StatusForbidden { + t.Fatalf("an unknown field: %d", w.Code) + } +} diff --git a/controller/internal/web/r365_abandon_overdue_test.go b/controller/internal/web/r365_abandon_overdue_test.go index 04039a6..ba03334 100644 --- a/controller/internal/web/r365_abandon_overdue_test.go +++ b/controller/internal/web/r365_abandon_overdue_test.go @@ -36,7 +36,7 @@ func TestR365_OverdueCountdownIsNotFutureTense(t *testing.T) { overdue := time.Now().Add(-20 * time.Hour) html := render(t, overdue) - date := overdue.UTC().Format("2006-01-02") + date := overdue.UTC().Format("2006. 01. 02.") // R-562: the Hungarian date if !strings.Contains(html, "óta esedékes") || !strings.Contains(html, date) { t.Errorf("R-365: an overdue countdown does not say the deletion is due since %s", date) } diff --git a/controller/internal/web/r365_banner_overdue_test.go b/controller/internal/web/r365_banner_overdue_test.go index 672b87e..64ecc00 100644 --- a/controller/internal/web/r365_banner_overdue_test.go +++ b/controller/internal/web/r365_banner_overdue_test.go @@ -43,7 +43,7 @@ func TestR365_BannerSaysDueAtZeroDays(t *testing.T) { overdue := time.Now().Add(-20 * time.Hour) bar := render(t, overdue) - if !strings.Contains(bar, "óta esedékes") || !strings.Contains(bar, overdue.UTC().Format("2006-01-02")) { + if !strings.Contains(bar, "óta esedékes") || !strings.Contains(bar, overdue.UTC().Format("2006. 01. 02.")) { // R-562 t.Errorf("R-365: at 0 days the bar does not say the deletion is due:\n%s", bar) } if strings.Contains(bar, "remind-optout") { diff --git a/controller/internal/web/r403_surface_test.go b/controller/internal/web/r403_surface_test.go index 3e795e6..1b10628 100644 --- a/controller/internal/web/r403_surface_test.go +++ b/controller/internal/web/r403_surface_test.go @@ -32,8 +32,8 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) { t.Error("the preserved-package notice is not on the page — a preserved copy renders as a fresh one") } // The PACKAGE's date is shown, not only the run's. - if !strings.Contains(html, fmtRFC3339Local(pkgDate)) { - t.Errorf("the package's own date %q is not on the page", fmtRFC3339Local(pkgDate)) + if !strings.Contains(html, fmtRFC3339Local("hu", pkgDate)) { + t.Errorf("the package's own date %q is not on the page", fmtRFC3339Local("hu", pkgDate)) } // NEGATIVE CONTROL: an ordinary row must NOT carry the notice, or D1 would pass on a page that @@ -49,7 +49,7 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) { } func staleNoticeFor(t *testing.T, pkgDate string) string { - return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate)) + return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local("hu", pkgDate)) } // D2 — TestR403_UnitRestoreOfferNamesTheOlderPackageDate. @@ -64,7 +64,7 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) { if !strings.Contains(staleConfirm, noteHU(t, tier2UnitStaleClauseKey)) { t.Error("the confirm does not say the package is older than the newest run") } - if !strings.Contains(staleConfirm, fmtRFC3339Local(pkgDate)) { + if !strings.Contains(staleConfirm, fmtRFC3339Local("hu", pkgDate)) { t.Error("the confirm does not name the package's date") } // Everything the ordinary confirm promised is still promised. @@ -114,8 +114,8 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) { UnitLegPreserved: true, } msg := noteServer(t).tier2UnitSourceMsg(cov) - pkg := fmtRFC3339Local("2026-08-31T09:43:41Z") - run := fmtRFC3339Local("2026-08-31T12:23:51Z") + pkg := fmtRFC3339Local("hu", "2026-08-31T09:43:41Z") + run := fmtRFC3339Local("hu", "2026-08-31T12:23:51Z") if !strings.Contains(msg, pkg) { t.Errorf("the outcome does not name the package's date %q: %q", pkg, msg) } @@ -131,7 +131,7 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) { // NEGATIVE CONTROL: with no preserved leg, the package date IS the fresh one and both agree on it. fresh := backup.Tier2Coverage{CopyLastSuccess: "2026-08-31T12:23:51Z", UnitPackageDate: "2026-08-31T12:23:00Z"} - if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) { + if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("hu", "2026-08-31T12:23:00Z")) { t.Error("the ordinary outcome stopped naming its own package date") } } diff --git a/controller/internal/web/r516_items_test.go b/controller/internal/web/r516_items_test.go index b2dd9e7..11f4661 100644 --- a/controller/internal/web/r516_items_test.go +++ b/controller/internal/web/r516_items_test.go @@ -76,7 +76,7 @@ func TestR516_DisconnectTimeIsLocalNotRawUTC(t *testing.T) { if strings.Contains(h, raw) { t.Errorf("%s: the raw UTC timestamp reached the page", tc.lang) } - if want := tc.prefix + fmtRFC3339Local(raw); !strings.Contains(h, want) { + if want := tc.prefix + fmtRFC3339Local(tc.lang, raw); !strings.Contains(h, want) { t.Errorf("%s: the page must carry %q", tc.lang, want) } } diff --git a/controller/internal/web/r521_storage_disconnected_option_test.go b/controller/internal/web/r521_storage_disconnected_option_test.go new file mode 100644 index 0000000..0c65f0d --- /dev/null +++ b/controller/internal/web/r521_storage_disconnected_option_test.go @@ -0,0 +1,69 @@ +package web + +import ( + "encoding/json" + "net/url" + "strings" + "testing" +) + +// R-521 controller half (operator ruling 2026-10-12, S32, `09` §3 decision 227): the household's own +// notification settings carry a lost-drive option (`storage_disconnected`), saved and sent to the hub in +// `enabled_events` exactly like the others — and ONLY when the household has it on. The hub mails a lost +// drive only when this setting carries it. +// +// RED-PROOF (REPORT.md): drop "storage_disconnected" from the single-event list in +// settingsNotificationsHandler → the "on" half fails (the option is silently not saved and not sent). +func TestR521_StorageDisconnectedOptionRoundTrips(t *testing.T) { + eventsOf := func(t *testing.T, body map[string]json.RawMessage) []string { + t.Helper() + var ev []string + if err := json.Unmarshal(body["enabled_events"], &ev); err != nil { + t.Fatalf("enabled_events is not a list: %s", body["enabled_events"]) + } + return ev + } + has := func(list []string, e string) bool { + for _, x := range list { + if x == e { + return true + } + } + return false + } + + // ON: saved, sent, and the page shows it ticked. + s, sett, hub := r922Server(t) + s.loadTemplates() + rr := postNotifications(t, s, url.Values{ + "notification_email": {"household@example.hu"}, + "cooldown_hours": {"6"}, + "event_backup_failed": {"on"}, + "event_storage_disconnected": {"on"}, + }) + if p := sett.GetNotificationPrefs(); !has(p.EnabledEvents, "storage_disconnected") { + t.Fatalf("on: storage_disconnected not saved (stored %v)", p.EnabledEvents) + } + if ev := eventsOf(t, hub.last(t)); !has(ev, "storage_disconnected") { + t.Fatalf("on: storage_disconnected not sent to the hub (enabled_events %v)", ev) + } + if !strings.Contains(rr.Body.String(), `name="event_storage_disconnected" checked`) { + t.Error("on: the saved option does not render ticked") + } + + // OFF: the same household switches it off — not saved, not sent, not ticked. + rr = postNotifications(t, s, url.Values{ + "notification_email": {"household@example.hu"}, + "cooldown_hours": {"6"}, + "event_backup_failed": {"on"}, + }) + if p := sett.GetNotificationPrefs(); has(p.EnabledEvents, "storage_disconnected") { + t.Fatalf("off: storage_disconnected still saved (stored %v)", p.EnabledEvents) + } + if ev := eventsOf(t, hub.last(t)); has(ev, "storage_disconnected") { + t.Fatalf("off: storage_disconnected still sent to the hub (enabled_events %v)", ev) + } + if strings.Contains(rr.Body.String(), `name="event_storage_disconnected" checked`) { + t.Error("off: the option still renders ticked") + } +} diff --git a/controller/internal/web/r562_locale_format_test.go b/controller/internal/web/r562_locale_format_test.go new file mode 100644 index 0000000..ed5bd16 --- /dev/null +++ b/controller/internal/web/r562_locale_format_test.go @@ -0,0 +1,72 @@ +package web + +import ( + "strings" + "testing" + "time" +) + +// R-562 (`09` §3 decision 223): the dashboard prints dates and numbers the reader's way — Hungarian +// „2026. 09. 14. 05:12" and a decimal comma, English „2026-09-14 05:12" and a decimal point. Asserted +// on a RENDERED page in both languages (the backups page carries a date-time and a GB size), and on +// the func set the templates call. +// +// RED-PROOF (REPORT.md): make i18n.FormatDateTime / FormatDecimal ignore the language → the +// Hungarian half fails. +func TestR562_BackupsPageFormatsPerLanguage(t *testing.T) { + var c i18nCase + for _, x := range i18nCases() { + if x.name == "backups_full" { + c = x + } + } + if c.name == "" { + t.Fatal("parity case backups_full not found") + } + s := i18nTestServer(t) + hu := renderI18nCase(t, s, "hu", c) + en := renderI18nCase(t, s, "en", c) + + for _, want := range []string{"2026. 09. 14. 05:12", "5,0 GB", "42,5 GB"} { + if !strings.Contains(hu, want) { + t.Errorf("hu: the page does not carry %q", want) + } + } + for _, bad := range []string{"2026-09-14 05:12", "5.0 GB"} { + if strings.Contains(hu, bad) { + t.Errorf("hu: the page still carries the ISO/point form %q", bad) + } + } + for _, want := range []string{"2026-09-14 05:12", "5.0 GB", "42.5 GB"} { + if !strings.Contains(en, want) { + t.Errorf("en: the page does not carry %q", want) + } + } + if strings.Contains(en, "2026. 09. 14.") || strings.Contains(en, "5,0 GB") { + t.Error("en: the page carries the Hungarian date or decimal comma") + } +} + +func TestR562_FormatFuncsPerLanguage(t *testing.T) { + at := time.Date(2026, 10, 10, 13, 4, 0, 0, time.UTC) // 15:04 in Budapest (CEST) + hu, en := formatFuncs("hu"), formatFuncs("en") + check := func(name, got, want string) { + t.Helper() + if got != want { + t.Errorf("%s: got %q, want %q", name, got, want) + } + } + check("hu fmtTime", hu["fmtTime"].(func(time.Time) string)(at), "2026. 10. 10. 15:04") + check("en fmtTime", en["fmtTime"].(func(time.Time) string)(at), "2026-10-10 15:04") + check("hu fmtTimeStr", hu["fmtTimeStr"].(func(string) string)("2026-10-10T13:04:00Z"), "2026. 10. 10. 15:04") + check("en fmtTimeStr", en["fmtTimeStr"].(func(string) string)("2026-10-10T13:04:00Z"), "2026-10-10 15:04") + check("hu fmtGB", hu["fmtGB"].(func(float64) string)(42.5), "42,5 GB") + check("en fmtGB", en["fmtGB"].(func(float64) string)(42.5), "42.5 GB") + check("hu fmtBytes", hu["fmtBytes"].(func(int64) string)(1610612736), "1,5 GB") + check("en fmtBytes", en["fmtBytes"].(func(int64) string)(1610612736), "1.5 GB") + check("hu fmtLoad", hu["fmtLoad"].(func(float64) string)(0.25), "0,25") + check("en fmtLoad", en["fmtLoad"].(func(float64) string)(0.25), "0.25") + check("hu numText", hu["numText"].(func(string) string)("12.0 MB"), "12,0 MB") + check("en numText", en["numText"].(func(string) string)("12.0 MB"), "12.0 MB") + check("zero time", hu["fmtTime"].(func(time.Time) string)(time.Time{}), "–") +} diff --git a/controller/internal/web/r922_email_cleared_test.go b/controller/internal/web/r922_email_cleared_test.go index d04530e..64931eb 100644 --- a/controller/internal/web/r922_email_cleared_test.go +++ b/controller/internal/web/r922_email_cleared_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync" "testing" @@ -69,8 +70,65 @@ func r922Server(t *testing.T) (*Server, *settings.Settings, *prefsHub) { return s, sett, hub } +// clearForm is a CONFIRMED clear: the household pressed the warning's own button (confirm_clear=1). func clearForm() url.Values { - return url.Values{"notification_email": {""}, "cooldown_hours": {"6"}} // no event_* boxes + return url.Values{"notification_email": {""}, "cooldown_hours": {"6"}, "confirm_clear": {"1"}} // no event_* boxes +} + +// R-922 warning (S1, `09` §3 decision 196): clearing a stored address is WARNED first. The first press +// saves nothing and sends nothing; the page says „no more notification mails" in the reader's language and +// offers the confirm button. Only the confirm clears. +// +// RED-PROOF (REPORT.md): delete the warning block in settingsNotificationsHandler → the first press clears +// the address and pushes to the hub, and this test fails on "the address was cleared before the warning". +func TestR922_ClearWarnsFirst(t *testing.T) { + for _, tc := range []struct{ lang, fragment string }{ + {"hu", "nem kapsz t"}, // „nem kapsz több értesítő levelet" — ASCII fragment + {"en", "you will get no more notification e-mails"}, + } { + s, sett, hub := r922Server(t) + if err := sett.SetLanguage(tc.lang); err != nil { + t.Fatal(err) + } + if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{ + Email: "household@example.hu", EnabledEvents: []string{"backup_failed"}, CooldownHours: 6, + }); err != nil { + t.Fatal(err) + } + s.loadTemplates() + + rr := postNotifications(t, s, url.Values{"notification_email": {""}, "cooldown_hours": {"6"}}) + if p := sett.GetNotificationPrefs(); p.Email != "household@example.hu" { + t.Fatalf("%s: the address was cleared before the warning (stored %q)", tc.lang, p.Email) + } + if hub.count() != 0 { + t.Fatalf("%s: a push reached the hub before the household confirmed", tc.lang) + } + body := rr.Body.String() + if !strings.Contains(body, `id="notification-clear-warning"`) || !strings.Contains(body, tc.fragment) { + t.Fatalf("%s: the warning is not on the page (want %q)", tc.lang, tc.fragment) + } + if !strings.Contains(body, `name="confirm_clear" value="1"`) { + t.Fatalf("%s: the warning offers no confirm button", tc.lang) + } + + postNotifications(t, s, clearForm()) + if p := sett.GetNotificationPrefs(); p.Email != "" { + t.Fatalf("%s: the confirmed clear did not clear (stored %q)", tc.lang, p.Email) + } + if present, v := flagOf(t, hub.last(t)); !present || !v { + t.Fatalf("%s: the confirmed clear did not send email_cleared", tc.lang) + } + } + + // A box with no stored address is not warned: there is nothing to lose. + s, sett, _ := r922Server(t) + s.loadTemplates() + rr := postNotifications(t, s, url.Values{"notification_email": {""}, "cooldown_hours": {"6"}}) + if strings.Contains(rr.Body.String(), `id="notification-clear-warning"`) { + t.Error("a box with no stored address was warned") + } + _ = sett } func flagOf(t *testing.T, body map[string]json.RawMessage) (present bool, value bool) { diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 1359b2e..7972fa3 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -7,6 +7,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) @@ -687,7 +688,7 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) for _, mark := range backup.AbandonRemindAtDays { if st.DaysLeft <= mark { data["RecoveryAbandonDays"] = st.DaysLeft - data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02") + data["RecoveryAbandonDate"] = i18n.FormatDate(s.langFor(r), st.DueAt) // R-365 follow-up: DaysLeft is a ceiling, so 0 means the due moment has PASSED and the // sweep has not run yet. The template's `{{if .RecoveryAbandonDays}}` is false at 0, so the // bar fell back to the undecided-box reminder text — and offered the reminder opt-out over diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index adfa631..418e949 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -340,6 +340,9 @@ func (s *Server) loadTemplates() { for name, fn := range s.localeFuncs(lang) { funcs[name] = fn } + for name, fn := range formatFuncs(lang) { // R-562: dates and numbers in this language + funcs[name] = fn + } for name, fn := range s.readerFuncs(lang, funcs) { funcs[name] = fn } diff --git a/controller/internal/web/share_handlers.go b/controller/internal/web/share_handlers.go index 43e7f36..2165562 100644 --- a/controller/internal/web/share_handlers.go +++ b/controller/internal/web/share_handlers.go @@ -121,7 +121,7 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques } if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil { s.shareRegisterFailure(ip) - s.renderSharePasswordPage(w, r, "Hibás jelszó") + s.renderSharePasswordPage(w, r, s.msg(r, "flash.share.wrong_password")) // R-577: in the visitor's language return } s.shareClearFailures(ip) diff --git a/controller/internal/web/templates/app_export.html b/controller/internal/web/templates/app_export.html index 5496c2a..da88118 100644 --- a/controller/internal/web/templates/app_export.html +++ b/controller/internal/web/templates/app_export.html @@ -148,9 +148,9 @@ async function loadEstimate() { // Task 4: the class-scoped selection UI (classified apps only; legacy apps see the plain estimate). var lastEst = null; function humanBytes(b) { - if (b >= 1073741824) return (b/1073741824).toFixed(1) + ' GB'; - if (b >= 1048576) return (b/1048576).toFixed(1) + ' MB'; - if (b >= 1024) return (b/1024).toFixed(1) + ' KB'; + if (b >= 1073741824) return fhNum(b/1073741824, 1) + ' GB'; + if (b >= 1048576) return fhNum(b/1048576, 1) + ' MB'; + if (b >= 1024) return fhNum(b/1024, 1) + ' KB'; return b + ' B'; } function renderClassSelect(est) { diff --git a/controller/internal/web/templates/app_import.html b/controller/internal/web/templates/app_import.html index 8622071..9d5e7cb 100644 --- a/controller/internal/web/templates/app_import.html +++ b/controller/internal/web/templates/app_import.html @@ -49,8 +49,8 @@ {{range .Bundles}} {{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}} - {{.ExportedAt}} - {{.SizeHuman}} + {{fmtTimeStr .ExportedAt}} + {{numText .SizeHuman}} {{if .DriveLabel}}{{.DriveLabel}}{{else}}{{.DrivePath}}{{end}} {{if .Encrypted}}🔒{{end}} @@ -195,7 +195,7 @@ function showManifest(m) { document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' {{T "app_import.csomag_reszletei_2"}}'; document.getElementById('mfName').textContent = m.display_name || m.app_name; - document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-'; + document.getElementById('mfDate').textContent = m.exported_at ? fhDateTime(new Date(m.exported_at)) : '-'; document.getElementById('mfVersion').textContent = m.controller_version || '-'; if (m.has_database && m.db_type) { @@ -212,9 +212,9 @@ function showManifest(m) { // Format size var bytes = m.total_size_bytes || 0; - var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' : - bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' : - bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B'; + var sizeStr = bytes > 1073741824 ? fhNum(bytes / 1073741824, 1) + ' GB' : + bytes > 1048576 ? fhNum(bytes / 1048576, 1) + ' MB' : + bytes > 1024 ? fhNum(bytes / 1024, 1) + ' KB' : bytes + ' B'; document.getElementById('mfSize').textContent = sizeStr; // Check if app already exists — show overwrite warning @@ -317,7 +317,7 @@ function fabPick() { } function fabGB(b) { - return (b / 1073741824).toFixed(2); + return fhNum(b / 1073741824, 2); } function fabProgress(done, total) { diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 71dcf03..379e0fe 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -175,7 +175,7 @@ + + + +
+ + +
+ + +
+ + + + + + + + + + + +
+

Értesítések

+ + + +
+ +
+

Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő levelet. A mentés csak akkor történik meg, ha megerősíted.

+ +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+
+ +
+ + +
+
+
+ +
+ + óra (azonos probléma esetén ennyi ideig nem küld újat) +
+
+
+ + +
+
+ +
+ + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html b/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html index 607ad70..119f2ff 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html +++ b/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html b/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html index 064d823..f6b12a7 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html +++ b/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_full.html b/controller/internal/web/testdata/i18n_parity/settings_security_full.html index 749f9ba..724eb04 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_full.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} @@ -257,7 +262,7 @@ - Utolsó szinkronizálás: 2026-09-14 03:12 + Utolsó szinkronizálás: 2026. 09. 14. 03:12 api error diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html index d61b418..b4d17c1 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html index 92aec5c..590c02a 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_full.html b/controller/internal/web/testdata/i18n_parity/settings_system_full.html index 19ac88c..2ce25bd 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_full.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html b/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html index e94ae88..8a3a31c 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_pending.html b/controller/internal/web/testdata/i18n_parity/settings_system_pending.html index 1ead89b..368334a 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_pending.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_pending.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html b/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html index c9ea43d..dcfaff4 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/sharing_full.html b/controller/internal/web/testdata/i18n_parity/sharing_full.html index 8fb70a6..bc24e69 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_full.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_full.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} @@ -357,7 +362,7 @@ 2. mentés (másik meghajtó): - rendben — 2026-09-14 03:12 + rendben — 2026. 09. 14. 03:12 egy figyelmeztetés itt @@ -366,7 +371,7 @@ Távoli mentés (felhő): - rendben — 2 megosztás, 2026-09-14 03:12 + rendben — 2 megosztás, 2026. 09. 14. 03:12 diff --git a/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html b/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html index 87c8102..2e4e99c 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html b/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html index 656f047..4202e5e 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/sharing_off.html b/controller/internal/web/testdata/i18n_parity/sharing_off.html index d8f69e2..38c4a62 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_off.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_off.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/sharing_other_none.html b/controller/internal/web/testdata/i18n_parity/sharing_other_none.html index 17e92d0..e6716d2 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_other_none.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_other_none.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/stacks_full.html b/controller/internal/web/testdata/i18n_parity/stacks_full.html index de590c6..b442c5d 100644 --- a/controller/internal/web/testdata/i18n_parity/stacks_full.html +++ b/controller/internal/web/testdata/i18n_parity/stacks_full.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/stacks_install_interrupted.html b/controller/internal/web/testdata/i18n_parity/stacks_install_interrupted.html index 405a71a..7959bbb 100644 --- a/controller/internal/web/testdata/i18n_parity/stacks_install_interrupted.html +++ b/controller/internal/web/testdata/i18n_parity/stacks_install_interrupted.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html b/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html index cc4cc10..8d95e17 100644 --- a/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html +++ b/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} diff --git a/controller/internal/web/testdata/i18n_parity/storage_attach.html b/controller/internal/web/testdata/i18n_parity/storage_attach.html index 3e2ff83..7d3c565 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_attach.html +++ b/controller/internal/web/testdata/i18n_parity/storage_attach.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} @@ -225,7 +230,7 @@ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){retur function fmtSize(b){ if(!b) return ''; var g=b/1073741824; - return g>=1 ? g.toFixed(g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB'; + return g>=1 ? fhNum(g, g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB'; } async function loadDisks(){ diff --git a/controller/internal/web/testdata/i18n_parity/storage_empty.html b/controller/internal/web/testdata/i18n_parity/storage_empty.html index a12fc77..de2a61b 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_empty.html +++ b/controller/internal/web/testdata/i18n_parity/storage_empty.html @@ -15,6 +15,11 @@ document.documentElement.className=document.documentElement.className.replace('no-js','js'); function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} + var fhEN=document.documentElement.lang==='en'; + function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');} + function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');} + function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));} + function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');} @@ -193,7 +198,7 @@