health check: the last six producers carry their dashboard sentence (R-79 option A, D3)
Docker unreachable, protected container down, and the four storage-path entries now carry a bundle key beside their wire text; the wire text is unchanged byte for byte. Hungarian values of the four storage keys equal the frozen formats (i18n_go_parity). Red-proven: a zero MsgRef on the unavailable-path warning fails TestR79_RemainingProducersCarryTheirDashboardSentence and TestR79_HealthBannersFollowTheHousehold (producer-driven, English household sees Hungarian). 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:
@@ -1571,6 +1571,12 @@
|
||||
"health.memory_high": "Memory usage high: %.0f%%",
|
||||
"health.cpu_high": "CPU usage high: %.0f%%",
|
||||
"health.temperature_high": "Temperature high: %.0f°C (%s)",
|
||||
"health.docker_unreachable": "Docker is not reachable: %s",
|
||||
"health.protected_container_down": "A protected system service is not running: %s",
|
||||
"health.storage_unavailable": "Storage not reachable: %s",
|
||||
"health.storage_not_separate": "The storage (%s) is not on a separate drive — data is written to the system drive",
|
||||
"health.storage_usage_high": "Storage usage high: %s (%.0f%%)",
|
||||
"health.storage_almost_full": "Storage almost full: %s (%.0f%%)",
|
||||
"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",
|
||||
|
||||
@@ -1559,6 +1559,12 @@
|
||||
"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)",
|
||||
"health.docker_unreachable": "A Docker nem érhető el: %s",
|
||||
"health.protected_container_down": "Egy védett rendszerszolgáltatás nem fut: %s",
|
||||
"health.storage_unavailable": "Adattároló nem elérhető: %s",
|
||||
"health.storage_not_separate": "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak",
|
||||
"health.storage_usage_high": "Adattároló használat magas: %s (%.0f%%)",
|
||||
"health.storage_almost_full": "Adattároló majdnem megtelt: %s (%.0f%%)",
|
||||
"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",
|
||||
|
||||
@@ -135,7 +135,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
|
||||
// 5. Docker health
|
||||
if err := checkDocker(); err != nil {
|
||||
report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{})
|
||||
addDockerIssue(report, err)
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Docker daemon: FAIL (%v)", err)
|
||||
}
|
||||
@@ -153,9 +153,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected)
|
||||
}
|
||||
missingProtected := checkProtectedContainers(protected)
|
||||
for _, name := range missingProtected {
|
||||
report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{})
|
||||
}
|
||||
addProtectedIssues(report, missingProtected)
|
||||
if debug {
|
||||
if len(missingProtected) > 0 {
|
||||
logger.Printf("[DEBUG] [monitor] Protected containers missing: %v", missingProtected)
|
||||
@@ -165,13 +163,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
}
|
||||
|
||||
// 7. Storage paths
|
||||
storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths)
|
||||
for _, is := range storageIssues {
|
||||
report.addIssue(is, MsgRef{})
|
||||
}
|
||||
for i, w := range storageWarnings {
|
||||
report.addWarning(w, storageKinds[i])
|
||||
}
|
||||
addStorage(report, storagePaths)
|
||||
|
||||
// Determine status
|
||||
if len(report.Issues) > 0 {
|
||||
@@ -225,6 +217,38 @@ func (r *HealthReport) FormatMessage() string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// addDockerIssue / addProtectedIssues / addStorage are RunHealthCheck's Docker, protected-container
|
||||
// and storage-path halves, split out so a test can drive them without a Docker daemon or a real drive
|
||||
// (R-79 option A: TestR79_RemainingProducersCarryTheirDashboardSentence). Each entry keeps its wire
|
||||
// text and carries the dashboard's own sentence beside it (R-516 item 10).
|
||||
func addDockerIssue(report *HealthReport, err error) {
|
||||
// The argument is Docker's own error text, which is English in every language (it comes from
|
||||
// the docker client); the sentence around it follows the household.
|
||||
report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{"health.docker_unreachable", []interface{}{err.Error()}})
|
||||
}
|
||||
|
||||
func addProtectedIssues(report *HealthReport, missing []string) {
|
||||
for _, name := range missing {
|
||||
report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{"health.protected_container_down", []interface{}{name}})
|
||||
}
|
||||
}
|
||||
|
||||
func addStorage(report *HealthReport, paths []settings.StoragePath) {
|
||||
issues, issueMsgs, warnings, kinds, warnMsgs := checkStoragePaths(paths)
|
||||
for i, is := range issues {
|
||||
report.addIssue(is, issueMsgs[i])
|
||||
}
|
||||
for i, w := range warnings {
|
||||
report.addWarningMsg(w, kinds[i], warnMsgs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// isMountPointFn / diskUsageFn are test seams over the two drive probes checkStoragePaths makes.
|
||||
var (
|
||||
isMountPointFn = system.IsMountPoint
|
||||
diskUsageFn = system.GetDiskUsage
|
||||
)
|
||||
|
||||
func checkDocker() error {
|
||||
cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
out, err := cmd.Output()
|
||||
@@ -303,39 +327,47 @@ func checkProtectedContainers(protected []string) []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) {
|
||||
// find it by kind rather than by the words the sentence happens to contain today. issueMsgs and
|
||||
// warnMsgs are parallel to issues and warnings: the dashboard sentence of each (R-79 option A; the
|
||||
// Hungarian bundle value of each key equals the frozen wire format above, byte for byte).
|
||||
func checkStoragePaths(paths []settings.StoragePath) (issues []string, issueMsgs []MsgRef, warnings, kinds []string, warnMsgs []MsgRef) {
|
||||
warn := func(text, kind string, msg MsgRef) {
|
||||
warnings, kinds, warnMsgs = append(warnings, text), append(kinds, kind), append(warnMsgs, msg)
|
||||
}
|
||||
for _, sp := range paths {
|
||||
// Skip decommissioned paths — no longer in active use
|
||||
if sp.Decommissioned {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip disconnected paths — handled by the storage watchdog
|
||||
// Skip disconnected paths — handled by the storage watchdog. No dashboard sentence: the
|
||||
// dashboard drops this warning for its own alert.storage.disconnected banner (R-516 item 8).
|
||||
if sp.Disconnected {
|
||||
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path)), append(kinds, WarnKindStorageDisconnected)
|
||||
warn(fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path), WarnKindStorageDisconnected, MsgRef{})
|
||||
continue
|
||||
}
|
||||
|
||||
// Path accessible?
|
||||
if _, err := os.Stat(sp.Path); err != nil {
|
||||
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUnavailable, sp.Path)), append(kinds, WarnKindStorageUnavailable)
|
||||
warn(fmt.Sprintf(warnFmtStorageUnavailable, sp.Path), WarnKindStorageUnavailable,
|
||||
MsgRef{"health.storage_unavailable", []interface{}{sp.Path}})
|
||||
continue
|
||||
}
|
||||
|
||||
// Mount point check — warning, not issue (avoids false FAIL on demo/test environments)
|
||||
if !system.IsMountPoint(sp.Path) {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
warnFmtStorageNotSeparate, sp.Path))
|
||||
kinds = append(kinds, WarnKindStorageNotSeparate)
|
||||
if !isMountPointFn(sp.Path) {
|
||||
warn(fmt.Sprintf(warnFmtStorageNotSeparate, sp.Path), WarnKindStorageNotSeparate,
|
||||
MsgRef{"health.storage_not_separate", []interface{}{sp.Path}})
|
||||
}
|
||||
|
||||
// Disk usage
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
if di := diskUsageFn(sp.Path); di != nil {
|
||||
if di.UsedPercent >= 95 {
|
||||
issues = append(issues, fmt.Sprintf(issueFmtStorageAlmostFull, sp.Path, di.UsedPercent))
|
||||
issueMsgs = append(issueMsgs, MsgRef{"health.storage_almost_full", []interface{}{sp.Path, di.UsedPercent}})
|
||||
} else if di.UsedPercent >= 90 {
|
||||
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent)), append(kinds, WarnKindStorageUsageHigh)
|
||||
warn(fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent), WarnKindStorageUsageHigh,
|
||||
MsgRef{"health.storage_usage_high", []interface{}{sp.Path, di.UsedPercent}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
@@ -62,3 +67,79 @@ func TestR516_ResourceWarningsCarryTheirDashboardSentence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-79 option A (operator ruling D3, 2026-10-08) — the six producers R-516 item 10 left verbatim:
|
||||
// Docker unreachable, a protected container down, and the four storage-path entries. Each now
|
||||
// carries its dashboard sentence; the wire text is byte for byte what it was (it is ON THE WIRE,
|
||||
// report health.*; the frozen storage formats are also pinned by TestStorageWarningFormatsAreFrozen).
|
||||
//
|
||||
// COMPANION RED-PROOF: give any one of them a zero MsgRef (e.g. the unavailable-path warning back to
|
||||
// addWarning) — its case fails here, and TestR79_StorageBannerFollowsTheHousehold in internal/web
|
||||
// shows the Hungarian wire text to an English household again.
|
||||
func TestR79_RemainingProducersCarryTheirDashboardSentence(t *testing.T) {
|
||||
type entry struct{ wire, key string }
|
||||
check := func(name string, texts []string, msgs func(int) MsgRef, want []entry) {
|
||||
t.Helper()
|
||||
if len(texts) != len(want) {
|
||||
t.Fatalf("%s: %d entries %q, want %d — the cases below would be compared against nothing", name, len(texts), texts, len(want))
|
||||
}
|
||||
for i := range texts {
|
||||
if texts[i] != want[i].wire {
|
||||
t.Errorf("%s: the wire text moved: %q, want %q", name, texts[i], want[i].wire)
|
||||
}
|
||||
if k := msgs(i).Key; k != want[i].key {
|
||||
t.Errorf("%s: %q carries dashboard key %q, want %q", name, texts[i], k, want[i].key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Docker and protected containers: driven through the same adders RunHealthCheck calls.
|
||||
r := &HealthReport{}
|
||||
addDockerIssue(r, fmt.Errorf("docker not reachable: exit status 1"))
|
||||
addProtectedIssues(r, []string{"cloudflared", "traefik"})
|
||||
check("docker+protected", r.Issues, r.IssueMsgAt, []entry{
|
||||
{"Docker: docker not reachable: exit status 1", "health.docker_unreachable"},
|
||||
{"Protected container not running: cloudflared", "health.protected_container_down"},
|
||||
{"Protected container not running: traefik", "health.protected_container_down"},
|
||||
})
|
||||
if a := r.IssueMsgAt(1).Args; len(a) != 1 || a[0] != "cloudflared" {
|
||||
t.Errorf("protected-container sentence args = %v, want [cloudflared]", a)
|
||||
}
|
||||
|
||||
// Storage paths: a missing path, a path that is not a mount point at 92 %, one at 97 %.
|
||||
oldMount, oldUsage := isMountPointFn, diskUsageFn
|
||||
defer func() { isMountPointFn, diskUsageFn = oldMount, oldUsage }()
|
||||
isMountPointFn = func(p string) bool { return !strings.HasSuffix(p, "/notsep") }
|
||||
diskUsageFn = func(p string) *system.DiskUsageInfo {
|
||||
switch {
|
||||
case strings.HasSuffix(p, "/notsep"):
|
||||
return &system.DiskUsageInfo{UsedPercent: 92}
|
||||
case strings.HasSuffix(p, "/full"):
|
||||
return &system.DiskUsageInfo{UsedPercent: 97}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dir := t.TempDir()
|
||||
for _, sub := range []string{"notsep", "full"} {
|
||||
if err := os.Mkdir(filepath.Join(dir, sub), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
missing := filepath.Join(dir, "missing")
|
||||
r = &HealthReport{}
|
||||
addStorage(r, []settings.StoragePath{{Path: missing}, {Path: dir + "/notsep"}, {Path: dir + "/full"}})
|
||||
check("storage warnings", r.Warnings, r.WarningMsgAt, []entry{
|
||||
{"Adattároló nem elérhető: " + missing, "health.storage_unavailable"},
|
||||
{"Az adattároló (" + dir + "/notsep) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", "health.storage_not_separate"},
|
||||
{"Adattároló használat magas: " + dir + "/notsep (92%)", "health.storage_usage_high"},
|
||||
})
|
||||
check("storage issues", r.Issues, r.IssueMsgAt, []entry{
|
||||
{"Adattároló majdnem megtelt: " + dir + "/full (97%)", "health.storage_almost_full"},
|
||||
})
|
||||
if len(r.Warnings) != len(r.WarningKinds) || len(r.Warnings) != len(r.WarningMsgs) || len(r.Issues) != len(r.IssueMsgs) {
|
||||
t.Errorf("storage: the parallel slices drifted apart")
|
||||
}
|
||||
if got := r.WarningKindAt(1); got != WarnKindStorageNotSeparate {
|
||||
t.Errorf("not-separate warning lost its kind: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestR553_StorageWarningsCarryKindsAndKeepTheirWords(t *testing.T) {
|
||||
{Path: present},
|
||||
{Path: filepath.Join(dir, "kihagyott"), Decommissioned: true},
|
||||
}
|
||||
_, warnings, kinds := checkStoragePaths(paths)
|
||||
_, _, 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))
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestStorageWarningWireTextIsFrozen(t *testing.T) {
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, warnings, kinds := checkStoragePaths([]settings.StoragePath{tc.in})
|
||||
_, _, warnings, kinds, _ := checkStoragePaths([]settings.StoragePath{tc.in})
|
||||
if len(warnings) != len(tc.want) {
|
||||
t.Fatalf("got %d warnings %q, want %d %q", len(warnings), warnings, len(tc.want), tc.want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"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-79 option A (operator ruling D3, 2026-10-08) — the storage and protected-container entries of the
|
||||
// health check reached the banner verbatim: Hungarian for an English household (the frozen storage
|
||||
// formats), English for a Hungarian one (the container issue). Driven from the PRODUCER
|
||||
// (monitor.RunHealthCheck), not a hand-built report, so a dropped MsgRef in healthcheck.go is seen here.
|
||||
// Docker is refused under go test (R-650), so the Docker and protected-container issues appear on
|
||||
// every run of this test — that is the input, not a side effect on this host.
|
||||
//
|
||||
// COMPANION RED-PROOF: give the unavailable-path warning in checkStoragePaths a zero MsgRef — the "en"
|
||||
// case fails with „Adattároló nem elérhető: …".
|
||||
func TestR79_HealthBannersFollowTheHousehold(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "gone")
|
||||
cfg := config.Default()
|
||||
cfg.Hub.Enabled = false
|
||||
cfg.Stacks.Protected = []string{"traefik"}
|
||||
hr := monitor.RunHealthCheck(cfg, nil, []settings.StoragePath{{Path: missing}}, settings.SMBSettings{}, nil)
|
||||
am := NewAlertManager(log.New(io.Discard, "", 0))
|
||||
am.Refresh(hr, cfg, nil, false, "")
|
||||
|
||||
want := map[string][]string{
|
||||
"hu": {"Adattároló nem elérhető: " + missing, "Egy védett rendszerszolgáltatás nem fut: traefik", "A Docker nem érhető el: "},
|
||||
"en": {"Storage not reachable: " + missing, "A protected system service is not running: traefik", "Docker is not reachable: "},
|
||||
}
|
||||
for lang, ws := range want {
|
||||
var got []string
|
||||
for _, a := range am.GetAlerts(lang) {
|
||||
if strings.HasPrefix(a.ID, "health-") {
|
||||
got = append(got, a.Message)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(got, "\n")
|
||||
for _, w := range ws {
|
||||
if !strings.Contains(joined, w) {
|
||||
t.Errorf("%s: no banner contains %q; banners:\n%s", lang, w, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,12 @@
|
||||
"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.",
|
||||
"health.docker_unreachable": "BORN AS A KEY (R-79 option A, operator ruling D3 2026-10-08) -- the dashboard banner's own sentence for a health-check issue whose wire text (report health.issues) is English and stays so; a NEW sentence, never a Go literal. Pinned by TestR79_RemainingProducersCarryTheirDashboardSentence and TestR79_HealthBannersFollowTheHousehold.",
|
||||
"health.protected_container_down": "BORN AS A KEY (R-79 option A, operator ruling D3 2026-10-08) -- the dashboard banner's own sentence for a health-check issue whose wire text (report health.issues) is English and stays so; a NEW sentence, never a Go literal. Pinned by TestR79_RemainingProducersCarryTheirDashboardSentence and TestR79_HealthBannersFollowTheHousehold.",
|
||||
"health.storage_unavailable": "Adattároló nem elérhető: %s",
|
||||
"health.storage_not_separate": "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak",
|
||||
"health.storage_usage_high": "Adattároló használat magas: %s (%.0f%%)",
|
||||
"health.storage_almost_full": "Adattároló majdnem megtelt: %s (%.0f%%)",
|
||||
"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.",
|
||||
|
||||
Reference in New Issue
Block a user