v0.169.0: disk-health card + degradation notification (Lemezek állapota)

Consumes the agent v0.94.0 smart payload (MinAgent floor unchanged; feature-detect
by presence). One pure verdict fn agentapi.DiskVerdictFor shared by the dashboard
card and the 6h check. Card via a 60s /disks TTL cache (anti-smartctl-storm);
unreachable agent -> Nincs adat, page never blocks. disk-health-check (6h) emits
disk_health_degraded on a degradation only vs an in-memory baseline (first run
silent, recovery/UNKNOWN never notify, multi-attr -> one event). No global banner
(deliberate). Pairs with the hub allowlist bump.

Tests: verdict table (>=90 red-proof), notifier emit, check first-run-silent
(red-proof), degradation-once, recovery-silent, UNKNOWN-excluded, FAILING-critical,
nil-smart card, TTL cache.
This commit is contained in:
2026-07-24 21:27:16 +02:00
parent e164fef70c
commit c97975c1df
16 changed files with 741 additions and 5 deletions
+204
View File
@@ -0,0 +1,204 @@
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 (has a backing device
// or a serialized SMART summary). pbs/lvm/nfs targets are excluded from the disk-health view.
func isPhysicalDisk(d agentapi.DiskInfo) bool {
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)
}
}
+167
View File
@@ -0,0 +1,167 @@
package web
import (
"context"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
func smartPtr(v int) *int { return &v }
func physDisk(name string, sm *agentapi.SmartSummary) agentapi.DiskInfo {
return agentapi.DiskInfo{Name: name, BackingDevice: "/dev/" + name, DurableID: "uuid:" + name, Smart: sm}
}
// diskCheckHarness wires a Server with the disks source + notify sink seams and returns a captured
// list of emitted disk labels.
func diskCheckHarness(t *testing.T) (*Server, *[]string, *[]agentapi.DiskInfo) {
t.Helper()
s := testServer(t)
var fired []string
payload := &[]agentapi.DiskInfo{}
s.diskNotifyFn = func(label string, attrs []string, critical bool) { fired = append(fired, label) }
s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{Disks: *payload}, nil
}
return s, &fired, payload
}
// Scenario B — first run baselines silently; a real degradation (OK→Warn) emits exactly once; a
// steady-state re-check does not re-emit. Red-proof: remove the `firstRun || !had` guard → the first
// run emits and the "no notify on first run" assertion fails.
func TestDiskHealthCheck_DegradationOnce(t *testing.T) {
s, fired, payload := diskCheckHarness(t)
ctx := context.Background()
// First check: disk PASSED clean (verdict OK). Baseline only.
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed})}
_ = s.RunDiskHealthCheck(ctx)
if len(*fired) != 0 {
t.Fatalf("first run must not notify, got %v", *fired)
}
// Degrade: pending sectors 0→5 (OK→Figyelmeztetés).
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(5)})}
_ = s.RunDiskHealthCheck(ctx)
if len(*fired) != 1 {
t.Fatalf("degradation must emit exactly once, got %v", *fired)
}
// Steady state: still Figyelmeztetés — no repeat.
_ = s.RunDiskHealthCheck(ctx)
if len(*fired) != 1 {
t.Fatalf("steady-state degraded must not re-emit, got %v", *fired)
}
}
// Scenario B (cont.) — recovery (Figyelmeztetés→Rendben) notifies nothing.
func TestDiskHealthCheck_RecoverySilent(t *testing.T) {
s, fired, payload := diskCheckHarness(t)
ctx := context.Background()
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed})}
_ = s.RunDiskHealthCheck(ctx) // baseline OK
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(5)})}
_ = s.RunDiskHealthCheck(ctx) // OK→Warn: emits
if len(*fired) != 1 {
t.Fatalf("expected 1 emit on degradation, got %v", *fired)
}
// Recover back to clean.
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed})}
_ = s.RunDiskHealthCheck(ctx)
if len(*fired) != 1 {
t.Errorf("recovery must not notify, got %v", *fired)
}
}
// Scenario C — UNKNOWN is excluded both directions: a UNKNOWN disk never baselines/emits, and an
// OK→UNKNOWN→Warn sequence fires on the real OK→Warn (the UNKNOWN blip is ignored, not treated as a
// transition). Red-proof: the truth of "excluded both directions" — if UNKNOWN were recorded as a
// verdict, UNKNOWN→Warn would look like a degradation from a low baseline.
func TestDiskHealthCheck_UnknownExcluded(t *testing.T) {
s, fired, payload := diskCheckHarness(t)
ctx := context.Background()
// A purely-UNKNOWN disk: first run + repeat, never notifies, never records.
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartUnknown})}
_ = s.RunDiskHealthCheck(ctx)
_ = s.RunDiskHealthCheck(ctx)
if len(*fired) != 0 {
t.Fatalf("UNKNOWN disk must never notify, got %v", *fired)
}
// OK baseline, then a UNKNOWN blip, then Warn — must fire once (OK→Warn), the blip ignored.
*payload = []agentapi.DiskInfo{physDisk("sdc", &agentapi.SmartSummary{Health: agentapi.SmartPassed})}
_ = s.RunDiskHealthCheck(ctx) // baseline OK
*payload = []agentapi.DiskInfo{physDisk("sdc", &agentapi.SmartSummary{Health: agentapi.SmartUnknown})}
_ = s.RunDiskHealthCheck(ctx) // UNKNOWN blip: no change, no emit
if len(*fired) != 0 {
t.Fatalf("UNKNOWN blip must not emit, got %v", *fired)
}
*payload = []agentapi.DiskInfo{physDisk("sdc", &agentapi.SmartSummary{Health: agentapi.SmartPassed, PendingSectors: smartPtr(3)})}
_ = s.RunDiskHealthCheck(ctx) // OK→Warn (the blip was ignored): fire once
if len(*fired) != 1 {
t.Fatalf("real OK→Warn after a UNKNOWN blip must fire once, got %v", *fired)
}
}
// Scenario C — the card renders gracefully with nil SMART (old agent / no data): the row shows
// "Nincs adat" and never errors; a physical disk with no smart field is still listed.
func TestDiskHealthRows_NilSmart(t *testing.T) {
s, _, payload := diskCheckHarness(t)
*payload = []agentapi.DiskInfo{
{Name: "sdb", BackingDevice: "/dev/sdb", Smart: nil}, // physical, no smart → Nincs adat
{Name: "felhom-pbs", Type: "pbs"}, // non-physical → excluded
{Name: "sdc", BackingDevice: "/dev/sdc", Smart: &agentapi.SmartSummary{Health: agentapi.SmartPassed, TemperatureC: smartPtr(31)}}, // Rendben, 31°C
}
rows := s.diskHealthRows(context.Background())
if len(rows) != 2 {
t.Fatalf("want 2 physical-disk rows (pbs excluded), got %d: %+v", len(rows), rows)
}
byLabel := map[string]DiskHealthRow{}
for _, r := range rows {
byLabel[r.Label] = r
}
if byLabel["sdb"].ChipLabel != "Nincs adat" {
t.Errorf("nil-smart disk chip = %q, want Nincs adat", byLabel["sdb"].ChipLabel)
}
if byLabel["sdc"].ChipLabel != "Rendben" || byLabel["sdc"].Temp != "31" {
t.Errorf("sdc row = %+v, want Rendben / 31", byLabel["sdc"])
}
}
// A degraded verdict is FAILING → critical (Scenario B, Hiba→critical path).
func TestDiskHealthCheck_FailingCritical(t *testing.T) {
s := testServer(t)
var crit []bool
s.diskNotifyFn = func(label string, attrs []string, critical bool) { crit = append(crit, critical) }
payload := &[]agentapi.DiskInfo{}
s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
return agentapi.DisksResponse{Disks: *payload}, nil
}
ctx := context.Background()
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartPassed})}
_ = s.RunDiskHealthCheck(ctx) // baseline OK
*payload = []agentapi.DiskInfo{physDisk("sdb", &agentapi.SmartSummary{Health: agentapi.SmartFailing})}
_ = s.RunDiskHealthCheck(ctx) // OK→Fail
if len(crit) != 1 || !crit[0] {
t.Fatalf("OK→FAILING must emit one critical event, got %v", crit)
}
}
// TTL cache (Scenario A): two card fetches inside 60s hit the agent once.
func TestCachedDisks_TTL(t *testing.T) {
s := testServer(t)
calls := 0
s.disksFn = func(ctx context.Context) (agentapi.DisksResponse, error) {
calls++
return agentapi.DisksResponse{}, nil
}
ctx := context.Background()
_, _ = s.cachedDisks(ctx)
_, _ = s.cachedDisks(ctx)
if calls != 1 {
t.Errorf("two fetches within the TTL should call the agent once, got %d", calls)
}
}
+4
View File
@@ -171,6 +171,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["SystemInfo"] = sysInfo
data["StorageBars"] = s.buildStorageBars()
// Disk-health card (v0.169.0) — physical-disk SMART verdicts via the 60s-TTL-cached /disks call.
// Never blocks the render: an unreachable agent yields nil rows and the card shows its empty state.
data["DiskHealthRows"] = s.diskHealthRows(r.Context())
// Backup status
data["BackupEnabled"] = s.cfg.Backup.Enabled
if s.backupMgr != nil {
+7
View File
@@ -149,6 +149,13 @@ type Server struct {
// manual trigger goes through the loop (stop stacks → backup → resume), never a bare agent call.
backupTrigger BackupTrigger
// Disk-health card + 6h degradation check (v0.169.0). diskHealth holds the 60s /disks TTL cache +
// the in-memory verdict baseline. disksFn / diskNotifyFn are test seams (nil → the real agent
// client Disks() / the real notifier).
diskHealth diskHealthState
disksFn func(context.Context) (agentapi.DisksResponse, error)
diskNotifyFn func(label string, attrs []string, critical bool)
// App-email SMTP shim lifecycle (optional — nil when no hub is configured or the kill-switch is
// off). The global app-email settings toggle calls Apply() so the shim starts/stops at runtime.
mailShim MailShimController
@@ -110,6 +110,23 @@
</div>
{{end}}
<div class="disk-health-card">
<h3>Lemezek állapota</h3>
{{if .DiskHealthRows}}
<ul class="disk-health-list">
{{range .DiskHealthRows}}
<li class="disk-health-row">
<span class="disk-health-label">{{.Label}}</span>
<span class="disk-health-status {{.ChipClass}}">{{.ChipLabel}}</span>
{{if .Temp}}<span class="disk-health-temp">{{.Temp}} °C</span>{{end}}
</li>
{{end}}
</ul>
{{else}}
<p class="form-hint state-text-neutral">Nincs adat</p>
{{end}}
</div>
{{if .BackupEnabled}}
<div class="backup-status-card">
<h3><a href="/backups" class="backup-card-link">Biztonsági mentés</a></h3>
@@ -1552,6 +1552,28 @@ a.stat-card:hover {
border: 1px solid var(--line);
margin-bottom: 2rem;
}
/* Disk-health card (v0.169.0) — same card grammar as the backup card. */
.disk-health-card {
background: var(--bg-1);
border-radius: var(--radius);
padding: 1rem 1.25rem;
border: 1px solid var(--line);
margin-bottom: 2rem;
}
.disk-health-card h3 { margin-bottom: .75rem; }
.disk-health-list { list-style: none; padding: 0; margin: 0; }
.disk-health-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .35rem 0;
font-size: .85rem;
border-top: 1px solid var(--line);
}
.disk-health-row:first-child { border-top: none; }
.disk-health-label { flex: 1; color: var(--text-1); }
.disk-health-status { font-weight: 600; }
.disk-health-temp { color: var(--text-3); font-variant-numeric: tabular-nums; }
.backup-status-card h3 {
margin-bottom: .75rem;
}