v0.169.0: disk-health card + degradation notification (Lemezek állapota)

Consumes the agent v0.94.0 smart payload (MinAgent floor unchanged; feature-detect
by presence). One pure verdict fn agentapi.DiskVerdictFor shared by the dashboard
card and the 6h check. Card via a 60s /disks TTL cache (anti-smartctl-storm);
unreachable agent -> Nincs adat, page never blocks. disk-health-check (6h) emits
disk_health_degraded on a degradation only vs an in-memory baseline (first run
silent, recovery/UNKNOWN never notify, multi-attr -> one event). No global banner
(deliberate). Pairs with the hub allowlist bump.

Tests: verdict table (>=90 red-proof), notifier emit, check first-run-silent
(red-proof), degradation-once, recovery-silent, UNKNOWN-excluded, FAILING-critical,
nil-smart card, TTL cache.
This commit is contained in:
2026-07-24 21:27:16 +02:00
parent e164fef70c
commit c97975c1df
16 changed files with 741 additions and 5 deletions
+26 -5
View File
@@ -322,6 +322,10 @@ type DiskInfo struct {
// BoundUnderParent reports whether the drive's felhom-data is currently bound under the shared parent
// (live + usable in the guest). The controller's drive-absent gate keys on this + State.
BoundUnderParent bool `json:"bound_under_parent"`
// Smart is the per-disk SMART health (agent v0.94.0+), nil when the device exposes no SMART or the
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
Smart *SmartSummary `json:"smart,omitempty"`
}
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
@@ -1003,14 +1007,31 @@ type ThinPoolFill struct {
MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
}
// SmartSummary mirrors the agent's per-disk SMART health (only the fields the UI renders). Pointers
// are null when the device type does not expose that attribute.
// SmartSummary mirrors the agent's per-disk SMART health. Pointers are null when the device type
// does not expose that attribute (a null is "unknown / not-applicable", distinct from a real zero).
// The SATA set (reallocated/pending/offline-uncorrectable) and the NVMe set
// (critical_warning/media_errors/percentage_used) are both carried; a device populates only its own.
type SmartSummary struct {
Health string `json:"health"` // PASSED | FAILING | UNKNOWN
TemperatureC *int `json:"temperature_c"`
PercentageUsed *int `json:"percentage_used"` // NVMe wear (%); null for SATA/USB
Health string `json:"health"` // PASSED | FAILING | UNKNOWN
TemperatureC *int `json:"temperature_c"`
PowerOnHours *int `json:"power_on_hours"`
// SATA attributes.
ReallocatedSectors *int `json:"reallocated_sectors"`
PendingSectors *int `json:"pending_sectors"`
OfflineUncorrectable *int `json:"offline_uncorrectable"`
// NVMe attributes.
CriticalWarning *int `json:"critical_warning"`
MediaErrors *int `json:"media_errors"`
PercentageUsed *int `json:"percentage_used"` // NVMe wear (%); null for SATA/USB
}
// SMART health vocabulary (mirrors the agent's).
const (
SmartPassed = "PASSED"
SmartFailing = "FAILING"
SmartUnknown = "UNKNOWN"
)
// StorageTarget mirrors the agent's GET /host/metrics storage_targets entry (the per-storage
// capacity + health the monitoring view renders). It is a SUBSET of the agent's wire shape — only
// the fields the UI reads; unknown JSON keys are ignored.
@@ -0,0 +1,87 @@
package agentapi
// DiskVerdict is the customer-facing disk-health verdict derived from a SmartSummary (v0.169.0).
// It is the SHARED source of truth for both the "Lemezek állapota" dashboard card and the 6-hourly
// degradation check — one pure function so the chip and the alert can never disagree.
type DiskVerdict int
const (
// DiskVerdictUnknown — no SMART data (nil / UNKNOWN / old agent). Renders "Nincs adat"; NEVER
// alarms and NEVER participates in degradation transitions (excluded both directions).
DiskVerdictUnknown DiskVerdict = iota
DiskVerdictOK // "Rendben" — PASSED, all counters clean
DiskVerdictWarn // "Figyelmeztetés" — PASSED but a wear/relocation counter is non-zero (or NVMe ≥90%)
DiskVerdictFail // "Hiba" — FAILING
)
// percentageUsedWarn is the NVMe wear threshold (inclusive) at which a still-PASSED disk warns.
const percentageUsedWarn = 90
// DiskVerdictFor maps a SmartSummary to a verdict per the v0.169.0 rules:
//
// FAILING → Hiba
// PASSED + any(reallocated>0, pending>0, offline_unc>0,
// critical_warning>0, media_errors>0,
// percentage_used >= 90) → Figyelmeztetés
// PASSED otherwise → Rendben
// nil / UNKNOWN / empty → Nincs adat
func DiskVerdictFor(s *SmartSummary) DiskVerdict {
if s == nil || s.Health == "" || s.Health == SmartUnknown {
return DiskVerdictUnknown
}
if s.Health == SmartFailing {
return DiskVerdictFail
}
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters.
if positive(s.ReallocatedSectors) || positive(s.PendingSectors) || positive(s.OfflineUncorrectable) ||
positive(s.CriticalWarning) || positive(s.MediaErrors) || atLeast(s.PercentageUsed, percentageUsedWarn) {
return DiskVerdictWarn
}
return DiskVerdictOK
}
// Label is the exact Hungarian customer copy for the verdict (shared by the card chip and the email).
func (v DiskVerdict) Label() string {
switch v {
case DiskVerdictOK:
return "Rendben"
case DiskVerdictWarn:
return "Figyelmeztetés"
case DiskVerdictFail:
return "Hiba"
default:
return "Nincs adat"
}
}
// DegradedAttributes returns the human-readable Hungarian names of the attribute(s) that pushed a
// PASSED disk to Figyelmeztetés (empty for OK/Fail/Unknown) — for the alert body. FAILING is a
// whole-disk verdict with no single triggering counter, so it returns nil there.
func DegradedAttributes(s *SmartSummary) []string {
if s == nil {
return nil
}
var out []string
if positive(s.ReallocatedSectors) {
out = append(out, "áthelyezett szektorok")
}
if positive(s.PendingSectors) {
out = append(out, "függőben lévő szektorok")
}
if positive(s.OfflineUncorrectable) {
out = append(out, "javíthatatlan szektorok")
}
if positive(s.CriticalWarning) {
out = append(out, "kritikus figyelmeztetés")
}
if positive(s.MediaErrors) {
out = append(out, "adathordozó-hibák")
}
if atLeast(s.PercentageUsed, percentageUsedWarn) {
out = append(out, "elhasználódás")
}
return out
}
func positive(p *int) bool { return p != nil && *p > 0 }
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
@@ -0,0 +1,63 @@
package agentapi
import "testing"
func ip(v int) *int { return &v }
// Verdict table (Part 2). Red-proof: change the PercentageUsed boundary from `>= 90` to `> 90` in
// DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case fails.
func TestDiskVerdictFor(t *testing.T) {
cases := []struct {
name string
in *SmartSummary
want DiskVerdict
}{
{"nil → unknown", nil, DiskVerdictUnknown},
{"empty health → unknown", &SmartSummary{Health: ""}, DiskVerdictUnknown},
{"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, DiskVerdictUnknown},
{"FAILING → fail", &SmartSummary{Health: SmartFailing}, DiskVerdictFail},
{"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, DiskVerdictFail},
{"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, DiskVerdictOK},
{"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, DiskVerdictOK},
{"reallocated>0 → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, DiskVerdictWarn},
{"pending>0 → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, DiskVerdictWarn},
{"offline_unc>0 → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, DiskVerdictWarn},
{"critical_warning>0 → warn", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, DiskVerdictWarn},
{"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, DiskVerdictWarn},
{"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, DiskVerdictOK},
{"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, DiskVerdictWarn},
{"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, DiskVerdictWarn},
}
for _, c := range cases {
if got := DiskVerdictFor(c.in); got != c.want {
t.Errorf("%s: DiskVerdictFor = %d, want %d", c.name, got, c.want)
}
}
}
func TestDiskVerdict_Label(t *testing.T) {
want := map[DiskVerdict]string{
DiskVerdictUnknown: "Nincs adat",
DiskVerdictOK: "Rendben",
DiskVerdictWarn: "Figyelmeztetés",
DiskVerdictFail: "Hiba",
}
for v, w := range want {
if got := v.Label(); got != w {
t.Errorf("verdict %d Label = %q, want %q", v, got, w)
}
}
}
// A warn lists every triggering attribute at once (Scenario "multiple attributes degrade" → ONE event).
func TestDegradedAttributes_ListsAll(t *testing.T) {
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(5), ReallocatedSectors: ip(2), PercentageUsed: ip(91)}
got := DegradedAttributes(s)
if len(got) != 3 {
t.Fatalf("want 3 attributes, got %d: %v", len(got), got)
}
// clean disk → none
if a := DegradedAttributes(&SmartSummary{Health: SmartPassed}); len(a) != 0 {
t.Errorf("clean disk should list no attributes, got %v", a)
}
}