diff --git a/REUSE.md b/REUSE.md index 0c5fc12..80e2f96 100644 --- a/REUSE.md +++ b/REUSE.md @@ -62,6 +62,7 @@ | `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 | | `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate | | `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) | +| `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) | | `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the text fallback in `offboxWarningDisplay` is LEGACY only (kind == "") and is removed when R-570 closes | ### HTTP/JSON envelopes + flash messages diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 2c589cc..924b321 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1563,6 +1563,13 @@ "func.time.tomorrow_at": "tomorrow %s", "func.time.yesterday": "yesterday", "health.no_probe_container": "No health check ran: no matching container.", + "health.system_disk_high": "System storage is filling up: %.0f%% used", + "health.system_disk_critical": "System storage is almost full: %.0f%% used", + "health.data_disk_high": "The data drive is filling up: %.0f%% used", + "health.data_disk_critical": "The data drive is almost full: %.0f%% used", + "health.memory_high": "Memory usage high: %.0f%%", + "health.cpu_high": "CPU usage high: %.0f%%", + "health.temperature_high": "Temperature high: %.0f°C (%s)", "hold.copy_holds.db_only": "holds only the settings and the database, not the files", "hold.copy_holds.files": "holds the settings, the database and the files", "hold.copy_holds.volumes": "holds the settings, the database and the data volumes", @@ -2255,7 +2262,7 @@ "storage.levalasztas": "Disconnect", "storage.levalasztas_folyamatban": "Disconnecting…", "storage.levalasztva": "Disconnected", - "storage.levalasztva_2": "Disconnected: {{.DisconnectedAt}}", + "storage.levalasztva_2": "Disconnected: {{fmtTimeStr .DisconnectedAt}}", "storage.majd_a_z": "then", "storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Add already mounted storage by hand", "storage.megerosites": "Confirm", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 54fd8c9..ffa15de 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -7,7 +7,7 @@ "alert.agent_channel.unknown": "A tárolókezelő ügynök nem elérhető.", "alert.agent_channel.unreachable": "A tárolókezelő ügynök nem elérhető.", "alert.backup.disabled": "A biztonsági mentés nincs bekapcsolva", - "alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", + "alert.deadapp.group": "%d telepített alkalmazás nem fut — nézd meg a rendszermonitort", "alert.deadapp.single": "Telepített alkalmazás nem fut: %s", "alert.deadapp.single_state": "Telepített alkalmazás nem fut: %s (%s)", "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.", @@ -1551,6 +1551,13 @@ "func.time.tomorrow_at": "holnap %s", "func.time.yesterday": "tegnap", "health.no_probe_container": "Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.", + "health.system_disk_high": "A rendszer tárhelye fogy: %.0f%% foglalt", + "health.system_disk_critical": "A rendszer tárhelye majdnem megtelt: %.0f%% foglalt", + "health.data_disk_high": "Az adatmeghajtó tárhelye fogy: %.0f%% foglalt", + "health.data_disk_critical": "Az adatmeghajtó majdnem megtelt: %.0f%% foglalt", + "health.memory_high": "A memóriahasználat magas: %.0f%%", + "health.cpu_high": "A processzor terhelése magas: %.0f%%", + "health.temperature_high": "A hőmérséklet magas: %.0f°C (%s)", "hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem", "hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza", "hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza", @@ -2243,7 +2250,7 @@ "storage.levalasztas": "Leválasztás", "storage.levalasztas_folyamatban": "Leválasztás folyamatban…", "storage.levalasztva": "Leválasztva", - "storage.levalasztva_2": "Leválasztva: {{.DisconnectedAt}}", + "storage.levalasztva_2": "Leválasztva: {{fmtTimeStr .DisconnectedAt}}", "storage.majd_a_z": "majd a(z)", "storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Már csatlakoztatott tárhely hozzáadása kézzel", "storage.megerosites": "Megerősítés", diff --git a/controller/internal/monitor/healthcheck.go b/controller/internal/monitor/healthcheck.go index 18006c6..ae80826 100644 --- a/controller/internal/monitor/healthcheck.go +++ b/controller/internal/monitor/healthcheck.go @@ -26,8 +26,15 @@ type HealthReport struct { // Issues and Warnings only, so the hub report is unchanged — pinned by // TestR553_HubReportWarningsAreUnchangedOnTheWire. WarningKinds []string - Info []string // informational items - Timestamp time.Time + // WarningMsgs / IssueMsgs are parallel to Warnings / Issues (R-516 item 10): the bundle key and + // arguments of the dashboard's OWN sentence for that entry, in the household's language. The + // Warnings/Issues text stays exactly what it was — it is ON THE WIRE (report health.*), and + // internal/report/builder.go copies Status, Issues and Warnings only (pinned by + // TestR553_HubReportWarningsAreUnchangedOnTheWire). An entry with no key shows its text verbatim. + WarningMsgs []MsgRef + IssueMsgs []MsgRef + Info []string // informational items + Timestamp time.Time } // Warning kinds (R-553). A kind names WHAT the warning is about; the text stays the only thing shown. @@ -54,8 +61,43 @@ const ( // addWarning appends a warning together with its kind, so the two slices cannot drift apart. Every // warning goes through here; `WarningKindAt` reads them back. func (r *HealthReport) addWarning(text, kind string) { + r.addWarningMsg(text, kind, MsgRef{}) +} + +// MsgRef names a dashboard sentence by bundle key; the zero value means "show the text verbatim". +type MsgRef struct { + Key string + Args []interface{} +} + +// addWarningMsg is addWarning with the dashboard's own sentence beside the wire text (R-516 item 10). +func (r *HealthReport) addWarningMsg(text, kind string, msg MsgRef) { r.Warnings = append(r.Warnings, text) r.WarningKinds = append(r.WarningKinds, kind) + r.WarningMsgs = append(r.WarningMsgs, msg) +} + +// addIssue appends a critical issue with its dashboard sentence (zero MsgRef = verbatim), so the two +// slices cannot drift apart. Every issue goes through here; IssueMsgAt reads them back. +func (r *HealthReport) addIssue(text string, msg MsgRef) { + r.Issues = append(r.Issues, text) + r.IssueMsgs = append(r.IssueMsgs, msg) +} + +// WarningMsgAt / IssueMsgAt return the dashboard sentence of entry i, or the zero MsgRef when there is +// none (a report built by hand in a test, or an entry with no key). +func (r *HealthReport) WarningMsgAt(i int) MsgRef { + if r == nil || i < 0 || i >= len(r.WarningMsgs) { + return MsgRef{} + } + return r.WarningMsgs[i] +} + +func (r *HealthReport) IssueMsgAt(i int) MsgRef { + if r == nil || i < 0 || i >= len(r.IssueMsgs) { + return MsgRef{} + } + return r.IssueMsgs[i] } // WarningKindAt returns the kind of Warnings[i], or "" when there is none (older callers, a report @@ -89,109 +131,11 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora sysInfo.CPUPercent, sysInfo.TemperatureCelsius, sysInfo.TemperatureSource) } - // 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller - // container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this - // IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the - // rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used - // trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before - // the deploy gate even engages. - if sysInfo.DiskPercent > 0 { - if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) { - report.Issues = append(report.Issues, fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent)) - if logger != nil { - logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent) - } - if debug { - logger.Printf("[DEBUG] [monitor] SSD disk: CRITICAL (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent) - } - } else if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) { - report.addWarning(fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent), "") - if logger != nil { - logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent) - } - if debug { - logger.Printf("[DEBUG] [monitor] SSD disk: WARN (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent) - } - } else { - report.Info = append(report.Info, fmt.Sprintf("SSD: %.0f%% used", sysInfo.DiskPercent)) - if debug { - logger.Printf("[DEBUG] [monitor] SSD disk: OK (%.0f%%)", sysInfo.DiskPercent) - } - } - } - - // HDD disk usage - if sysInfo.HDDConfigured && sysInfo.HDDPercent > 0 { - if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) { - report.Issues = append(report.Issues, fmt.Sprintf("HDD disk usage critical: %.0f%%", sysInfo.HDDPercent)) - if logger != nil { - logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskCritPercent) - } - } else if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) { - report.addWarning(fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent), "") - if logger != nil { - logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskWarnPercent) - } - } - } - - // 2. Memory usage - if sysInfo.MemPercent > 0 { - if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) { - report.addWarning(fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent), "") - if logger != nil { - logger.Printf("[WARN] [monitor] Memory threshold breached: %.0f%% (limit: %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent) - } - if debug { - logger.Printf("[DEBUG] [monitor] Memory: WARN (%.0f%% >= %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent) - } - } else { - report.Info = append(report.Info, fmt.Sprintf("Memory: %.0f%% used", sysInfo.MemPercent)) - if debug { - logger.Printf("[DEBUG] [monitor] Memory: OK (%.0f%%)", sysInfo.MemPercent) - } - } - } - - // 3. CPU usage - if sysInfo.CPUPercent > 0 { - if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) { - report.addWarning(fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent), "") - if logger != nil { - logger.Printf("[WARN] [monitor] CPU threshold breached: %.0f%% (limit: %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent) - } - if debug { - logger.Printf("[DEBUG] [monitor] CPU: WARN (%.0f%% >= %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent) - } - } else { - report.Info = append(report.Info, fmt.Sprintf("CPU: %.0f%%", sysInfo.CPUPercent)) - if debug { - logger.Printf("[DEBUG] [monitor] CPU: OK (%.0f%%)", sysInfo.CPUPercent) - } - } - } - - // 4. Temperature - if sysInfo.TemperatureCelsius > 0 { - if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) { - report.addWarning(fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource), "") - if logger != nil { - logger.Printf("[WARN] [monitor] Temperature threshold breached: %.0f°C (limit: %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius) - } - if debug { - logger.Printf("[DEBUG] [monitor] Temperature: WARN (%.0f°C >= %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius) - } - } else { - report.Info = append(report.Info, fmt.Sprintf("Temperature: %.0f°C", sysInfo.TemperatureCelsius)) - if debug { - logger.Printf("[DEBUG] [monitor] Temperature: OK (%.0f°C)", sysInfo.TemperatureCelsius) - } - } - } + checkResources(report, sysInfo, cfg, logger, debug) // 5. Docker health if err := checkDocker(); err != nil { - report.Issues = append(report.Issues, fmt.Sprintf("Docker: %v", err)) + report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{}) if debug { logger.Printf("[DEBUG] [monitor] Docker daemon: FAIL (%v)", err) } @@ -210,7 +154,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora } missingProtected := checkProtectedContainers(protected) for _, name := range missingProtected { - report.Issues = append(report.Issues, fmt.Sprintf("Protected container not running: %s", name)) + report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{}) } if debug { if len(missingProtected) > 0 { @@ -222,7 +166,9 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora // 7. Storage paths storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths) - report.Issues = append(report.Issues, storageIssues...) + for _, is := range storageIssues { + report.addIssue(is, MsgRef{}) + } for i, w := range storageWarnings { report.addWarning(w, storageKinds[i]) } @@ -395,3 +341,115 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds [] } return } + +// checkResources is the threshold half of RunHealthCheck (disk, memory, CPU, temperature), split out +// so a test can drive it with a made-up SystemInfo instead of the box it runs on (R-516 item 10: +// TestR516_ResourceWarningsCarryTheirDashboardSentence). +func checkResources(report *HealthReport, sysInfo system.SystemInfo, cfg *config.Config, logger *log.Logger, debug bool) { + // 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller + // container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this + // IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the + // rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used + // trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before + // the deploy gate even engages. + if sysInfo.DiskPercent > 0 { + if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) { + report.addIssue(fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent), + MsgRef{"health.system_disk_critical", []interface{}{sysInfo.DiskPercent}}) + if logger != nil { + logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent) + } + if debug { + logger.Printf("[DEBUG] [monitor] SSD disk: CRITICAL (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent) + } + } else if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) { + report.addWarningMsg(fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent), "", + MsgRef{"health.system_disk_high", []interface{}{sysInfo.DiskPercent}}) + if logger != nil { + logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent) + } + if debug { + logger.Printf("[DEBUG] [monitor] SSD disk: WARN (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent) + } + } else { + report.Info = append(report.Info, fmt.Sprintf("SSD: %.0f%% used", sysInfo.DiskPercent)) + if debug { + logger.Printf("[DEBUG] [monitor] SSD disk: OK (%.0f%%)", sysInfo.DiskPercent) + } + } + } + + // HDD disk usage + if sysInfo.HDDConfigured && sysInfo.HDDPercent > 0 { + if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) { + report.addIssue(fmt.Sprintf("HDD disk usage critical: %.0f%%", sysInfo.HDDPercent), + MsgRef{"health.data_disk_critical", []interface{}{sysInfo.HDDPercent}}) + if logger != nil { + logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskCritPercent) + } + } else if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) { + report.addWarningMsg(fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent), "", + MsgRef{"health.data_disk_high", []interface{}{sysInfo.HDDPercent}}) + if logger != nil { + logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskWarnPercent) + } + } + } + + // 2. Memory usage + if sysInfo.MemPercent > 0 { + if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) { + report.addWarningMsg(fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent), "", + MsgRef{"health.memory_high", []interface{}{sysInfo.MemPercent}}) + if logger != nil { + logger.Printf("[WARN] [monitor] Memory threshold breached: %.0f%% (limit: %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent) + } + if debug { + logger.Printf("[DEBUG] [monitor] Memory: WARN (%.0f%% >= %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent) + } + } else { + report.Info = append(report.Info, fmt.Sprintf("Memory: %.0f%% used", sysInfo.MemPercent)) + if debug { + logger.Printf("[DEBUG] [monitor] Memory: OK (%.0f%%)", sysInfo.MemPercent) + } + } + } + + // 3. CPU usage + if sysInfo.CPUPercent > 0 { + if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) { + report.addWarningMsg(fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent), "", + MsgRef{"health.cpu_high", []interface{}{sysInfo.CPUPercent}}) + if logger != nil { + logger.Printf("[WARN] [monitor] CPU threshold breached: %.0f%% (limit: %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent) + } + if debug { + logger.Printf("[DEBUG] [monitor] CPU: WARN (%.0f%% >= %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent) + } + } else { + report.Info = append(report.Info, fmt.Sprintf("CPU: %.0f%%", sysInfo.CPUPercent)) + if debug { + logger.Printf("[DEBUG] [monitor] CPU: OK (%.0f%%)", sysInfo.CPUPercent) + } + } + } + + // 4. Temperature + if sysInfo.TemperatureCelsius > 0 { + if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) { + report.addWarningMsg(fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource), "", + MsgRef{"health.temperature_high", []interface{}{sysInfo.TemperatureCelsius, sysInfo.TemperatureSource}}) + if logger != nil { + logger.Printf("[WARN] [monitor] Temperature threshold breached: %.0f°C (limit: %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius) + } + if debug { + logger.Printf("[DEBUG] [monitor] Temperature: WARN (%.0f°C >= %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius) + } + } else { + report.Info = append(report.Info, fmt.Sprintf("Temperature: %.0f°C", sysInfo.TemperatureCelsius)) + if debug { + logger.Printf("[DEBUG] [monitor] Temperature: OK (%.0f°C)", sysInfo.TemperatureCelsius) + } + } + } +} diff --git a/controller/internal/monitor/r516_resource_msgs_test.go b/controller/internal/monitor/r516_resource_msgs_test.go new file mode 100644 index 0000000..dad00bb --- /dev/null +++ b/controller/internal/monitor/r516_resource_msgs_test.go @@ -0,0 +1,64 @@ +package monitor + +import ( + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// R-516 item 10 — the resource warnings („SSD disk usage high: 90%") reached the household's banner in +// English on every page. checkResources now attaches the dashboard's own sentence (a bundle key) beside +// each one, and the wire text is byte-for-byte what it was. +// +// COMPANION RED-PROOF: give the SSD warning a zero MsgRef (addWarning) — the "warn" case fails here +// and the banner test in internal/web shows the English wire text again. +func TestR516_ResourceWarningsCarryTheirDashboardSentence(t *testing.T) { + cfg := config.Default() + cases := []struct { + name string + info system.SystemInfo + wantIssue, wantKey []string + wantWire []string + }{ + {"warn", system.SystemInfo{DiskPercent: 85, HDDConfigured: true, HDDPercent: 85, MemPercent: 90, CPUPercent: 95, + TemperatureCelsius: 80, TemperatureSource: "coretemp"}, nil, + []string{"health.system_disk_high", "health.data_disk_high", "health.memory_high", "health.cpu_high", "health.temperature_high"}, + []string{"SSD disk usage high: 85%", "HDD disk usage high: 85%", "Memory usage high: 90%", "CPU usage high: 95%", "Temperature high: 80°C (coretemp)"}}, + {"critical", system.SystemInfo{DiskPercent: 95, HDDConfigured: true, HDDPercent: 96}, + []string{"health.system_disk_critical", "health.data_disk_critical"}, nil, + []string{"SSD disk usage critical: 95%", "HDD disk usage critical: 96%"}}, + } + for _, c := range cases { + r := &HealthReport{} + checkResources(r, c.info, cfg, nil, false) + texts, keys := r.Warnings, []string{} + for i := range r.Warnings { + keys = append(keys, r.WarningMsgAt(i).Key) + } + if c.wantIssue != nil { + texts, keys = r.Issues, nil + for i := range r.Issues { + keys = append(keys, r.IssueMsgAt(i).Key) + } + } + want := c.wantKey + if c.wantIssue != nil { + want = c.wantIssue + } + if len(texts) != len(c.wantWire) { + t.Fatalf("%s: %d entries %q, want %d — the cases below would be compared against nothing", c.name, len(texts), texts, len(c.wantWire)) + } + for i := range texts { + if texts[i] != c.wantWire[i] { + t.Errorf("%s: the wire text moved: %q, want %q", c.name, texts[i], c.wantWire[i]) + } + if keys[i] != want[i] { + t.Errorf("%s: %q carries dashboard key %q, want %q", c.name, texts[i], keys[i], want[i]) + } + } + if len(r.Warnings) != len(r.WarningKinds) || len(r.Warnings) != len(r.WarningMsgs) || len(r.Issues) != len(r.IssueMsgs) { + t.Errorf("%s: the parallel slices drifted apart", c.name) + } + } +} diff --git a/controller/internal/web/alerts.go b/controller/internal/web/alerts.go index f560152..072a43c 100644 --- a/controller/internal/web/alerts.go +++ b/controller/internal/web/alerts.go @@ -235,11 +235,16 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config } // From health check issues (critical) - for _, issue := range report.Issues { + for i, issue := range report.Issues { + // R-516 item 10: the banner shows the dashboard's own sentence (household language) when the + // check named one; the wire text is the fallback and stays untouched on the wire. + msg := report.IssueMsgAt(i) alerts = append(alerts, Alert{ ID: "health-" + simpleHash(issue), Level: "error", - Message: issue, // ON THE WIRE (report health.issues) -- not ours to translate; slice 3 + Message: issue, // ON THE WIRE (report health.issues) -- the fallback when MessageKey is empty + MessageKey: msg.Key, + MessageArgs: msg.Args, Link: "/monitoring", LinkTextKey: "alert.link.monitoring", }) @@ -247,10 +252,21 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config // From health check warnings for i, w := range report.Warnings { + // R-516 item 8: a disconnected drive already has its own banner above (alert.storage.disconnected, + // in the household's language). The health check's „Meghajtó leválasztva" warning for the same + // drive was a SECOND banner on every page — dropped here whenever the dedicated banners were built + // from the storage list. The warning itself stays on the wire (report health.warnings). + // Pinned by TestR516_DisconnectedDriveHasOneBanner. + if len(storagePaths) > 0 && report.WarningKindAt(i) == monitor.WarnKindStorageDisconnected { + continue + } + msg := report.WarningMsgAt(i) // R-516 item 10, as for the issues above alert := Alert{ ID: "health-" + simpleHash(w), Level: "warning", - Message: w, // ON THE WIRE (report health.warnings) -- not ours to translate; slice 3 + Message: w, // ON THE WIRE (report health.warnings) -- the fallback when MessageKey is empty + MessageKey: msg.Key, + MessageArgs: msg.Args, Link: "/monitoring", LinkTextKey: "alert.link.monitoring", } diff --git a/controller/internal/web/i18n_flash_test.go b/controller/internal/web/i18n_flash_test.go index 5fbebc5..ef9b93a 100644 --- a/controller/internal/web/i18n_flash_test.go +++ b/controller/internal/web/i18n_flash_test.go @@ -194,10 +194,10 @@ func TestPluralEnglish(t *testing.T) { hu7 := am.GetAlerts("hu")[0].Message en7 := am.GetAlerts("en")[0].Message - if hu4 != "4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { + if hu4 != "4 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { // R-516 item 9: te-form t.Errorf("the Hungarian sentence moved: %q", hu4) } - if hu7 != "7 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { + if hu7 != "7 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { // R-516 item 9: te-form t.Errorf("the Hungarian sentence moved: %q", hu7) } if !strings.Contains(en4, "4 installed apps are not running") { @@ -213,7 +213,7 @@ func TestPluralEnglish(t *testing.T) { t.Errorf("English singular: %q", got) } // And Hungarian at 1 is the SAME single form — no .one, no change. - if got := b.Msgf("hu", "alert.deadapp.group", 1); got != "1 telepített alkalmazás nem fut — nézze meg a rendszermonitort" { + if got := b.Msgf("hu", "alert.deadapp.group", 1); got != "1 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { t.Errorf("Hungarian must have one form at every count: %q", got) } } diff --git a/controller/internal/web/r516_items_test.go b/controller/internal/web/r516_items_test.go new file mode 100644 index 0000000..c291fb3 --- /dev/null +++ b/controller/internal/web/r516_items_test.go @@ -0,0 +1,113 @@ +package web + +import ( + "io" + "log" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-516 item 8 — a disconnected drive showed TWO banners on every page: its own +// (alert.storage.disconnected, in the household's language) and the health check's Hungarian +// „Meghajtó leválasztva" warning for the same drive. The dedicated banner is the one kept. +// +// COMPANION RED-PROOF: drop the WarnKindStorageDisconnected `continue` in AlertManager.Refresh — the +// first half fails with two banners. +func TestR516_DisconnectedDriveHasOneBanner(t *testing.T) { + const warn = "Meghajtó leválasztva: Külső (/mnt/usb)" + hr := &monitor.HealthReport{Status: "warn", Warnings: []string{warn, "CPU usage high: 91%"}, + WarningKinds: []string{monitor.WarnKindStorageDisconnected, ""}} + cfg := &config.Config{} + cfg.Hub.Enabled = false + sp := []settings.StoragePath{{Path: "/mnt/usb", Label: "Külső", Disconnected: true}} + + count := func(alerts []Alert) (dedicated, health, cpu int) { + for _, a := range alerts { + switch { + case strings.HasPrefix(a.ID, "storage-disconnected-"): + dedicated++ + case a.Message == warn: + health++ + case a.Message == "CPU usage high: 91%": + cpu++ + } + } + return + } + + am := NewAlertManager(log.New(io.Discard, "", 0)) + am.Refresh(hr, cfg, nil, false, "", sp) + d, h, c := count(am.GetAlerts("hu")) + if d != 1 || h != 0 { + t.Errorf("a disconnected drive must have exactly one banner: dedicated=%d health-warning=%d", d, h) + } + if c != 1 { + t.Errorf("positive control: another health warning must still reach the banners (got %d)", c) + } + + // Without the storage list there is no dedicated banner, so the health warning is the only one + // that can say it — it must stay. + am2 := NewAlertManager(log.New(io.Discard, "", 0)) + am2.Refresh(hr, cfg, nil, false, "") + if d, h, _ := count(am2.GetAlerts("hu")); d != 0 || h != 1 { + t.Errorf("without the storage list the health warning is the only banner: dedicated=%d health-warning=%d", d, h) + } +} + +// R-516 item 7 — the storage page printed the disconnect time as the raw stored RFC3339 UTC string +// („Leválasztva: 2026-09-14T19:58:02Z"). It now goes through fmtTimeStr, the page's local-time format, +// in both languages. +// +// COMPANION RED-PROOF: put `{{.DisconnectedAt}}` back in storage.levalasztva_2 (hu.json) — the hu half +// fails with the raw string. +func TestR516_DisconnectTimeIsLocalNotRawUTC(t *testing.T) { + const raw = "2026-09-14T19:58:02Z" + for _, tc := range []struct{ lang, prefix string }{{"hu", "Leválasztva: "}, {"en", "Disconnected: "}} { + d := i18nLayoutData("storage", "Tárhely") + paths := i18nStoragePaths() + paths[0]["DisconnectedAt"] = raw + d["StoragePaths"] = paths + h := renderBackupPageLang(t, tc.lang, "storage", d) + if strings.Contains(h, raw) { + t.Errorf("%s: the raw UTC timestamp reached the page", tc.lang) + } + if want := tc.prefix + fmtRFC3339Local(raw); !strings.Contains(h, want) { + t.Errorf("%s: the page must carry %q", tc.lang, want) + } + } +} + +// R-516 item 10 — a health warning with a dashboard sentence shows it in the household's language; +// a warning without one shows its wire text verbatim (the fail-open fallback). +func TestR516_HealthBannersFollowTheHousehold(t *testing.T) { + hr := &monitor.HealthReport{Status: "fail", + Issues: []string{"SSD disk usage critical: 95%"}, + IssueMsgs: []monitor.MsgRef{{Key: "health.system_disk_critical", Args: []interface{}{95.0}}}, + Warnings: []string{"SSD disk usage high: 90%", "Docker: something"}, WarningKinds: []string{"", ""}, + WarningMsgs: []monitor.MsgRef{{Key: "health.system_disk_high", Args: []interface{}{90.0}}, {}}} + cfg := &config.Config{} + cfg.Hub.Enabled = false + am := NewAlertManager(log.New(io.Discard, "", 0)) + am.Refresh(hr, cfg, nil, false, "") + got := map[string][]string{} + for _, lang := range []string{"hu", "en"} { + for _, a := range am.GetAlerts(lang) { + if strings.HasPrefix(a.ID, "health-") { + got[lang] = append(got[lang], a.Message) + } + } + } + want := map[string][]string{ + "hu": {"A rendszer tárhelye majdnem megtelt: 95% foglalt", "A rendszer tárhelye fogy: 90% foglalt", "Docker: something"}, + "en": {"System storage is almost full: 95% used", "System storage is filling up: 90% used", "Docker: something"}, + } + for lang, w := range want { + if strings.Join(got[lang], "|") != strings.Join(w, "|") { + t.Errorf("%s banners:\n got %q\n want %q", lang, got[lang], w) + } + } +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 090bac3..e08d721 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -161,6 +161,14 @@ "note.offsite.fail_locked": "BORN AS A KEY (R-104) -- the cause line for a repository lock that survived the self-heal; a NEW sentence, never a Go literal. Pinned in both languages by TestR104_SurvivingLockIsNamed.", "err.backup.restore_drive_gone": "BORN AS A KEY (R-362) -- names the drive a restore could not reach instead of a raw permission error; a NEW sentence, never a Go literal. Pinned in both languages by TestR362_DetachedDriveIsNamed.", "restore.refuse.files.second_drive_whole": "BORN AS A KEY (R-675) -- the unit-restore refusal names the second drive's WHOLE restore (decision 26); a NEW sentence, never a Go literal. Pinned in both languages by TestR675_RefusalNamesTheWholeCopy.", + "health.system_disk_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.system_disk_critical": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.data_disk_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.data_disk_critical": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.memory_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.cpu_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.temperature_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "alert.deadapp.group": "R-516 item 9 -- REWORDED on purpose: the formal „nézze meg\" became the product's te-form „nézd meg\"; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned in both languages by TestPluralEnglish (i18n_flash_test.go).", "event.appstop.failed": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", "event.appstop.failed_held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", "event.appstop.held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", @@ -641,7 +649,6 @@ "alert.link.settings": "Beállítások", "alert.link.monitoring": "Rendszermonitor", "alert.link.update": "Frissítés", - "alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", "alert.deadapp.single": [ "Telepített alkalmazás nem fut: " ], diff --git a/controller/scripts/i18n_missing_gate.py b/controller/scripts/i18n_missing_gate.py index 3f042f3..acf764b 100644 --- a/controller/scripts/i18n_missing_gate.py +++ b/controller/scripts/i18n_missing_gate.py @@ -38,7 +38,7 @@ TEMPLATE_ROOTS = [os.path.join(CTRL, "internal", "web", "templates")] MARKER = re.compile(r'\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}') EN_MISSING_CEILING = 0 -HU_FORMAL_CEILING = 14 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0) -> 18 (v0.269.0) -> 17 (v0.283.0, the recovery wizard, R-725) -> 14 (R-516 item 4, the storage pages in the te-form): the count follows the +HU_FORMAL_CEILING = 13 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0) -> 18 (v0.269.0) -> 17 (v0.283.0, the recovery wizard, R-725) -> 14 (R-516 item 4, the storage pages in the te-form) -> 13 (R-516 item 9, alert.deadapp.group „nézd meg"): the count follows the # conversion, it is not a judgement. Moving a Go literal into hu.json makes an „ön" form the gate could # not see before VISIBLE to it -- the two that arrived are `flash.login.password_changed` („kérjük") # and `alert.deadapp.group` („nézze meg"), both word-for-word what the Go code already said. Slice 2 may