R-553: four decisions stop reading their own Hungarian words (sites 1-4)

Every Hungarian sentence is byte-identical; each decision now reads a signal set where the message is
made. util.KindErrorf builds the same bytes fmt.Errorf did while carrying a sentinel for errors.Is.

- Deploy status (api/router.go): deployStatusFor() by kind — stacks.ErrAlreadyDeployed (409),
  ErrRequiredField / ErrPathMissing / ErrNotEnoughMemory (400). The „kötelező" / „memória" /
  "does not exist" / "already deployed" text chain is gone.
- Off-site failure class (backup/offbox.go): ErrOffsiteQuota replaces the „tárhelykeretet" match. The
  restic/ssh signatures stay text matches on purpose — that output is not ours and is not translated.
- Alert placement (web/alerts.go): monitor.HealthReport carries WarningKinds parallel to Warnings;
  the "not on a separate drive" warning is inline by KIND. The hub report is untouched (builder.go
  copies Status/Issues/Warnings only) — pinned by a wire test.
- Stale off-site note (web/handlers.go): settings LastWarningKind + backup.OffboxWarnNoAppsSelected.
  The text test survives ONLY for kind == "" (a box whose last run predates 0.251.0) and is removed
  when R-570 closes; slice 2 must not translate that producer before then.

