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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user