diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a22bb9..3a99295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## v0.309.0 — the simpler notification page: three group switches (R-388 slice 1) (2026-10-12) + +**MinAgent: 0.131.0** (unchanged — no agent call changed). **No wire change, no hub change:** the page still saves the +same `enabled_events` list. Design `08-alarm-ladder.md` §8.1, `09` §3 decision 200; operator answers 2026-10-12 evening +(Q1 „as picked": group 3 on for households; Q2 „as picked": the cooldown field under „Részletek"). + +- **R-388 slice 1 — three switches instead of eighteen.** The notification page shows „A gépem és a meghajtóim" / + "My machine and drives", „Az alkalmazásaim" / "My apps" and „Amit mi intézünk" / "What we handle", each one switch + with one sentence of what it sends. Membership is §8.1's table (`settings.NotificationGroups`; all 18 switches of + v0.308.0 are in it, each in exactly one group). Switching a group on or off writes or clears all its detectors. + A group whose saved detectors are partly on shows „egyedi" / "custom" and saving the page without touching it + stores the list **unchanged** — no household's saved choice changes. The 18 per-detector switches (now ordered by + group) and the cooldown field sit under a „Részletek" / "Details" fold (`
`, no script). A form without + the new group fields (an old page left open) is handled exactly as before. +- **Not changed, for the lead to decide:** `settings.DefaultEnabledEvents` fills **no** group completely — + machine misses `storage_reconnected`; apps misses `app_start_failed` (OFF by the R-329 ruling, 2026-08-23); + „what we handle" misses `backup_integrity_failed`, `crossdrive_failed`, `health_recovered`. So a fresh box (and + every household on today's default) sees all three groups as „egyedi". Pinned by `TestR388_DefaultListAsGroups`. +- Tests (`internal/web/r388_notif_groups_test.go`, the real render + the real save over a temp-file `Settings`, + posting back exactly what the rendered form carries): `TestR388_EveryPageSwitchIsInExactlyOneGroup`, + `TestR388_EachGroupRoundTrips` (RED without the switch-off branch: „group machine switched off still stores + [...] — the ticked details overrode the group switch"; RED without the switch-on branch: „group machine switched + on stored [], want exactly [...]"), `TestR388_CustomGroupSavedBackUnchanged` (RED with an unticked group always + clearing: „an untouched save CHANGED the stored list"), `TestR388_CustomGroupSwitchedOnTakesAll`, + `TestR388_FullListRendersThreeGroupsOn`, `TestR388_DefaultListAsGroups`, `TestR388_DetailsFoldSavesAnIndividualSwitch`. +- **Parity fixtures:** `settings_notifications_hub` and `settings_notifications_clear_confirm` re-captured for this + template change (the switch list regrouped under the fold, the three group switches added; the two old section + labels „Hibák és figyelmeztetések:" / „Tájékoztató:" removed with their keys); a new case + `settings_notifications_groups_custom` (every group „egyedi") captured when born. No other fixture moved. + ## 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 diff --git a/REUSE.md b/REUSE.md index bf161fb..4b65f7c 100644 --- a/REUSE.md +++ b/REUSE.md @@ -258,6 +258,7 @@ | `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only | | `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json | | `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image | +| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged | | `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — | | `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it | | `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood | diff --git a/controller/README.md b/controller/README.md index e2a8ec5..2a37c63 100644 --- a/controller/README.md +++ b/controller/README.md @@ -2910,8 +2910,23 @@ live hardware), which is exactly what the marker covers. #### Default Enabled Events -Events the customer receives notifications for (configurable in settings): -`backup_failed`, `db_dump_failed`, `disk_warning`, `disk_critical`, `storage_disconnected`, `node_down`, `health_critical`, `expected_backup_missed`, `expected_dbdump_missed` +Events the customer receives notifications for (configurable in settings) — `settings.DefaultEnabledEvents`: +`backup_failed`, `db_dump_failed`, `disk_warning`, `disk_critical`, `storage_disconnected`, `node_down`, `health_critical`, `expected_backup_missed`, `expected_dbdump_missed`, `offbox_enlarge_blocked`, `app_update_undone`, `app_update_held`, `app_stopped_unhealthy` + +#### The page: three group switches (v0.309.0, R-388 slice 1, `08` §8.1, `09` §3 decision 200) + +The notification page shows three switches instead of one per detector — „A gépem és a meghajtóim" / "My +machine and drives", „Az alkalmazásaim" / "My apps", „Amit mi intézünk" / "What we handle" — each with one +sentence of what it sends. The membership table is `settings.NotificationGroups` (every detector on the page +is in exactly one group, pinned by `TestR388_EveryPageSwitchIsInExactlyOneGroup`). A group is a VIEW over the +same `enabled_events` list: no wire change, no hub change. A group with some detectors on and some off shows +as „egyedi" / "custom". The per-detector switches and the cooldown field sit under a „Részletek" / "Details" +fold (`
`, no script). The save rule (`Server.applyNotificationGroupSwitches`): a group switch the +household MOVED wins (sets or clears all its detectors); a group switch left where it was rendered leaves the +per-detector switches authoritative — so a custom group saved untouched writes the list back byte-for-byte +(`TestR388_CustomGroupSavedBackUnchanged`). A POST without the `group__shown` fields (an old page) is +handled exactly as before. Note: today's default list fills NO group completely (each misses a member), so a +fresh box shows all three as „egyedi" (`TestR388_DefaultListAsGroups` pins this until the default is decided). #### Preference Sync @@ -3249,7 +3264,7 @@ Five sections: 2. **Version & update** — current/latest version, check/update buttons, auto-update status, last update result 3. **Storage paths** — add/remove, edit labels, set default, toggle schedulable, per-path app list with sizes, safe disconnect/reconnect for USB drives 4. **Password change** — current + new + confirm, min 8 chars -5. **Notifications** — email, event checkboxes, cooldown hours, test email button +5. **Notifications** — email, three group switches (v0.309.0), the per-event checkboxes and cooldown hours under „Részletek", test email button --- diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 0164f93..6b9afea 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1943,7 +1943,6 @@ "settings_notifications.ertesitesi_szunet": "Notification pause", "settings_notifications.ez_jelenik_meg_a_kimeno": "This appears as the sender of outgoing e-mails, next to the app name.", "settings_notifications.felado_neve_opcionalis": "Sender name (optional)", - "settings_notifications.hibak_es_figyelmeztetesek": "Errors and warnings:", "settings_notifications.lemez_beteloben_90_felett": "Disk filling up (over 90%)", "settings_notifications.lemez_megtelt_vagy_meghibasodott": "Disk full or failed", "settings_notifications.masodlagos_mentes_sikertelen": "Secondary backup failed", @@ -1957,9 +1956,19 @@ "settings_notifications.rendszer_allapot_helyreallt": "System health recovered", "settings_notifications.rendszer_allapot_kritikus": "System health critical", "settings_notifications.szerver_nem_elerheto": "Server not reachable", - "settings_notifications.tajekoztato": "For information:", "settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete": "Remote backup — storage quota warning", "settings_notifications.teszt_email_kuldese": "Send test e-mail", + "settings_notifications.groups_label": "What should we e-mail you about?", + "settings_notifications.group_machine": "My machine and drives", + "settings_notifications.group_machine_desc": "You get an e-mail when a drive is unplugged or back, filling up, full or failing, or the off-site backup is short of space. These need you.", + "settings_notifications.group_apps": "My apps", + "settings_notifications.group_apps_desc": "You get an e-mail when an app is not running or we stopped it because it kept crashing, and when an update was undone or failed.", + "settings_notifications.group_handled": "What we handle", + "settings_notifications.group_handled_desc": "You get an e-mail when a backup failed or did not run, or the machine cannot be reached. We see these too and deal with them; you need not do anything.", + "settings_notifications.group_custom": "custom", + "settings_notifications.group_custom_hint": "This group has your own selection. Switching it on turns on all of its notifications.", + "settings_notifications.reszletek": "Details", + "settings_notifications.reszletek_hint": "Here you can switch each notification on or off one by one.", "settings_security.a_cloudflare_api_token_nincs": "The Cloudflare API token is not configured. Ask the operator to set it up.
\n The token needs the Zone WAF:Edit permission.", "settings_security.a_helyi_halozati_hozzaferes_mindig": "Access from the local network is always allowed (it does not pass through Cloudflare).", "settings_security.a_jelszavas_vedelem_nincs_beallitva": "Password protection is not set up. Ask the operator to set it up.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index d3b8456..5c2eb0f 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1931,7 +1931,6 @@ "settings_notifications.ertesitesi_szunet": "Értesítési szünet", "settings_notifications.ez_jelenik_meg_a_kimeno": "Ez jelenik meg a kimenő emailek feladójaként az alkalmazás neve mellett.", "settings_notifications.felado_neve_opcionalis": "Feladó neve (opcionális)", - "settings_notifications.hibak_es_figyelmeztetesek": "Hibák és figyelmeztetések:", "settings_notifications.lemez_beteloben_90_felett": "Lemez betelőben (90% felett)", "settings_notifications.lemez_megtelt_vagy_meghibasodott": "Lemez megtelt vagy meghibásodott", "settings_notifications.masodlagos_mentes_sikertelen": "Másodlagos mentés sikertelen", @@ -1945,9 +1944,19 @@ "settings_notifications.rendszer_allapot_helyreallt": "Rendszer állapot helyreállt", "settings_notifications.rendszer_allapot_kritikus": "Rendszer állapot kritikus", "settings_notifications.szerver_nem_elerheto": "Szerver nem elérhető", - "settings_notifications.tajekoztato": "Tájékoztató:", "settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete": "Távoli mentés — tárhelykeret-figyelmeztetés", "settings_notifications.teszt_email_kuldese": "Teszt email küldése", + "settings_notifications.groups_label": "Miről küldjünk levelet?", + "settings_notifications.group_machine": "A gépem és a meghajtóim", + "settings_notifications.group_machine_desc": "Levelet kapsz, ha egy meghajtó leválik vagy visszakerül, betelőben van, megtelt vagy meghibásodott, vagy a távoli mentésnek kevés a helye. Ezekkel neked van teendőd.", + "settings_notifications.group_apps": "Az alkalmazásaim", + "settings_notifications.group_apps_desc": "Levelet kapsz, ha egy alkalmazás nem fut, vagy leállítottuk, mert újra és újra összeomlott, és ha egy frissítést visszavontunk vagy nem sikerült.", + "settings_notifications.group_handled": "Amit mi intézünk", + "settings_notifications.group_handled_desc": "Levelet kapsz, ha egy mentés nem sikerült vagy elmaradt, vagy a gép nem elérhető. Ezeket mi is látjuk és intézzük, neked nincs teendőd.", + "settings_notifications.group_custom": "egyedi", + "settings_notifications.group_custom_hint": "Ennél a csoportnál egyedi beállításod van. Ha bekapcsolod, minden értesítése bekapcsol.", + "settings_notifications.reszletek": "Részletek", + "settings_notifications.reszletek_hint": "Itt egyenként is be- és kikapcsolhatod az értesítéseket.", "settings_security.a_cloudflare_api_token_nincs": "A Cloudflare API token nincs konfigurálva. Kérd az üzemeltetőt a beállításhoz.
\n A tokennek Zone WAF:Edit jogosultsággal kell rendelkeznie.", "settings_security.a_helyi_halozati_hozzaferes_mindig": "A helyi hálózati hozzáférés mindig engedélyezett (nem halad át a Cloudflare-en).", "settings_security.a_jelszavas_vedelem_nincs_beallitva": "A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz.", diff --git a/controller/internal/settings/notif_groups.go b/controller/internal/settings/notif_groups.go new file mode 100644 index 0000000..a58a282 --- /dev/null +++ b/controller/internal/settings/notif_groups.go @@ -0,0 +1,88 @@ +package settings + +// R-388 slice 1 (`08-alarm-ladder.md` §8.1, `09` §3 decision 200; operator answers 2026-10-12): +// the notification page shows THREE group switches instead of one switch per detector. A group is a +// view over the SAME `enabled_events` list — saving a group writes all its detectors into it, so +// nothing on the wire and nothing on the hub changes. +// +// Every detector the page can save belongs to EXACTLY ONE group. Pinned by +// TestR388_EveryPageSwitchIsInExactlyOneGroup (web), which reads the rendered page's switches and +// fails when a new detector gets a checkbox but no group. + +// Notification group keys (also the form-field stems `group_` / `group__shown`). +const ( + NotifGroupMachine = "machine" // „A gépem és a meghajtóim" — the household acts + NotifGroupApps = "apps" // „Az alkalmazásaim" + NotifGroupHandled = "handled" // „Amit mi intézünk" — we act; the operator always gets these +) + +// Group states as the page renders them. +const ( + NotifGroupOn = "on" + NotifGroupOff = "off" + NotifGroupCustom = "custom" // some of its detectors on, some off — „egyedi" +) + +// NotificationGroup is one switch on the notification page and the detectors behind it. +type NotificationGroup struct { + Key string + Events []string +} + +// NotificationGroups is the §8.1 membership table, in page order. +var NotificationGroups = []NotificationGroup{ + {Key: NotifGroupMachine, Events: []string{ + "storage_disconnected", "storage_reconnected", "disk_warning", "disk_critical", "offbox_enlarge_blocked", + }}, + {Key: NotifGroupApps, Events: []string{ + "app_stopped_unhealthy", "app_start_failed", "app_update_held", "app_update_undone", + }}, + {Key: NotifGroupHandled, Events: []string{ + "backup_failed", "db_dump_failed", "backup_integrity_failed", "crossdrive_failed", + "expected_backup_missed", "expected_dbdump_missed", "node_down", "health_critical", "health_recovered", + }}, +} + +// NotificationGroupByKey returns the group with this key, or false. +func NotificationGroupByKey(key string) (NotificationGroup, bool) { + for _, g := range NotificationGroups { + if g.Key == key { + return g, true + } + } + return NotificationGroup{}, false +} + +// NotificationGroupState reports whether all, none or some of the group's detectors are in enabled. +// An unknown key reads as off (renders unchecked, and the save leaves the details authoritative). +func NotificationGroupState(enabled []string, key string) string { + g, ok := NotificationGroupByKey(key) + if !ok { + return NotifGroupOff + } + set := make(map[string]bool, len(enabled)) + for _, e := range enabled { + set[e] = true + } + on := 0 + for _, e := range g.Events { + if set[e] { + on++ + } + } + switch { + case on == len(g.Events): + return NotifGroupOn + case on == 0: + return NotifGroupOff + default: + return NotifGroupCustom + } +} + +// GroupState is the template's view of NotificationGroupState over this household's list. +// VALUE receiver on purpose: a pointer receiver compiles and passes vet, then 500s at render +// (`.claude/rules/ui-hungarian.md`). +func (p NotificationPrefs) GroupState(key string) string { + return NotificationGroupState(p.EnabledEvents, key) +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 48218b4..a4774db 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -22,6 +22,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/logx" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" @@ -2846,6 +2847,47 @@ func dedupeEvents(in []string) []string { return out } +// applyNotificationGroupSwitches — R-388 slice 1 (`08` §8.1). The page posts, per group, the switch +// (`group_` = "on" when ticked) and the state it was RENDERED in (`group__shown`: +// on / off / custom). The per-detector switches under „Részletek" are posted too, so the rule is: +// +// - the household MOVED the group switch (rendered on → now off, rendered off/custom → now on): +// the group wins — all its detectors are set, or all cleared; +// - the switch is where it was rendered: the per-detector switches are authoritative, exactly as +// before this slice. So a „custom" (egyedi) group saved untouched writes its detectors back as +// they were — no household's saved choice changes (TestR388_CustomGroupSavedBackUnchanged). +// +// A form with no `_shown` field (an old page left open, a bookmarked POST) is not touched by this +// step at all: the per-detector names decide, as in v0.308.0. +func (s *Server) applyNotificationGroupSwitches(r *http.Request, enabled []string) []string { + for _, g := range settings.NotificationGroups { + shown := r.FormValue("group_" + g.Key + "_shown") + if shown != settings.NotifGroupOn && shown != settings.NotifGroupOff && shown != settings.NotifGroupCustom { + continue + } + on := r.FormValue("group_"+g.Key) == "on" + switch { + case shown == settings.NotifGroupOn && !on: + members := make(map[string]bool, len(g.Events)) + for _, e := range g.Events { + members[e] = true + } + kept := enabled[:0:0] + for _, e := range enabled { + if !members[e] { + kept = append(kept, e) + } + } + enabled = kept + logx.Debugf(s.logger, "[web] notification group switched off: group=%s", g.Key) + case shown != settings.NotifGroupOn && on: + enabled = append(enabled, g.Events...) + logx.Debugf(s.logger, "[web] notification group switched on: group=%s was=%s", g.Key, shown) + } + } + return enabled +} + func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() @@ -2916,6 +2958,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req enabledEvents = append(enabledEvents, c.events...) } } + enabledEvents = s.applyNotificationGroupSwitches(r, enabledEvents) enabledEvents = dedupeEvents(enabledEvents) // R-329 Part 4 — A SAVE THAT CHANGES NOTHING MUST STORE NOTHING NEW. diff --git a/controller/internal/web/i18n_cases_a_test.go b/controller/internal/web/i18n_cases_a_test.go index dbd9126..1467306 100644 --- a/controller/internal/web/i18n_cases_a_test.go +++ b/controller/internal/web/i18n_cases_a_test.go @@ -3,6 +3,7 @@ package web import ( "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -262,7 +263,7 @@ func i18nCasesA() []i18nCase { d := i18nLayoutData("settings-notifications", "Értesítések") d["HubEnabled"] = true d["NotificationSuccess"], d["NotificationError"] = "A beállítás elmentve.", "A mentés nem sikerült." - d["NotificationPrefs"] = map[string]interface{}{"Email": "anna@example.hu", "EnabledEvents": []string{"backup_failed", "node_down"}, "CooldownHours": 4} + d["NotificationPrefs"] = &settings.NotificationPrefs{Email: "anna@example.hu", EnabledEvents: []string{"backup_failed", "node_down"}, CooldownHours: 4} d["AppEmailAvailable"], d["AppEmailEnabled"], d["AppEmailFromName"] = true, true, "Kovács család" d["AppEmailSuccess"], d["AppEmailError"] = "Az e-mail beállítás mentve.", "Az e-mail beállítás hibás." return d @@ -272,7 +273,15 @@ func i18nCasesA() []i18nCase { d := i18nLayoutData("settings-notifications", "Értesítések") d["HubEnabled"] = true d["NotificationClearConfirm"] = true - d["NotificationPrefs"] = map[string]interface{}{"Email": "", "EnabledEvents": []string{}, "CooldownHours": 6} + d["NotificationPrefs"] = &settings.NotificationPrefs{Email: "", EnabledEvents: []string{}, CooldownHours: 6} + return d + }}, + // v0.309.0 (R-388 slice 1, decision 200): every group „egyedi" (custom) — some of its detectors on, + // some off — so each group's custom badge and hint renders; captured when born. + {"settings_notifications_groups_custom", "settings_notifications", func() map[string]interface{} { + d := i18nLayoutData("settings-notifications", "Értesítések") + d["HubEnabled"] = true + d["NotificationPrefs"] = &settings.NotificationPrefs{Email: "anna@example.hu", EnabledEvents: []string{"disk_warning", "app_update_held", "backup_failed"}, CooldownHours: 6} return d }}, {"settings_notifications_nohub", "settings_notifications", func() map[string]interface{} { diff --git a/controller/internal/web/r388_notif_groups_test.go b/controller/internal/web/r388_notif_groups_test.go new file mode 100644 index 0000000..c6f7235 --- /dev/null +++ b/controller/internal/web/r388_notif_groups_test.go @@ -0,0 +1,301 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "regexp" + "sort" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-388 slice 1 (`08-alarm-ladder.md` §8.1, `09` §3 decision 200) — the notification page shows three +// group switches; the per-detector switches and the cooldown live under a „Részletek" fold. +// +// THE LAYER: the REAL render (`settingsNotificationsPageHandler`) and the REAL save +// (`settingsNotificationsHandler`) over a REAL temp-file `Settings`. `renderedForm` posts back exactly +// what a browser would — every ticked checkbox, every hidden field, the address and the cooldown — so +// the round trips below exercise what the TEMPLATE renders, not what a test assumes it renders. + +var r388InputRe = regexp.MustCompile(`]*)>`) +var r388AttrRe = regexp.MustCompile(`([a-z]+)(?:="([^"]*)")?`) + +// renderPage renders the notifications page and returns its body. +func r388RenderPage(t *testing.T, s *Server) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/settings/notifications", nil) + rr := httptest.NewRecorder() + s.settingsNotificationsPageHandler(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("render: HTTP %d", rr.Code) + } + return rr.Body.String() +} + +// renderedForm returns what a browser submits for the notifications form when the household presses +// Save without touching anything. +func renderedForm(t *testing.T, body string) url.Values { + t.Helper() + start := strings.Index(body, `action="/settings/notifications"`) + end := strings.Index(body, `action="/settings/app-email"`) + if start < 0 { + t.Fatalf("the notifications form did not render — this test would then prove nothing") + } + if end < start { + end = len(body) + } + form := url.Values{} + for _, m := range r388InputRe.FindAllStringSubmatch(body[start:end], -1) { + attrs := map[string]string{} + for _, a := range r388AttrRe.FindAllStringSubmatch(m[1], -1) { + attrs[a[1]] = a[2] + } + if _, isChecked := attrs["checked"]; attrs["type"] == "checkbox" { + if isChecked { + form.Set(attrs["name"], "on") + } + continue + } + if attrs["name"] != "" { + form.Set(attrs["name"], attrs["value"]) + } + } + return form +} + +func r388Seed(t *testing.T, sett *settings.Settings, events []string) { + t.Helper() + if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{ + Email: "seed@felhom.eu", EnabledEvents: events, CooldownHours: 6, + }); err != nil { + t.Fatal(err) + } +} + +func r388Save(t *testing.T, s *Server, form url.Values) { + t.Helper() + if rr := postNotifications(t, s, form); rr.Code >= 400 { + t.Fatalf("save: HTTP %d", rr.Code) + } +} + +func sortedCopy(in []string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +// Every per-detector switch the page renders belongs to exactly one group, and every group member has +// a switch — a new detector that gets a checkbox but no group fails here (the invariant stated in +// settings/notif_groups.go). +func TestR388_EveryPageSwitchIsInExactlyOneGroup(t *testing.T) { + s, _ := notifyGuardServer(t) + body := r388RenderPage(t, s) + page := map[string]bool{} + for _, m := range regexp.MustCompile(`name="event_([a-z_]+)"`).FindAllStringSubmatch(body, -1) { + page[m[1]] = true + } + if len(page) < 18 { + t.Fatalf("only %d per-detector switches rendered, want ≥18 — the probe is not reading the page", len(page)) + } + member := map[string]int{} + for _, g := range settings.NotificationGroups { + for _, e := range g.Events { + member[e]++ + } + } + for e := range page { + if member[e] != 1 { + t.Errorf("switch event_%s is in %d groups, want exactly 1 (`08` §8.1 table)", e, member[e]) + } + } + for e := range member { + if !page[e] { + t.Errorf("group member %s has no switch on the page", e) + } + } +} + +// Each group, switched on from an empty list, writes exactly its detectors; switched off again (the +// details still render them ticked), it clears them — the group switch wins over the fold. +func TestR388_EachGroupRoundTrips(t *testing.T) { + for _, g := range settings.NotificationGroups { + t.Run(g.Key, func(t *testing.T) { + s, sett := notifyGuardServer(t) + r388Seed(t, sett, []string{}) + + form := renderedForm(t, r388RenderPage(t, s)) + if got := form.Get("group_" + g.Key + "_shown"); got != settings.NotifGroupOff { + t.Fatalf("empty list rendered group %s as %q, want off", g.Key, got) + } + form.Set("group_"+g.Key, "on") + r388Save(t, s, form) + if got := sett.GetNotificationPrefs().EnabledEvents; !reflect.DeepEqual(sortedCopy(got), sortedCopy(g.Events)) { + t.Fatalf("group %s switched on stored %v, want exactly %v", g.Key, got, g.Events) + } + + form = renderedForm(t, r388RenderPage(t, s)) + if form.Get("group_"+g.Key) != "on" || form.Get("group_"+g.Key+"_shown") != settings.NotifGroupOn { + t.Fatalf("group %s did not render ON after it was saved on: %v", g.Key, form) + } + form.Del("group_" + g.Key) // the household unticks the group switch + r388Save(t, s, form) + if got := sett.GetNotificationPrefs().EnabledEvents; len(got) != 0 { + t.Fatalf("group %s switched off still stores %v — the ticked details overrode the group switch", g.Key, got) + } + }) + } +} + +// A list that mixes a group (some on, some off) renders that group as „egyedi", and saving the page +// untouched stores the list byte-for-byte as it was — no household's saved choice changes. +func TestR388_CustomGroupSavedBackUnchanged(t *testing.T) { + cases := map[string][]string{ + "defaults": append([]string(nil), settings.DefaultEnabledEvents...), + "mixed": {"disk_warning", "app_update_held", "backup_failed", "health_recovered"}, + } + for name, stored := range cases { + t.Run(name, func(t *testing.T) { + s, sett := notifyGuardServer(t) + r388Seed(t, sett, stored) + before := append([]string(nil), sett.GetNotificationPrefs().EnabledEvents...) + + body := r388RenderPage(t, s) + form := renderedForm(t, body) + customs := 0 + for _, g := range settings.NotificationGroups { + want := settings.NotificationGroupState(before, g.Key) + if got := form.Get("group_" + g.Key + "_shown"); got != want { + t.Errorf("group %s rendered %q, want %q", g.Key, got, want) + } + if want == settings.NotifGroupCustom { + customs++ + if form.Get("group_"+g.Key) == "on" { + t.Errorf("custom group %s rendered with its switch ticked", g.Key) + } + } + } + if customs == 0 { + t.Fatalf("case %s has no custom group — it would prove nothing", name) + } + // ASCII fragment of „egyedi" in the badge (positive control); its absence on an all-on page is + // checked in TestR388_FullListRendersThreeGroupsOn (negative control). + if !strings.Contains(body, `egyedi`) { + t.Errorf("no „egyedi\" badge rendered for a custom group") + } + + r388Save(t, s, form) + if after := sett.GetNotificationPrefs().EnabledEvents; !reflect.DeepEqual(before, after) { + t.Errorf("an untouched save CHANGED the stored list.\n before: %v\n after: %v", before, after) + } + }) + } +} + +// A custom group switched ON turns on all of its detectors and leaves the other groups as they were. +func TestR388_CustomGroupSwitchedOnTakesAll(t *testing.T) { + s, sett := notifyGuardServer(t) + r388Seed(t, sett, []string{"disk_warning", "backup_failed"}) + form := renderedForm(t, r388RenderPage(t, s)) + form.Set("group_machine", "on") + r388Save(t, s, form) + machine, _ := settings.NotificationGroupByKey(settings.NotifGroupMachine) + want := sortedCopy(append(append([]string(nil), machine.Events...), "backup_failed")) + if got := sortedCopy(sett.GetNotificationPrefs().EnabledEvents); !reflect.DeepEqual(got, want) { + t.Fatalf("stored %v, want %v", got, want) + } +} + +// Every detector on → three groups rendered on, no „egyedi" badge. +func TestR388_FullListRendersThreeGroupsOn(t *testing.T) { + s, sett := notifyGuardServer(t) + var all []string + for _, g := range settings.NotificationGroups { + all = append(all, g.Events...) + } + r388Seed(t, sett, all) + body := r388RenderPage(t, s) + form := renderedForm(t, body) + for _, g := range settings.NotificationGroups { + if form.Get("group_"+g.Key) != "on" || form.Get("group_"+g.Key+"_shown") != settings.NotifGroupOn { + t.Errorf("group %s not rendered on for the full list", g.Key) + } + } + if strings.Contains(body, "egyedi") { + t.Errorf("an all-on page shows an „egyedi\" badge") + } +} + +// Today's DefaultEnabledEvents, read as groups. §8.1 and the operator's Q1 want all three ON for a new +// household; today NONE is fully on (each misses a member: storage_reconnected; app_start_failed, OFF +// by the R-329 ruling; backup_integrity_failed, crossdrive_failed, health_recovered). This slice does +// NOT change the default (the lead decides). The test pins the fact so a change to either side is seen; +// when the default is made to cover the groups, flip `want` to on. +func TestR388_DefaultListAsGroups(t *testing.T) { + want := map[string]string{ + settings.NotifGroupMachine: settings.NotifGroupCustom, + settings.NotifGroupApps: settings.NotifGroupCustom, + settings.NotifGroupHandled: settings.NotifGroupCustom, + } + for _, g := range settings.NotificationGroups { + if got := settings.NotificationGroupState(settings.DefaultEnabledEvents, g.Key); got != want[g.Key] { + t.Errorf("DefaultEnabledEvents renders group %s as %q, this test pins %q", g.Key, got, want[g.Key]) + } + } + // A fresh box (no stored prefs) renders through the same path. + s, _ := notifyGuardServer(t) + form := renderedForm(t, r388RenderPage(t, s)) + for _, g := range settings.NotificationGroups { + if got := form.Get("group_" + g.Key + "_shown"); got != want[g.Key] { + t.Errorf("a fresh box renders group %s as %q, want %q", g.Key, got, want[g.Key]) + } + } +} + +// The details fold still saves one switch on its own: all groups on, one detector unticked under +// „Részletek" → exactly that detector is gone and its group becomes custom. +func TestR388_DetailsFoldSavesAnIndividualSwitch(t *testing.T) { + s, sett := notifyGuardServer(t) + var all []string + for _, g := range settings.NotificationGroups { + all = append(all, g.Events...) + } + r388Seed(t, sett, all) + body := r388RenderPage(t, s) + open := strings.Index(body, `
`)] + for _, needle := range []string{`name="event_disk_warning"`, `name="cooldown_hours"`} { + if !strings.Contains(fold, needle) { + t.Errorf("%s is not inside the „Részletek\" fold", needle) + } + } + + form := renderedForm(t, body) + form.Del("event_disk_warning") + form.Set("cooldown_hours", "12") + r388Save(t, s, form) + got := sett.GetNotificationPrefs() + var want []string + for _, e := range all { + if e != "disk_warning" { + want = append(want, e) + } + } + if !reflect.DeepEqual(sortedCopy(got.EnabledEvents), sortedCopy(want)) { + t.Fatalf("stored %v, want all but disk_warning", got.EnabledEvents) + } + if got.CooldownHours != 12 { + t.Errorf("cooldown under the fold not saved: %d", got.CooldownHours) + } + if st := got.GroupState(settings.NotifGroupMachine); st != settings.NotifGroupCustom { + t.Errorf("machine group after one detector off = %q, want custom", st) + } +} diff --git a/controller/internal/web/templates/settings_notifications.html b/controller/internal/web/templates/settings_notifications.html index e3b5571..1b0a054 100644 --- a/controller/internal/web/templates/settings_notifications.html +++ b/controller/internal/web/templates/settings_notifications.html @@ -24,96 +24,144 @@ placeholder="{{T "settings_notifications.pelda_email"}}" class="form-control">
- -
+ + {{with .NotificationPrefs}}{{$st := .GroupState "machine"}} +
+ - - - - - - - - - - - - - - - + {{T "settings_notifications.group_machine_desc"}}{{if eq $st "custom"}} + {{T "settings_notifications.group_custom_hint"}} {{T "settings_notifications.reszletek"}}{{end}}
-
-
- -
+ {{end}} + {{with .NotificationPrefs}}{{$st := .GroupState "apps"}} +
+ + {{T "settings_notifications.group_apps_desc"}}{{if eq $st "custom"}} + {{T "settings_notifications.group_custom_hint"}} {{T "settings_notifications.reszletek"}}{{end}} +
+ {{end}} + {{with .NotificationPrefs}}{{$st := .GroupState "handled"}} +
+ + {{T "settings_notifications.group_handled_desc"}}{{if eq $st "custom"}} + {{T "settings_notifications.group_custom_hint"}} {{T "settings_notifications.reszletek"}}{{end}}
+ {{end}}
-
- -
- - {{T "settings_notifications.ora_azonos_problema_eseten_ennyi"}} +
+ {{T "settings_notifications.reszletek"}} +

{{T "settings_notifications.reszletek_hint"}}

+
+ +
+ + + + + +
-
+
+ +
+ + + + +
+
+
+ +
+ + + + + + + + + +
+
+
+ +
+ + {{T "settings_notifications.ora_azonos_problema_eseten_ennyi"}} +
+
+
diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index c7e3db0..d58b7a6 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -2639,6 +2639,23 @@ a.stat-card:hover { .storage-add-details { margin-top: .5rem; } +/* R-388 slice 1 — the notification page's three group switches and the „Részletek" fold */ +.notif-group { + display: flex; + flex-direction: column; + gap: .25rem; + margin-bottom: .75rem; +} +.notif-details { + margin-bottom: 1rem; +} +.notif-details summary { + cursor: pointer; + font-size: .9rem; +} +.notif-details[open] summary { + margin-bottom: .75rem; +} .storage-add-details[open] summary { margin-bottom: 1rem; }