dbf631312e
The agent defaults SMART to UNKNOWN on non-physical targets, so they showed as spurious 'Nincs adat' rows. isPhysicalDisk now excludes those types (card + check). Test strengthened: a PBS/LVM fixture with UNKNOWN SMART must still be excluded.
210 lines
6.8 KiB
Go
210 lines
6.8 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 {
|
|
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)
|
|
}
|
|
}
|