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:
@@ -1,5 +1,35 @@
|
||||
## Changelog
|
||||
|
||||
### v0.169.0 — Disk-health card + degradation notification ("Lemezek állapota") (2026-07-24)
|
||||
|
||||
Consumes the agent's new `smart` payload field (agent **v0.94.0**); **MinAgent floor unchanged** — the
|
||||
feature detects by payload presence (nil → "Nincs adat", never alarms). Pairs with the hub allowlist
|
||||
bump (adds `disk_health_degraded`). No new smartctl load anywhere — the agent serializes
|
||||
already-computed SMART; the controller only reads it.
|
||||
|
||||
- **`agentapi`:** `SmartSummary` extended to the full counter set (SATA reallocated/pending/offline +
|
||||
NVMe critical/media/percentage_used + power-on-hours); `DiskInfo` gains `Smart *SmartSummary`; new
|
||||
pure `DiskVerdictFor(*SmartSummary) DiskVerdict` (the SINGLE source of truth for card + check) with
|
||||
`Label()` (Rendben / Figyelmeztetés / Hiba / Nincs adat) + `DegradedAttributes`. Mapping: FAILING →
|
||||
Hiba; PASSED with any of reallocated>0 / pending>0 / offline_uncorrectable>0 / critical_warning>0 /
|
||||
media_errors>0 / percentage_used ≥ 90 → Figyelmeztetés; PASSED clean → Rendben; nil/UNKNOWN/empty →
|
||||
Nincs adat (never alarms).
|
||||
- **Dashboard "Lemezek állapota" card:** one row per PHYSICAL disk (label + colored verdict chip +
|
||||
temperature). Fed by a **60 s in-process TTL cache** around `/disks` so dashboard refresh-spam cannot
|
||||
smartctl-storm the host. An unreachable agent renders "Nincs adat" — the page never blocks.
|
||||
- **6-hourly `disk-health-check`:** compares each physical disk's verdict against an in-memory baseline
|
||||
and emits `disk_health_degraded` **only on a degradation** (verdict worsened). First run baselines
|
||||
silently; recovery/improvement notifies nothing; **UNKNOWN is excluded both directions** (a transient
|
||||
UNKNOWN blip never fires and never erases history); multiple attributes on one disk → ONE event.
|
||||
Severity: warn (Figyelmeztetés) / critical (Hiba). The hub applies its own per-event-type cooldown.
|
||||
- **Deliberately no global alert banner** (CONTEXT ruling) — the card + email carry it; banner fatigue
|
||||
is a real cost. Not wired into the dead-app/alert-banner machinery. Controller restart re-baselines
|
||||
silently (accepted, consistent with the health-change pattern).
|
||||
|
||||
Tests: verdict table (+ ≥90 boundary red-proof); notifier emit (type/severity/subject); check
|
||||
first-run-silent (red-proof: disable the guard → first run notifies), degradation-once, recovery-silent,
|
||||
UNKNOWN-excluded, FAILING→critical, nil-smart card graceful, TTL cache.
|
||||
|
||||
### v0.168.0 — Customer-configurable backup window ("Mentési időablak") (2026-07-24)
|
||||
|
||||
No agent coupling; MinAgent unchanged (the disk-tier gate is controller-side; the agent's cadence-based
|
||||
|
||||
+17
@@ -7,6 +7,23 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-07-24 (v0.169.0 — disk-health card + degradation notification "Lemezek állapota")
|
||||
|
||||
> **2026-07-24 — v0.169.0 (disk-health card + degradation alert).** Consumes the agent's new `smart`
|
||||
> field (agent v0.94.0; MinAgent floor unchanged — feature-detect by presence). **Rulings:** (1) ONE
|
||||
> pure verdict fn `agentapi.DiskVerdictFor` is the shared truth for the card chip AND the 6h check — they
|
||||
> can never disagree. Thresholds: FAILING→Hiba; PASSED + any(reallocated>0/pending>0/offline_unc>0/
|
||||
> critical_warning>0/media_errors>0/percentage_used **≥90**)→Figyelmeztetés; PASSED clean→Rendben;
|
||||
> nil/UNKNOWN→Nincs adat (never alarms). (2) **No global alert banner** — the card + email carry disk
|
||||
> health; banner fatigue is a real cost, so this is deliberately NOT wired into the dead-app/alert-banner
|
||||
> machinery. (3) Degradation-only notification with an in-memory baseline: first run baselines silently,
|
||||
> recovery never notifies, **UNKNOWN excluded both directions** (a transient blip neither fires nor erases
|
||||
> history). (4) **Controller restart re-baselines silently** (in-memory baseline lost on restart) — an
|
||||
> accepted trade consistent with the health-change pattern (a real post-restart degradation still fires on
|
||||
> the following 6h check once a baseline exists). (5) A **60s TTL cache** wraps the card's /disks call so
|
||||
> dashboard refresh-spam can't smartctl-storm the host; the 6h check fetches FRESH (cache-independent).
|
||||
> Pairs with hub +1 (allowlist `disk_health_degraded`). No new smartctl load — serialization only.
|
||||
|
||||
Last updated: 2026-07-24 (v0.168.0 — customer-configurable backup window "Mentési időablak")
|
||||
|
||||
> **2026-07-24 — v0.168.0 (customer-configurable backup window).** ONE customer setting — the window
|
||||
|
||||
@@ -136,6 +136,8 @@
|
||||
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
|
||||
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
|
||||
| `SupportCache.Supports` / `Client.Supports` | controller/internal/agentapi/features.go | `(ctx, prober, Feature) SupportState` | Agent-capability gate for COUPLED features (route probe, TTL 5m) | 404 ⇒ No; transport/5xx ⇒ Unknown (NEVER refuse on Unknown). New coupled feature = new `featureProbes` row + gate call at the entry point + `MinAgent:` in the CHANGELOG header (publish-train-rules.md). Web layer: `Server.netFeatures` through the `netAgent` seam |
|
||||
| `agentapi.DiskVerdictFor` / `DiskVerdict.Label` / `DegradedAttributes` | controller/internal/agentapi/diskverdict.go | `(*SmartSummary) DiskVerdict` | THE shared disk-health verdict (card chip + 6h check) — v0.169.0 | Pure; nil/UNKNOWN → `DiskVerdictUnknown` (Nincs adat, NEVER alarms); percentage_used threshold is **≥90**. Feature-detects the agent's `DiskInfo.Smart` (nil = old agent). Do NOT recompute the verdict inline anywhere |
|
||||
| `Server.cachedDisks` / `RunDiskHealthCheck` | controller/internal/web/disk_health.go | `(ctx)` | Card fetch (60s TTL) / the 6h degradation check | Card uses the 60s TTL cache (anti-smartctl-storm); the CHECK fetches FRESH (`fetchDisks`). Test seams: `Server.disksFn` (source) + `Server.diskNotifyFn` (sink). Baseline is in-memory (restart re-baselines) |
|
||||
|
||||
### Notifications / hub sync
|
||||
|
||||
|
||||
@@ -201,6 +201,22 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
|
||||
|
||||
## Features
|
||||
|
||||
### Disk health — "Lemezek állapota" card + degradation alert (v0.169.0)
|
||||
|
||||
The dashboard shows a per-physical-disk health card driven by the agent's SMART summary (serialized
|
||||
into `/disks` from agent v0.94.0 — no new smartctl load; the controller only reads it). One pure
|
||||
verdict function (`agentapi.DiskVerdictFor`) is the shared truth for the card chip and the check:
|
||||
|
||||
- **Rendben** (PASSED, clean) · **Figyelmeztetés** (PASSED but reallocated/pending/offline-uncorrectable
|
||||
or NVMe critical/media/percentage-used ≥ 90) · **Hiba** (FAILING) · **Nincs adat** (nil/UNKNOWN/old
|
||||
agent — never alarms).
|
||||
- The card fetches `/disks` through a **60 s TTL cache** (dashboard refresh-spam can't smartctl-storm
|
||||
the host); an unreachable agent renders "Nincs adat" and the page still loads.
|
||||
- A **6-hourly `disk-health-check`** emits `disk_health_degraded` (warn/critical) **only on a
|
||||
degradation** vs an in-memory baseline — first run baselines silently, recovery/UNKNOWN never notify,
|
||||
and a controller restart re-baselines silently. **No global banner** (deliberate): the card + email
|
||||
carry it. The hub allowlist must include `disk_health_degraded`.
|
||||
|
||||
### 1. App Management
|
||||
|
||||
The controller manages Docker Compose stacks through a complete lifecycle: catalog sync, first-time deployment, runtime operations, and deletion.
|
||||
|
||||
@@ -959,6 +959,13 @@ func main() {
|
||||
stackMgr.RecoverMigration(ctx)
|
||||
webServer.SetEncryptionKey(encKey)
|
||||
webServer.SetAppExporter(appExporter)
|
||||
// Disk-health degradation check (v0.169.0): every 6h, compare each physical disk's SMART verdict
|
||||
// against the in-memory baseline and emit disk_health_degraded on a degradation only (first run
|
||||
// baselines silently; recovery/UNKNOWN never notify). Only on a provisioned guest (an agent to
|
||||
// read /disks from); the check no-ops gracefully if the agent is unreachable.
|
||||
if cfg.LocalAPI.Endpoint != "" {
|
||||
sched.Every("disk-health-check", 6*time.Hour, webServer.RunDiskHealthCheck)
|
||||
}
|
||||
// Browser .fab upload (v0.128.0): upload state is in-memory, so a restart strands the .part —
|
||||
// GC stray part files in every registered drive's exports dir at startup.
|
||||
webServer.CleanupStaleUploadParts()
|
||||
|
||||
@@ -322,6 +322,10 @@ type DiskInfo struct {
|
||||
// BoundUnderParent reports whether the drive's felhom-data is currently bound under the shared parent
|
||||
// (live + usable in the guest). The controller's drive-absent gate keys on this + State.
|
||||
BoundUnderParent bool `json:"bound_under_parent"`
|
||||
// Smart is the per-disk SMART health (agent v0.94.0+), nil when the device exposes no SMART or the
|
||||
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
|
||||
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
|
||||
Smart *SmartSummary `json:"smart,omitempty"`
|
||||
}
|
||||
|
||||
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
|
||||
@@ -1003,14 +1007,31 @@ type ThinPoolFill struct {
|
||||
MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
|
||||
}
|
||||
|
||||
// SmartSummary mirrors the agent's per-disk SMART health (only the fields the UI renders). Pointers
|
||||
// are null when the device type does not expose that attribute.
|
||||
// SmartSummary mirrors the agent's per-disk SMART health. Pointers are null when the device type
|
||||
// does not expose that attribute (a null is "unknown / not-applicable", distinct from a real zero).
|
||||
// The SATA set (reallocated/pending/offline-uncorrectable) and the NVMe set
|
||||
// (critical_warning/media_errors/percentage_used) are both carried; a device populates only its own.
|
||||
type SmartSummary struct {
|
||||
Health string `json:"health"` // PASSED | FAILING | UNKNOWN
|
||||
TemperatureC *int `json:"temperature_c"`
|
||||
PercentageUsed *int `json:"percentage_used"` // NVMe wear (%); null for SATA/USB
|
||||
Health string `json:"health"` // PASSED | FAILING | UNKNOWN
|
||||
TemperatureC *int `json:"temperature_c"`
|
||||
PowerOnHours *int `json:"power_on_hours"`
|
||||
// SATA attributes.
|
||||
ReallocatedSectors *int `json:"reallocated_sectors"`
|
||||
PendingSectors *int `json:"pending_sectors"`
|
||||
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
||||
// NVMe attributes.
|
||||
CriticalWarning *int `json:"critical_warning"`
|
||||
MediaErrors *int `json:"media_errors"`
|
||||
PercentageUsed *int `json:"percentage_used"` // NVMe wear (%); null for SATA/USB
|
||||
}
|
||||
|
||||
// SMART health vocabulary (mirrors the agent's).
|
||||
const (
|
||||
SmartPassed = "PASSED"
|
||||
SmartFailing = "FAILING"
|
||||
SmartUnknown = "UNKNOWN"
|
||||
)
|
||||
|
||||
// StorageTarget mirrors the agent's GET /host/metrics storage_targets entry (the per-storage
|
||||
// capacity + health the monitoring view renders). It is a SUBSET of the agent's wire shape — only
|
||||
// the fields the UI reads; unknown JSON keys are ignored.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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 6-hourly
|
||||
// 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" — PASSED, all counters clean
|
||||
DiskVerdictWarn // "Figyelmeztetés" — PASSED but a wear/relocation counter is non-zero (or NVMe ≥90%)
|
||||
DiskVerdictFail // "Hiba" — FAILING
|
||||
)
|
||||
|
||||
// percentageUsedWarn is the NVMe wear threshold (inclusive) at which a still-PASSED disk warns.
|
||||
const percentageUsedWarn = 90
|
||||
|
||||
// DiskVerdictFor maps a SmartSummary to a verdict per the v0.169.0 rules:
|
||||
//
|
||||
// FAILING → Hiba
|
||||
// PASSED + any(reallocated>0, pending>0, offline_unc>0,
|
||||
// critical_warning>0, media_errors>0,
|
||||
// percentage_used >= 90) → Figyelmeztetés
|
||||
// PASSED otherwise → Rendben
|
||||
// nil / UNKNOWN / empty → Nincs adat
|
||||
func DiskVerdictFor(s *SmartSummary) DiskVerdict {
|
||||
if s == nil || s.Health == "" || s.Health == SmartUnknown {
|
||||
return DiskVerdictUnknown
|
||||
}
|
||||
if s.Health == SmartFailing {
|
||||
return DiskVerdictFail
|
||||
}
|
||||
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters.
|
||||
if positive(s.ReallocatedSectors) || positive(s.PendingSectors) || positive(s.OfflineUncorrectable) ||
|
||||
positive(s.CriticalWarning) || positive(s.MediaErrors) || atLeast(s.PercentageUsed, percentageUsedWarn) {
|
||||
return DiskVerdictWarn
|
||||
}
|
||||
return DiskVerdictOK
|
||||
}
|
||||
|
||||
// Label is the exact Hungarian customer copy for the verdict (shared by the card chip and the email).
|
||||
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) that pushed a
|
||||
// PASSED disk to Figyelmeztetés (empty for OK/Fail/Unknown) — for the alert body. FAILING is a
|
||||
// whole-disk verdict with no single triggering counter, so it returns nil there.
|
||||
func DegradedAttributes(s *SmartSummary) []string {
|
||||
if s == nil {
|
||||
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")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func positive(p *int) bool { return p != nil && *p > 0 }
|
||||
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
|
||||
@@ -0,0 +1,63 @@
|
||||
package agentapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func ip(v int) *int { return &v }
|
||||
|
||||
// Verdict table (Part 2). Red-proof: change the PercentageUsed boundary from `>= 90` to `> 90` in
|
||||
// DiskVerdictFor → the "NVMe percentage_used exactly 90 → Figyelmeztetés" case fails.
|
||||
func TestDiskVerdictFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *SmartSummary
|
||||
want DiskVerdict
|
||||
}{
|
||||
{"nil → unknown", nil, DiskVerdictUnknown},
|
||||
{"empty health → unknown", &SmartSummary{Health: ""}, DiskVerdictUnknown},
|
||||
{"UNKNOWN → unknown", &SmartSummary{Health: SmartUnknown}, DiskVerdictUnknown},
|
||||
{"FAILING → fail", &SmartSummary{Health: SmartFailing}, DiskVerdictFail},
|
||||
{"FAILING beats counters", &SmartSummary{Health: SmartFailing, ReallocatedSectors: ip(0)}, DiskVerdictFail},
|
||||
{"PASSED clean → ok", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(0), PendingSectors: ip(0), TemperatureC: ip(30)}, DiskVerdictOK},
|
||||
{"PASSED nil counters → ok", &SmartSummary{Health: SmartPassed}, DiskVerdictOK},
|
||||
{"reallocated>0 → warn", &SmartSummary{Health: SmartPassed, ReallocatedSectors: ip(1)}, DiskVerdictWarn},
|
||||
{"pending>0 → warn", &SmartSummary{Health: SmartPassed, PendingSectors: ip(5)}, DiskVerdictWarn},
|
||||
{"offline_unc>0 → warn", &SmartSummary{Health: SmartPassed, OfflineUncorrectable: ip(2)}, DiskVerdictWarn},
|
||||
{"critical_warning>0 → warn", &SmartSummary{Health: SmartPassed, CriticalWarning: ip(1)}, DiskVerdictWarn},
|
||||
{"media_errors>0 → warn", &SmartSummary{Health: SmartPassed, MediaErrors: ip(3)}, DiskVerdictWarn},
|
||||
{"percentage_used 89 → ok", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(89)}, DiskVerdictOK},
|
||||
{"percentage_used exactly 90 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(90)}, DiskVerdictWarn},
|
||||
{"percentage_used 95 → warn", &SmartSummary{Health: SmartPassed, PercentageUsed: ip(95)}, DiskVerdictWarn},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DiskVerdictFor(c.in); got != c.want {
|
||||
t.Errorf("%s: DiskVerdictFor = %d, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskVerdict_Label(t *testing.T) {
|
||||
want := map[DiskVerdict]string{
|
||||
DiskVerdictUnknown: "Nincs adat",
|
||||
DiskVerdictOK: "Rendben",
|
||||
DiskVerdictWarn: "Figyelmeztetés",
|
||||
DiskVerdictFail: "Hiba",
|
||||
}
|
||||
for v, w := range want {
|
||||
if got := v.Label(); got != w {
|
||||
t.Errorf("verdict %d Label = %q, want %q", v, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A warn lists every triggering attribute at once (Scenario "multiple attributes degrade" → ONE event).
|
||||
func TestDegradedAttributes_ListsAll(t *testing.T) {
|
||||
s := &SmartSummary{Health: SmartPassed, PendingSectors: ip(5), ReallocatedSectors: ip(2), PercentageUsed: ip(91)}
|
||||
got := DegradedAttributes(s)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 attributes, got %d: %v", len(got), got)
|
||||
}
|
||||
// clean disk → none
|
||||
if a := DegradedAttributes(&SmartSummary{Health: SmartPassed}); len(a) != 0 {
|
||||
t.Errorf("clean disk should list no attributes, got %v", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// NotifyDiskHealthDegraded emits the right event type + severity + a message carrying the required
|
||||
// "Lemez állapot romlás: <label>" phrase and the triggering attribute(s). Uses the pushFn seam.
|
||||
func TestNotifyDiskHealthDegraded(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var gotType, gotSev, gotMsg string
|
||||
var gotDetails interface{}
|
||||
n.pushFn = func(eventType, severity, message string, details interface{}) {
|
||||
gotType, gotSev, gotMsg, gotDetails = eventType, severity, message, details
|
||||
}
|
||||
|
||||
// Warn (Figyelmeztetés): severity warn, attributes named.
|
||||
n.NotifyDiskHealthDegraded("sdb (lassú)", []string{"függőben lévő szektorok"}, false)
|
||||
if gotType != "disk_health_degraded" {
|
||||
t.Errorf("event type = %q, want disk_health_degraded", gotType)
|
||||
}
|
||||
if gotSev != "warn" {
|
||||
t.Errorf("severity = %q, want warn", gotSev)
|
||||
}
|
||||
if !strings.Contains(gotMsg, "Lemez állapot romlás: sdb (lassú)") {
|
||||
t.Errorf("message missing the required subject phrase: %q", gotMsg)
|
||||
}
|
||||
if !strings.Contains(gotMsg, "függőben lévő szektorok") {
|
||||
t.Errorf("message does not name the triggering attribute: %q", gotMsg)
|
||||
}
|
||||
if d, ok := gotDetails.(DiskHealthDetails); !ok || d.Disk != "sdb (lassú)" || d.Critical {
|
||||
t.Errorf("details = %+v, want DiskHealthDetails{Disk:sdb (lassú), Critical:false}", gotDetails)
|
||||
}
|
||||
|
||||
// Critical (Hiba/FAILING): severity critical.
|
||||
n.NotifyDiskHealthDegraded("sdc", nil, true)
|
||||
if gotSev != "critical" {
|
||||
t.Errorf("critical severity = %q, want critical", gotSev)
|
||||
}
|
||||
if !strings.Contains(gotMsg, "SMART önellenőrzése hibát jelez") {
|
||||
t.Errorf("critical message wrong: %q", gotMsg)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -428,6 +429,33 @@ func (n *Notifier) NotifyAppStartFailures(apps []AppRunState) {
|
||||
}
|
||||
}
|
||||
|
||||
// DiskHealthDetails is the event-detail payload for disk_health_degraded.
|
||||
type DiskHealthDetails struct {
|
||||
Disk string `json:"disk"`
|
||||
Attributes []string `json:"attributes,omitempty"`
|
||||
Critical bool `json:"critical"`
|
||||
}
|
||||
|
||||
// NotifyDiskHealthDegraded fires a disk_health_degraded hub event on a disk-health DEGRADATION only
|
||||
// (the controller's 6h check owns the transition logic — never first-run/recovery/UNKNOWN). severity
|
||||
// is warn for a Figyelmeztetés, critical for a Hiba (FAILING). The hub applies its own per-event-type
|
||||
// cooldown. NOTE: the event type "disk_health_degraded" MUST be in the hub's allowedEventTypes (else
|
||||
// the hub 400s the POST) — added in the hub's v0.x bump alongside this.
|
||||
func (n *Notifier) NotifyDiskHealthDegraded(label string, attrs []string, critical bool) {
|
||||
severity := "warn"
|
||||
var msg string
|
||||
switch {
|
||||
case critical:
|
||||
severity = "critical"
|
||||
msg = fmt.Sprintf("Lemez állapot romlás: %s — a lemez SMART önellenőrzése hibát jelez. Kérjük, mentse az adatait, és vegye fel velünk a kapcsolatot.", label)
|
||||
case len(attrs) > 0:
|
||||
msg = fmt.Sprintf("Lemez állapot romlás: %s — romló érték: %s. Javasolt figyelemmel kísérni.", label, strings.Join(attrs, ", "))
|
||||
default:
|
||||
msg = fmt.Sprintf("Lemez állapot romlás: %s — a lemez állapota romlott. Javasolt figyelemmel kísérni.", label)
|
||||
}
|
||||
n.emit("disk_health_degraded", severity, msg, DiskHealthDetails{Disk: label, Attributes: attrs, Critical: critical})
|
||||
}
|
||||
|
||||
// emit sends an event through the test seam if set, else the real async PushEvent.
|
||||
func (n *Notifier) emit(eventType, severity, message string, details interface{}) {
|
||||
if n.pushFn != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user