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 periodic // 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" — clean DiskVerdictWarn // "Figyelmeztetés" — a wear/relocation counter is non-zero, below the Hiba bar DiskVerdictFail // "Hiba" — FAILING, or failing-but-not-self-reported (v0.215.0) ) // Thresholds. A number without a reason becomes permanent by default, so each carries its provenance. // The evidence is committed at felhom.eu/documentation/audits/DIAG-smart-passed-trap-2026-08-14.md // and its two fixtures (ST3000VX010 S/N Z6A07P2G, /dev/sdg on DooPlex, 11-13 Aug 2026). const ( // percentageUsedWarn / percentageUsedFail — NVMe wear (%). 100 means the vendor's rated endurance // is spent; that is a declaration, not a trend, so it is Hiba. percentageUsedWarn = 90 percentageUsedFail = 100 // uncorrectableFailCount — unreadable sectors too numerous to be a blip. // // PROVENANCE: on the one real failing drive observed, the benign excursion peaked at 16 and // cleared COMPLETELY within an hour (11 Aug 12:28 -> 13:28); the terminal run passed 64 at // 13 Aug 11:28 and never came back below it. 64 sits above the one observed transient and below // the observed terminal run. This is a judgement from ONE drive: it is a static BACKSTOP behind // the sustain rule, not the primary signal, and Phase 3 is expected to replace it with // growth-rate detection once the box keeps history. uncorrectableFailCount = 64 // temperatureWarnC / TemperatureFailC — adopted UNCHANGED from the operator's existing Prometheus // bands on DooPlex, so the two systems cannot disagree about the same drive. temperatureWarnC = 55 // TemperatureFailC is exported because the alert-copy layer must pick the "overheated" message // shape from the SAME number the verdict fired on. A second literal elsewhere would be free to // drift, and the drift would show up as a customer told the wrong reason. TemperatureFailC = 60 ) // DiskPrior is what the previous check observed for THIS SAME disk. It is the only history the // verdict consults, and it is passed in rather than read so the function stays pure — the caller // (internal/web) owns loading it from the persisted per-disk state. // // Plain value type: no methods, no I/O. A zero DiskPrior means "nothing known", which is the correct // fail-safe — a first-ever observation can only reach Figyelmeztetés from counters, never Hiba. type DiskPrior struct { // SawUncorrectable reports whether unreadable sectors (pending OR offline-uncorrectable) were // present at the previous check. It is what turns a one-off excursion into a sustained fault. SawUncorrectable bool } // DiskVerdictFor maps a SmartSummary plus the previous observation to a verdict. Rules are evaluated // TOP-DOWN and the FIRST match wins (v0.215.0): // // 1. nil / "" / UNKNOWN -> Nincs adat // 2. Health == FAILING -> Hiba (drive self-reports) // 3. temperature_c >= 60 -> Hiba // 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba // 5. percentage_used >= 100 -> Hiba // 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED) // 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping) // 8. unreadable >= 64 -> Hiba (too large to be a blip) // 9. unreadable > 0 -> Figyelmeztetés (first sighting) // 10. reallocated > 0 -> Figyelmeztetés // 11. media_errors > 0 -> Figyelmeztetés // 12. percentage_used >= 90 -> Figyelmeztetés // 13. temperature_c >= 55 -> Figyelmeztetés // 14. otherwise -> Rendben // // WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187, // 197 and 198 all carry thresh 0, and a normalized SMART value floors at 1, so it can never drop to // or below the threshold. The real drive stayed PASSED at 352 pending sectors with 1001 reported // uncorrectable reads. A verdict built on the drive's own self-assessment is blind to this whole // class of failure, which is why rows 3-8 read the raw counters instead. // // WHY row 6 sits ABOVE row 8: sustain is the PRIMARY rule and the count is the backstop. On the real // drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for // a box that was powered off or restarted across the sustain window and so has no prior. // // Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument. func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict { // 1 — no data. Never alarms. if s == nil || s.Health == "" || s.Health == SmartUnknown { return DiskVerdictUnknown } // 2 — the drive admits failure. if s.Health == SmartFailing { return DiskVerdictFail } // Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters, // because the overall verdict is structurally unable to report this class of fault. switch { case atLeast(s.TemperatureC, TemperatureFailC): // 3 return DiskVerdictFail case positive(s.CriticalWarning): // 4 return DiskVerdictFail case atLeast(s.PercentageUsed, percentageUsedFail): // 5 return DiskVerdictFail } unreadable := UncorrectableSectors(s) switch { case unreadable > 0 && prior.SawUncorrectable: // 6 — sustained across two consecutive checks return DiskVerdictFail case unreadable > 0 && positive(s.ReallocatedSectors): // 7 — accumulating and remapping together return DiskVerdictFail case unreadable >= uncorrectableFailCount: // 8 — too large to be a blip return DiskVerdictFail case unreadable > 0: // 9 — first sighting, below the bar return DiskVerdictWarn case positive(s.ReallocatedSectors): // 10 return DiskVerdictWarn case positive(s.MediaErrors): // 11 return DiskVerdictWarn case atLeast(s.PercentageUsed, percentageUsedWarn): // 12 return DiskVerdictWarn case atLeast(s.TemperatureC, temperatureWarnC): // 13 return DiskVerdictWarn } return DiskVerdictOK // 14 } // UncorrectableSectors is the disk's unreadable-sector count: max(pending, offline_uncorrectable). // The two attributes track the same physical defect and on the real drive moved in lockstep, so the // larger is the honest figure. 0 when neither is reported (an old agent or a device without them). // Exported because the alert copy quotes this number and the persisted state remembers it. func UncorrectableSectors(s *SmartSummary) int { if s == nil { return 0 } n := 0 if s.PendingSectors != nil && *s.PendingSectors > n { n = *s.PendingSectors } if s.OfflineUncorrectable != nil && *s.OfflineUncorrectable > n { n = *s.OfflineUncorrectable } return n } // Label is the exact Hungarian customer copy for the verdict (shared by the card chip and the email). // // There are FOUR labels and there will not be a fifth: a predicted failure is "Hiba", the same word a // self-reported failure gets. A fourth word sharing a root with "Figyelmeztetés" would make the MORE // severe state read as the milder one (settled operator decision, v0.215.0). 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) behind a // degraded verdict, for the alert body. // // v0.215.0: this now also names the attributes behind a Hiba REACHED FROM COUNTERS (truth-table rows // 3 and 6-8), not only a Figyelmeztetés — the alert message needs to say what is wrong, and those // rows do have a triggering counter. It returns nil ONLY for row 2 (the drive self-reports FAILING, // a whole-disk verdict with no single triggering counter) and, naturally, for Nincs adat / Rendben. func DegradedAttributes(s *SmartSummary) []string { if s == nil || s.Health == "" || s.Health == SmartUnknown || s.Health == SmartFailing { 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") } // Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. if atLeast(s.TemperatureC, temperatureWarnC) { out = append(out, "hőmérséklet") } return out } func positive(p *int) bool { return p != nil && *p > 0 } func atLeast(p *int, n int) bool { return p != nil && *p >= n }