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) } }