v0.309.0: the notification page shows three group switches; details and cooldown under Részletek (R-388 slice 1, decision 200)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 (`<details>`, 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)
|
## 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
|
**MinAgent: 0.131.0** (unchanged — no agent call changed). **R-266 needs the hub of the same day** to act on the new
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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 | — |
|
| `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.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 |
|
| `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 |
|
||||||
|
|||||||
+18
-3
@@ -2910,8 +2910,23 @@ live hardware), which is exactly what the marker covers.
|
|||||||
|
|
||||||
#### Default Enabled Events
|
#### Default Enabled Events
|
||||||
|
|
||||||
Events the customer receives notifications for (configurable in settings):
|
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`
|
`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 (`<details>`, 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_<key>_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
|
#### Preference Sync
|
||||||
|
|
||||||
@@ -3249,7 +3264,7 @@ Five sections:
|
|||||||
2. **Version & update** — current/latest version, check/update buttons, auto-update status, last update result
|
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
|
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
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1943,7 +1943,6 @@
|
|||||||
"settings_notifications.ertesitesi_szunet": "Notification pause",
|
"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.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.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_beteloben_90_felett": "Disk filling up (over 90%)",
|
||||||
"settings_notifications.lemez_megtelt_vagy_meghibasodott": "Disk full or failed",
|
"settings_notifications.lemez_megtelt_vagy_meghibasodott": "Disk full or failed",
|
||||||
"settings_notifications.masodlagos_mentes_sikertelen": "Secondary backup 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_helyreallt": "System health recovered",
|
||||||
"settings_notifications.rendszer_allapot_kritikus": "System health critical",
|
"settings_notifications.rendszer_allapot_kritikus": "System health critical",
|
||||||
"settings_notifications.szerver_nem_elerheto": "Server not reachable",
|
"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.tavoli_mentes_tarhelykeret_figyelmeztete": "Remote backup — storage quota warning",
|
||||||
"settings_notifications.teszt_email_kuldese": "Send test e-mail",
|
"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.<br>\n <small>The token needs the <strong>Zone WAF:Edit</strong> permission.</small>",
|
"settings_security.a_cloudflare_api_token_nincs": "The Cloudflare API token is not configured. Ask the operator to set it up.<br>\n <small>The token needs the <strong>Zone WAF:Edit</strong> permission.</small>",
|
||||||
"settings_security.a_helyi_halozati_hozzaferes_mindig": "Access from the local network is always allowed (it does not pass through Cloudflare).",
|
"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.",
|
"settings_security.a_jelszavas_vedelem_nincs_beallitva": "Password protection is not set up. Ask the operator to set it up.",
|
||||||
|
|||||||
@@ -1931,7 +1931,6 @@
|
|||||||
"settings_notifications.ertesitesi_szunet": "Értesítési szünet",
|
"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.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.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_beteloben_90_felett": "Lemez betelőben (90% felett)",
|
||||||
"settings_notifications.lemez_megtelt_vagy_meghibasodott": "Lemez megtelt vagy meghibásodott",
|
"settings_notifications.lemez_megtelt_vagy_meghibasodott": "Lemez megtelt vagy meghibásodott",
|
||||||
"settings_notifications.masodlagos_mentes_sikertelen": "Másodlagos mentés sikertelen",
|
"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_helyreallt": "Rendszer állapot helyreállt",
|
||||||
"settings_notifications.rendszer_allapot_kritikus": "Rendszer állapot kritikus",
|
"settings_notifications.rendszer_allapot_kritikus": "Rendszer állapot kritikus",
|
||||||
"settings_notifications.szerver_nem_elerheto": "Szerver nem elérhető",
|
"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.tavoli_mentes_tarhelykeret_figyelmeztete": "Távoli mentés — tárhelykeret-figyelmeztetés",
|
||||||
"settings_notifications.teszt_email_kuldese": "Teszt email küldése",
|
"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.<br>\n <small>A tokennek <strong>Zone WAF:Edit</strong> jogosultsággal kell rendelkeznie.</small>",
|
"settings_security.a_cloudflare_api_token_nincs": "A Cloudflare API token nincs konfigurálva. Kérd az üzemeltetőt a beállításhoz.<br>\n <small>A tokennek <strong>Zone WAF:Edit</strong> jogosultsággal kell rendelkeznie.</small>",
|
||||||
"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_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.",
|
"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.",
|
||||||
|
|||||||
@@ -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_<key>` / `group_<key>_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)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
"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/scheduler"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
@@ -2846,6 +2847,47 @@ func dedupeEvents(in []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyNotificationGroupSwitches — R-388 slice 1 (`08` §8.1). The page posts, per group, the switch
|
||||||
|
// (`group_<key>` = "on" when ticked) and the state it was RENDERED in (`group_<key>_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) {
|
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
|
|
||||||
@@ -2916,6 +2958,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
|||||||
enabledEvents = append(enabledEvents, c.events...)
|
enabledEvents = append(enabledEvents, c.events...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enabledEvents = s.applyNotificationGroupSwitches(r, enabledEvents)
|
||||||
enabledEvents = dedupeEvents(enabledEvents)
|
enabledEvents = dedupeEvents(enabledEvents)
|
||||||
|
|
||||||
// R-329 Part 4 — A SAVE THAT CHANGES NOTHING MUST STORE NOTHING NEW.
|
// R-329 Part 4 — A SAVE THAT CHANGES NOTHING MUST STORE NOTHING NEW.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,7 +263,7 @@ func i18nCasesA() []i18nCase {
|
|||||||
d := i18nLayoutData("settings-notifications", "Értesítések")
|
d := i18nLayoutData("settings-notifications", "Értesítések")
|
||||||
d["HubEnabled"] = true
|
d["HubEnabled"] = true
|
||||||
d["NotificationSuccess"], d["NotificationError"] = "A beállítás elmentve.", "A mentés nem sikerült."
|
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["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."
|
d["AppEmailSuccess"], d["AppEmailError"] = "Az e-mail beállítás mentve.", "Az e-mail beállítás hibás."
|
||||||
return d
|
return d
|
||||||
@@ -272,7 +273,15 @@ func i18nCasesA() []i18nCase {
|
|||||||
d := i18nLayoutData("settings-notifications", "Értesítések")
|
d := i18nLayoutData("settings-notifications", "Értesítések")
|
||||||
d["HubEnabled"] = true
|
d["HubEnabled"] = true
|
||||||
d["NotificationClearConfirm"] = 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
|
return d
|
||||||
}},
|
}},
|
||||||
{"settings_notifications_nohub", "settings_notifications", func() map[string]interface{} {
|
{"settings_notifications_nohub", "settings_notifications", func() map[string]interface{} {
|
||||||
|
|||||||
@@ -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(`<input ([^>]*)>`)
|
||||||
|
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, `<span class="badge badge-neutral">egyedi</span>`) {
|
||||||
|
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, `<details class="notif-details"`)
|
||||||
|
if open < 0 {
|
||||||
|
t.Fatalf("no „Részletek\" fold on the page")
|
||||||
|
}
|
||||||
|
fold := body[open:]
|
||||||
|
fold = fold[:strings.Index(fold, `</details>`)]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,96 +24,144 @@
|
|||||||
placeholder="{{T "settings_notifications.pelda_email"}}" class="form-control">
|
placeholder="{{T "settings_notifications.pelda_email"}}" class="form-control">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{T "settings_notifications.hibak_es_figyelmeztetesek"}}</label>
|
<label>{{T "settings_notifications.groups_label"}}</label>
|
||||||
<div class="checkbox-group">
|
{{with .NotificationPrefs}}{{$st := .GroupState "machine"}}
|
||||||
|
<div class="notif-group">
|
||||||
|
<input type="hidden" name="group_machine_shown" value="{{$st}}">
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" name="event_backup_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "backup_failed"}}checked{{end}}{{end}}{{end}}>
|
<input type="checkbox" name="group_machine" {{if eq $st "on"}}checked{{end}}>
|
||||||
<span class="toggle-label">{{T "settings_notifications.biztonsagi_mentes_sikertelen"}}</span>
|
<span class="toggle-label">{{T "settings_notifications.group_machine"}}</span>{{if eq $st "custom"}}
|
||||||
</label>
|
<span class="badge badge-neutral">{{T "settings_notifications.group_custom"}}</span>{{end}}
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_db_dump_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "db_dump_failed"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.adatbazis_mentes_sikertelen"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_backup_integrity_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "backup_integrity_failed"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.mentes_serules_eszlelve"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_crossdrive_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "crossdrive_failed"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.masodlagos_mentes_sikertelen"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_offbox_enlarge_blocked" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "offbox_enlarge_blocked"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_disk_warning" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "disk_warning"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.lemez_beteloben_90_felett"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_disk_critical" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "disk_critical"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.lemez_megtelt_vagy_meghibasodott"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_storage_disconnected" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "storage_disconnected"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.meghajto_levalasztva"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_node_down" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "node_down"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.szerver_nem_elerheto"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_health_critical" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "health_critical"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.rendszer_allapot_kritikus"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_expected_backup_missed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "expected_backup_missed"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.elvart_fajlmentes_elmaradt"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_expected_dbdump_missed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "expected_dbdump_missed"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.elvart_adatbazismentes_elmaradt"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_app_start_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_start_failed"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.alkalmazas_nem_fut"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_app_update_undone" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_undone"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.app_update_undone"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_app_update_held" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_held"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.app_update_held"}}</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" name="event_app_stopped_unhealthy" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_stopped_unhealthy"}}checked{{end}}{{end}}{{end}}>
|
|
||||||
<span class="toggle-label">{{T "settings_notifications.app_stopped_unhealthy"}}</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
<span class="form-hint">{{T "settings_notifications.group_machine_desc"}}</span>{{if eq $st "custom"}}
|
||||||
|
<span class="form-hint">{{T "settings_notifications.group_custom_hint"}} <a href="#notif-details">{{T "settings_notifications.reszletek"}}</a></span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
<div class="form-group">
|
{{with .NotificationPrefs}}{{$st := .GroupState "apps"}}
|
||||||
<label>{{T "settings_notifications.tajekoztato"}}</label>
|
<div class="notif-group">
|
||||||
<div class="checkbox-group">
|
<input type="hidden" name="group_apps_shown" value="{{$st}}">
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" name="event_storage_reconnected" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "storage_reconnected"}}checked{{end}}{{end}}{{end}}>
|
<input type="checkbox" name="group_apps" {{if eq $st "on"}}checked{{end}}>
|
||||||
<span class="toggle-label">{{T "settings_notifications.meghajto_ujra_csatlakoztatva"}}</span>
|
<span class="toggle-label">{{T "settings_notifications.group_apps"}}</span>{{if eq $st "custom"}}
|
||||||
|
<span class="badge badge-neutral">{{T "settings_notifications.group_custom"}}</span>{{end}}
|
||||||
</label>
|
</label>
|
||||||
|
<span class="form-hint">{{T "settings_notifications.group_apps_desc"}}</span>{{if eq $st "custom"}}
|
||||||
|
<span class="form-hint">{{T "settings_notifications.group_custom_hint"}} <a href="#notif-details">{{T "settings_notifications.reszletek"}}</a></span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{with .NotificationPrefs}}{{$st := .GroupState "handled"}}
|
||||||
|
<div class="notif-group">
|
||||||
|
<input type="hidden" name="group_handled_shown" value="{{$st}}">
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" name="event_health_recovered" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "health_recovered"}}checked{{end}}{{end}}{{end}}>
|
<input type="checkbox" name="group_handled" {{if eq $st "on"}}checked{{end}}>
|
||||||
<span class="toggle-label">{{T "settings_notifications.rendszer_allapot_helyreallt"}}</span>
|
<span class="toggle-label">{{T "settings_notifications.group_handled"}}</span>{{if eq $st "custom"}}
|
||||||
|
<span class="badge badge-neutral">{{T "settings_notifications.group_custom"}}</span>{{end}}
|
||||||
</label>
|
</label>
|
||||||
|
<span class="form-hint">{{T "settings_notifications.group_handled_desc"}}</span>{{if eq $st "custom"}}
|
||||||
|
<span class="form-hint">{{T "settings_notifications.group_custom_hint"}} <a href="#notif-details">{{T "settings_notifications.reszletek"}}</a></span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<details class="notif-details" id="notif-details">
|
||||||
<label for="cooldown_hours">{{T "settings_notifications.ertesitesi_szunet"}}</label>
|
<summary>{{T "settings_notifications.reszletek"}}</summary>
|
||||||
<div class="form-inline">
|
<p class="form-hint">{{T "settings_notifications.reszletek_hint"}}</p>
|
||||||
<input type="number" id="cooldown_hours" name="cooldown_hours" min="1" max="168"
|
<div class="form-group">
|
||||||
value="{{with .NotificationPrefs}}{{.CooldownHours}}{{end}}"
|
<label>{{T "settings_notifications.group_machine"}}</label>
|
||||||
class="form-control form-control-narrow">
|
<div class="checkbox-group">
|
||||||
<span class="form-hint">{{T "settings_notifications.ora_azonos_problema_eseten_ennyi"}}</span>
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_storage_disconnected" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "storage_disconnected"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.meghajto_levalasztva"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_storage_reconnected" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "storage_reconnected"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.meghajto_ujra_csatlakoztatva"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_disk_warning" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "disk_warning"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.lemez_beteloben_90_felett"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_disk_critical" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "disk_critical"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.lemez_megtelt_vagy_meghibasodott"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_offbox_enlarge_blocked" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "offbox_enlarge_blocked"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete"}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-group">
|
||||||
|
<label>{{T "settings_notifications.group_apps"}}</label>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_app_stopped_unhealthy" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_stopped_unhealthy"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.app_stopped_unhealthy"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_app_start_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_start_failed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.alkalmazas_nem_fut"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_app_update_held" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_held"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.app_update_held"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_app_update_undone" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_undone"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.app_update_undone"}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{T "settings_notifications.group_handled"}}</label>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_backup_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "backup_failed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.biztonsagi_mentes_sikertelen"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_db_dump_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "db_dump_failed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.adatbazis_mentes_sikertelen"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_backup_integrity_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "backup_integrity_failed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.mentes_serules_eszlelve"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_crossdrive_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "crossdrive_failed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.masodlagos_mentes_sikertelen"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_expected_backup_missed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "expected_backup_missed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.elvart_fajlmentes_elmaradt"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_expected_dbdump_missed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "expected_dbdump_missed"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.elvart_adatbazismentes_elmaradt"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_node_down" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "node_down"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.szerver_nem_elerheto"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_health_critical" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "health_critical"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.rendszer_allapot_kritikus"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="event_health_recovered" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "health_recovered"}}checked{{end}}{{end}}{{end}}>
|
||||||
|
<span class="toggle-label">{{T "settings_notifications.rendszer_allapot_helyreallt"}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cooldown_hours">{{T "settings_notifications.ertesitesi_szunet"}}</label>
|
||||||
|
<div class="form-inline">
|
||||||
|
<input type="number" id="cooldown_hours" name="cooldown_hours" min="1" max="168"
|
||||||
|
value="{{with .NotificationPrefs}}{{.CooldownHours}}{{end}}"
|
||||||
|
class="form-control form-control-narrow">
|
||||||
|
<span class="form-hint">{{T "settings_notifications.ora_azonos_problema_eseten_ennyi"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">{{T "settings_notifications.mentes"}}</button>
|
<button type="submit" class="btn btn-primary">{{T "settings_notifications.mentes"}}</button>
|
||||||
<button type="submit" formaction="/settings/notifications/test" class="btn btn-outline">{{T "settings_notifications.teszt_email_kuldese"}}</button>
|
<button type="submit" formaction="/settings/notifications/test" class="btn btn-outline">{{T "settings_notifications.teszt_email_kuldese"}}</button>
|
||||||
|
|||||||
@@ -2639,6 +2639,23 @@ a.stat-card:hover {
|
|||||||
.storage-add-details {
|
.storage-add-details {
|
||||||
margin-top: .5rem;
|
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 {
|
.storage-add-details[open] summary {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user