Files
felhom-controller/controller/internal/web/disk_health.go
T
admin f6a8249593 v0.171.0: disk-health card device-model label (pairs with agent v0.95.0)
agentapi.SmartSummary.ModelName mirrors the agent's model_name; the card row label
prefers the device model over the raw name/UUID, falling back to Name(+hint) on an
old agent. Additive. Test + red-proof (drop fallback -> A4 fails).
2026-07-25 08:23:30 +02:00

215 lines
7.1 KiB
Go

package web
import (
"context"
"strconv"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// Disk-health card + 6-hourly degradation check (v0.169.0). The card and the check share ONE data
// path (the 60s-TTL-cached /disks call) and ONE verdict function (agentapi.DiskVerdictFor), so the
// chip a customer sees and the alert they receive can never disagree. No new smartctl load — the
// agent (v0.94.0) serializes its already-computed SMART; this consumes it and feature-detects
// (nil Smart → "Nincs adat", never alarms). No global banner (CONTEXT ruling) — card + email only.
const diskCacheTTL = 60 * time.Second
type diskHealthState struct {
mu sync.Mutex
cacheAt time.Time
cacheResp agentapi.DisksResponse
cacheErr error
cacheSet bool
// baseline is the last-seen verdict per disk (in-memory only). UNKNOWN is never recorded. Lost on
// restart → the next check re-baselines silently (accepted; see CONTEXT).
baseline map[string]agentapi.DiskVerdict
baselined bool
}
// DiskHealthRow is one rendered card row.
type DiskHealthRow struct {
Label string
ChipLabel string // "Rendben" | "Figyelmeztetés" | "Hiba" | "Nincs adat"
ChipClass string // design-system state-text-* color
Temp string // e.g. "34" — empty when the device reports no temperature
}
// cachedDisks fetches /disks through a 60s in-process TTL cache so dashboard refresh-spam cannot
// smartctl-storm the host (the agent recomputes SMART per /disks call). disksFn is the test seam
// (nil → the real agent client). Never blocks the page: the caller treats an error as "Nincs adat".
func (s *Server) cachedDisks(ctx context.Context) (agentapi.DisksResponse, error) {
s.diskHealth.mu.Lock()
defer s.diskHealth.mu.Unlock()
if s.diskHealth.cacheSet && time.Since(s.diskHealth.cacheAt) < diskCacheTTL {
return s.diskHealth.cacheResp, s.diskHealth.cacheErr
}
resp, err := s.fetchDisks(ctx)
s.diskHealth.cacheResp, s.diskHealth.cacheErr, s.diskHealth.cacheAt, s.diskHealth.cacheSet = resp, err, time.Now(), true
return resp, err
}
func (s *Server) fetchDisks(ctx context.Context) (agentapi.DisksResponse, error) {
if s.disksFn != nil {
return s.disksFn(ctx)
}
client, err := s.agentClient()
if err != nil {
return agentapi.DisksResponse{}, err
}
return client.Disks(ctx)
}
// isPhysicalDisk reports whether a target is a physical disk SMART applies to. Logical/network
// storage (PBS, LVM-thin, NFS, CIFS) is excluded even though the agent may default its SMART to
// UNKNOWN — those are storage abstractions, not disks, and must not clutter the disk-health card.
func isPhysicalDisk(d agentapi.DiskInfo) bool {
switch d.Type {
case "pbs", "lvmthin", "nfs", "cifs":
return false
}
return d.BackingDevice != "" || d.Smart != nil
}
// diskDisplayLabel is the customer-facing disk label (PVE name + a Hungarian speed hint when known).
func diskDisplayLabel(d agentapi.DiskInfo) string {
// Prefer the device model (agent v0.95.0) over the raw storage name/UUID — a customer reads
// "TOSHIBA MQ04ABF100", not "47a3361a-…". Falls back to Name (+ speed hint) on an older agent.
if d.Smart != nil && d.Smart.ModelName != nil && *d.Smart.ModelName != "" {
return *d.Smart.ModelName
}
switch d.Class {
case "fast":
return d.Name + " (gyors)"
case "slow":
return d.Name + " (lassú)"
default:
return d.Name
}
}
func diskChipClass(v agentapi.DiskVerdict) string {
switch v {
case agentapi.DiskVerdictOK:
return "state-text-run"
case agentapi.DiskVerdictWarn:
return "state-text-warn"
case agentapi.DiskVerdictFail:
return "state-text-crit"
default:
return "state-text-neutral"
}
}
// diskHealthRows builds the "Lemezek állapota" card rows for the physical disks. Never errors: an
// unreachable agent yields nil and the card renders its empty state.
func (s *Server) diskHealthRows(ctx context.Context) []DiskHealthRow {
resp, err := s.cachedDisks(ctx)
if err != nil {
return nil
}
var rows []DiskHealthRow
for _, d := range resp.Disks {
if !isPhysicalDisk(d) {
continue
}
v := agentapi.DiskVerdictFor(d.Smart)
row := DiskHealthRow{Label: diskDisplayLabel(d), ChipLabel: v.Label(), ChipClass: diskChipClass(v)}
if d.Smart != nil && d.Smart.TemperatureC != nil {
row.Temp = strconv.Itoa(*d.Smart.TemperatureC)
}
rows = append(rows, row)
}
return rows
}
// diskKey is a disk's stable identity across checks (durable id preferred; falls back to name).
func diskKey(d agentapi.DiskInfo) string {
if d.DurableID != "" {
return d.DurableID
}
if d.WipeDurableID != "" {
return d.WipeDurableID
}
return "name:" + d.Name
}
// RunDiskHealthCheck is the 6-hourly job. It emits disk_health_degraded ONLY on a degradation
// transition (a disk's verdict WORSENED) against the in-memory baseline. UNKNOWN is excluded both
// directions (never recorded, never a transition endpoint). The FIRST run baselines silently; a
// newly-appeared disk baselines silently; recovery (improvement) notifies nothing. Returns nil even
// when the agent is unreachable (skip quietly — no baseline churn, no alarm).
func (s *Server) RunDiskHealthCheck(ctx context.Context) error {
// Fetch FRESH (not the 60s card cache): the check runs every 6h, so it must see current SMART, and
// this keeps its transition logic independent of dashboard render timing.
resp, err := s.fetchDisks(ctx)
if err != nil {
return nil
}
type degradation struct {
label string
attrs []string
critical bool
}
var fired []degradation
s.diskHealth.mu.Lock()
if s.diskHealth.baseline == nil {
s.diskHealth.baseline = map[string]agentapi.DiskVerdict{}
}
firstRun := !s.diskHealth.baselined
seen := map[string]bool{}
for _, d := range resp.Disks {
if !isPhysicalDisk(d) {
continue
}
key := diskKey(d)
seen[key] = true
v := agentapi.DiskVerdictFor(d.Smart)
if v == agentapi.DiskVerdictUnknown {
// Excluded both directions: don't record, don't transition, don't drop an existing baseline
// (a transient UNKNOWN blip must not erase history or fire).
continue
}
prev, had := s.diskHealth.baseline[key]
s.diskHealth.baseline[key] = v
if firstRun || !had {
continue // first verdict ever for this disk → baseline silently
}
if v > prev { // verdict worsened (Unknown=0 < OK=1 < Warn=2 < Fail=3; Unknown excluded above)
fired = append(fired, degradation{
label: diskDisplayLabel(d),
attrs: agentapi.DegradedAttributes(d.Smart),
critical: v == agentapi.DiskVerdictFail,
})
}
}
// Forget disks no longer reported so a reappearance re-baselines silently.
for k := range s.diskHealth.baseline {
if !seen[k] {
delete(s.diskHealth.baseline, k)
}
}
s.diskHealth.baselined = true
s.diskHealth.mu.Unlock()
for _, f := range fired {
s.emitDiskDegraded(f.label, f.attrs, f.critical)
}
return nil
}
// emitDiskDegraded routes a degradation to the notifier (or the test seam). nil notifier → no-op.
func (s *Server) emitDiskDegraded(label string, attrs []string, critical bool) {
if s.diskNotifyFn != nil {
s.diskNotifyFn(label, attrs, critical)
return
}
if s.notifier != nil {
s.notifier.NotifyDiskHealthDegraded(label, attrs, critical)
}
}