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:
2026-10-12 16:50:41 +02:00
parent 68294ecb6f
commit 462196e29b
11 changed files with 659 additions and 89 deletions
+30
View File
@@ -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)
**MinAgent: 0.131.0** (unchanged — no agent call changed). **R-266 needs the hub of the same day** to act on the new
+1
View File
@@ -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 |
+18 -3
View File
@@ -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 (`<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
@@ -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
---
+11 -2
View File
@@ -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.<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_jelszavas_vedelem_nincs_beallitva": "Password protection is not set up. Ask the operator to set it up.",
+11 -2
View File
@@ -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.<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_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)
}
+43
View File
@@ -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_<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) {
_ = 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.
+11 -2
View File
@@ -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{} {
@@ -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">
</div>
<div class="form-group">
<label>{{T "settings_notifications.hibak_es_figyelmeztetesek"}}</label>
<div class="checkbox-group">
<label>{{T "settings_notifications.groups_label"}}</label>
{{with .NotificationPrefs}}{{$st := .GroupState "machine"}}
<div class="notif-group">
<input type="hidden" name="group_machine_shown" value="{{$st}}">
<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_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>
<input type="checkbox" name="group_machine" {{if eq $st "on"}}checked{{end}}>
<span class="toggle-label">{{T "settings_notifications.group_machine"}}</span>{{if eq $st "custom"}}
<span class="badge badge-neutral">{{T "settings_notifications.group_custom"}}</span>{{end}}
</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 class="form-group">
<label>{{T "settings_notifications.tajekoztato"}}</label>
<div class="checkbox-group">
{{end}}
{{with .NotificationPrefs}}{{$st := .GroupState "apps"}}
<div class="notif-group">
<input type="hidden" name="group_apps_shown" value="{{$st}}">
<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>
<input type="checkbox" name="group_apps" {{if eq $st "on"}}checked{{end}}>
<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>
<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">
<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>
<input type="checkbox" name="group_handled" {{if eq $st "on"}}checked{{end}}>
<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>
<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>
{{end}}
</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>
<details class="notif-details" id="notif-details">
<summary>{{T "settings_notifications.reszletek"}}</summary>
<p class="form-hint">{{T "settings_notifications.reszletek_hint"}}</p>
<div class="form-group">
<label>{{T "settings_notifications.group_machine"}}</label>
<div class="checkbox-group">
<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 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">
<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>
@@ -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;
}