Tests (all red-proofed by restoring the pre-fix predicate — see the audit's redproofs.txt):
TestR553_Deploy_DecisionSurvivesWordingChange, TestR553_DeployHandlerUsesTheKind,
TestR553_DeployProducersCarryKindAndKeepTheirWords (through the real DeployStack),
TestR553_OffsiteQuota_{Decision,HeadLine}SurvivesWordingChange, TestR553_OffboxRunRecordsTheKind,
TestR553_StorageWarningsCarryKindsAndKeepTheirWords, TestR553_DiskWarningPlacementSurvivesWordingChange,
TestR553_HubReportWarningsAreUnchangedOnTheWire, TestR553_StaleNote*, TestR553_WarningKindIsPersistedAndCopied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-17 20:58:45 +02:00
parent e236dca486
commit c00fed6db3
19 changed files with 816 additions and 55 deletions
+53 -16
View File
@@ -16,11 +16,42 @@ import (
// HealthReport contains the results of a system health check.
type HealthReport struct {
Status string // "ok", "warn", "fail"
Issues []string // critical problems
Warnings []string // non-critical warnings
Info []string // informational items
Timestamp time.Time
Status string // "ok", "warn", "fail"
Issues []string // critical problems
Warnings []string // non-critical warnings
// WarningKinds is parallel to Warnings: one entry per warning, "" when the warning has no kind.
// R-553 — it exists so the dashboard can decide WHERE a warning is shown without reading the
// warning's Hungarian words (internal/web/alerts.go used to match „meghajtó"/„adattároló", which
// localisation slice 2 translates). It is INTERNAL: internal/report/builder.go copies Status,
// Issues and Warnings only, so the hub report is unchanged — pinned by
// TestR553_HubReportWarningsAreUnchangedOnTheWire.
WarningKinds []string
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.
const (
WarnKindStorageNotSeparate = "storage-not-separate" // app data sits on the system drive
WarnKindStorageDisconnected = "storage-disconnected" // a registered drive is unplugged
WarnKindStorageUnavailable = "storage-unavailable" // the path cannot be read
WarnKindStorageUsageHigh = "storage-usage-high" // a data drive is filling up
)
// 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.Warnings = append(r.Warnings, text)
r.WarningKinds = append(r.WarningKinds, kind)
}
// WarningKindAt returns the kind of Warnings[i], or "" when there is none (older callers, a report
// built by hand in a test, or a warning that simply has no kind).
func (r *HealthReport) WarningKindAt(i int) string {
if r == nil || i < 0 || i >= len(r.WarningKinds) {
return ""
}
return r.WarningKinds[i]
}
// RunHealthCheck runs system checks and returns a diagnostic report.
@@ -61,7 +92,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
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.Warnings = append(report.Warnings, fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent))
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)
}
@@ -84,7 +115,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
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.Warnings = append(report.Warnings, fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent))
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)
}
@@ -94,7 +125,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
// 2. Memory usage
if sysInfo.MemPercent > 0 {
if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) {
report.Warnings = append(report.Warnings, fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent))
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)
}
@@ -112,7 +143,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
// 3. CPU usage
if sysInfo.CPUPercent > 0 {
if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) {
report.Warnings = append(report.Warnings, fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent))
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)
}
@@ -130,7 +161,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
// 4. Temperature
if sysInfo.TemperatureCelsius > 0 {
if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) {
report.Warnings = append(report.Warnings, fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource))
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)
}
@@ -177,9 +208,11 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
}
// 7. Storage paths
storageIssues, storageWarnings := checkStoragePaths(storagePaths)
storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths)
report.Issues = append(report.Issues, storageIssues...)
report.Warnings = append(report.Warnings, storageWarnings...)
for i, w := range storageWarnings {
report.addWarning(w, storageKinds[i])
}
// Determine status
if len(report.Issues) > 0 {
@@ -309,7 +342,10 @@ func checkProtectedContainers(protected []string) []string {
return missing
}
func checkStoragePaths(paths []settings.StoragePath) (issues, warnings []string) {
// checkStoragePaths returns the storage issues and, beside each warning, its KIND (R-553) — the
// dashboard places the "not on a separate drive" warning inline under the storage bars, and it must
// find it by kind rather than by the words the sentence happens to contain today.
func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds []string) {
for _, sp := range paths {
// Skip decommissioned paths — no longer in active use
if sp.Decommissioned {
@@ -318,13 +354,13 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings []string)
// Skip disconnected paths — handled by the storage watchdog
if sp.Disconnected {
warnings = append(warnings, fmt.Sprintf("Meghajtó leválasztva: %s (%s)", sp.Label, sp.Path))
warnings, kinds = append(warnings, fmt.Sprintf("Meghajtó leválasztva: %s (%s)", sp.Label, sp.Path)), append(kinds, WarnKindStorageDisconnected)
continue
}
// Path accessible?
if _, err := os.Stat(sp.Path); err != nil {
warnings = append(warnings, fmt.Sprintf("Adattároló nem elérhető: %s", sp.Path))
warnings, kinds = append(warnings, fmt.Sprintf("Adattároló nem elérhető: %s", sp.Path)), append(kinds, WarnKindStorageUnavailable)
continue
}
@@ -332,6 +368,7 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings []string)
if !system.IsMountPoint(sp.Path) {
warnings = append(warnings, fmt.Sprintf(
"Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", sp.Path))
kinds = append(kinds, WarnKindStorageNotSeparate)
}
// Disk usage
@@ -339,7 +376,7 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings []string)
if di.UsedPercent >= 95 {
issues = append(issues, fmt.Sprintf("Adattároló majdnem megtelt: %s (%.0f%%)", sp.Path, di.UsedPercent))
} else if di.UsedPercent >= 90 {
warnings = append(warnings, fmt.Sprintf("Adattároló használat magas: %s (%.0f%%)", sp.Path, di.UsedPercent))
warnings, kinds = append(warnings, fmt.Sprintf("Adattároló használat magas: %s (%.0f%%)", sp.Path, di.UsedPercent)), append(kinds, WarnKindStorageUsageHigh)
}
}
}
@@ -0,0 +1,74 @@
package monitor
import (
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-553 — the storage warnings carry a KIND, and their sentences are byte-for-byte unchanged.
//
// checkStoragePaths is the real producer the health check folds into the report. The kinds are what
// the dashboard reads to decide WHERE a warning is shown; the sentence is what the household reads.
//
// RED-PROOF (REPORT): drop the `kinds = append(…, WarnKindStorageNotSeparate)` line and the
// placement test in internal/web fails with the warning back in the top banner.
func TestR553_StorageWarningsCarryKindsAndKeepTheirWords(t *testing.T) {
dir := t.TempDir()
present := filepath.Join(dir, "data")
if err := os.MkdirAll(present, 0o755); err != nil {
t.Fatal(err)
}
paths := []settings.StoragePath{
{Path: filepath.Join(dir, "gone"), Label: "Leválasztott", Disconnected: true},
{Path: filepath.Join(dir, "nem-letezik")},
{Path: present},
{Path: filepath.Join(dir, "kihagyott"), Decommissioned: true},
}
_, warnings, kinds := checkStoragePaths(paths)
if len(warnings) != len(kinds) {
t.Fatalf("a warning without its kind (or the reverse): %d warnings, %d kinds", len(warnings), len(kinds))
}
want := []struct{ text, kind string }{
{"Meghajtó leválasztva: Leválasztott (" + filepath.Join(dir, "gone") + ")", WarnKindStorageDisconnected},
{"Adattároló nem elérhető: " + filepath.Join(dir, "nem-letezik"), WarnKindStorageUnavailable},
{"Az adattároló (" + present + ") nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", WarnKindStorageNotSeparate},
}
if len(warnings) != len(want) {
t.Fatalf("warnings = %q, want %d of them", warnings, len(want))
}
for i, w := range want {
if warnings[i] != w.text {
t.Errorf("warning %d CHANGED:\n got %q\nwant %q", i, warnings[i], w.text)
}
if kinds[i] != w.kind {
t.Errorf("warning %d has kind %q, want %q", i, kinds[i], w.kind)
}
}
// The decommissioned path is skipped entirely — it must not appear with an empty kind either.
for i, w := range warnings {
if kinds[i] == "" {
t.Errorf("storage warning %q carries no kind — the dashboard would fall back to placing it in the banner", w)
}
}
}
// A warning and its kind can never drift apart, because every warning goes through addWarning.
func TestR553_WarningKindsStayParallel(t *testing.T) {
r := &HealthReport{}
r.addWarning("CPU usage high: 91%", "")
r.addWarning("Az adattároló (/mnt/x) nem külön meghajtón van", WarnKindStorageNotSeparate)
if len(r.Warnings) != 2 || len(r.WarningKinds) != 2 {
t.Fatalf("lengths drifted: %d warnings, %d kinds", len(r.Warnings), len(r.WarningKinds))
}
if r.WarningKindAt(0) != "" || r.WarningKindAt(1) != WarnKindStorageNotSeparate {
t.Errorf("kinds read back wrong: %q, %q", r.WarningKindAt(0), r.WarningKindAt(1))
}
// A report built by hand (an older caller, a test fixture) answers "" instead of panicking.
old := &HealthReport{Warnings: []string{"x"}}
if old.WarningKindAt(0) != "" || old.WarningKindAt(9) != "" {
t.Error("a report with no kinds must answer \"\", never index out of range")
}
}