1453cfc69b
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
202 lines
9.5 KiB
Go
202 lines
9.5 KiB
Go
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 }
|