slice 9: host-health view on the monitoring page (v0.39.0)

Add agentapi HostMetrics() + a thin /api/host-metrics proxy to the agent's
new GET /host/metrics, and a 'Szerver allapota (gazdagep)' card on the
monitoring page rendering host CPU%/load/mem/CPU-temp(n/a)/uptime + per-
storage capacity bars (thin-pool fill, disk temp/wear). Polls every 8s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:16:15 +02:00
parent 4c9065381b
commit d8d1e17758
8 changed files with 406 additions and 35 deletions
@@ -0,0 +1,84 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// hostMetricsStub serves a GET /host/metrics payload with a populated host block (CPU temp set)
// and one storage target carrying a thin-pool + SMART temp.
func hostMetricsStub(cpuTempNull bool) (*httptest.Server, string) {
temp := `47`
if cpuTempNull {
temp = `null`
}
mux := http.NewServeMux()
mux.HandleFunc("GET /host/metrics", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{
"vmid":8200,
"host":{"node":"demo-felhom","cpu_percent":12.5,"memory_total_bytes":17179869184,
"memory_used_bytes":4294967296,"memory_percent":25,"loadavg":["0.10","0.20","0.15"],
"uptime_seconds":86400,"cpu_temp_c":` + temp + `},
"storage_targets":[
{"name":"local-lvm","type":"lvmthin","state":"attached","reachable":true,
"total_bytes":100000000000,"used_bytes":42000000000,"used_fraction":0.42,
"thin_pool":{"data_used_fraction":0.42,"metadata_used_fraction":null},
"smart":{"health":"PASSED","temperature_c":38,"percentage_used":2}},
{"name":"usb-backup","type":"usb","state":"attached","reachable":true,
"total_bytes":2000000000000,"used_bytes":500000000000,"used_fraction":0.25,
"smart":{"health":"PASSED","temperature_c":null,"percentage_used":null}}
]}}`))
})
s := httptest.NewTLSServer(mux)
return s, strings.TrimPrefix(s.URL, "https://")
}
func TestHostMetrics_DecodesHostAndStorage(t *testing.T) {
s, ep := hostMetricsStub(false)
defer s.Close()
c := clientFor(t, s, ep)
resp, err := c.HostMetrics(context.Background())
if err != nil {
t.Fatal(err)
}
if resp.Host.Node != "demo-felhom" || resp.Host.CPUPercent != 12.5 {
t.Fatalf("host = %+v", resp.Host)
}
if resp.Host.CPUTempC == nil || *resp.Host.CPUTempC != 47 {
t.Fatalf("cpu_temp_c = %v, want 47", resp.Host.CPUTempC)
}
if len(resp.StorageTargets) != 2 {
t.Fatalf("storage targets = %d, want 2", len(resp.StorageTargets))
}
lvm := resp.StorageTargets[0]
if lvm.ThinPool == nil || lvm.ThinPool.DataUsedFraction != 0.42 {
t.Errorf("thin_pool = %+v", lvm.ThinPool)
}
if lvm.Smart.TemperatureC == nil || *lvm.Smart.TemperatureC != 38 {
t.Errorf("smart temp = %v, want 38", lvm.Smart.TemperatureC)
}
if lvm.Smart.PercentageUsed == nil || *lvm.Smart.PercentageUsed != 2 {
t.Errorf("smart wear = %v, want 2", lvm.Smart.PercentageUsed)
}
// USB drive: SMART temp/wear are null (USB bridge exposes no SMART) → graceful null.
if resp.StorageTargets[1].Smart.TemperatureC != nil {
t.Errorf("usb smart temp = %v, want nil", resp.StorageTargets[1].Smart.TemperatureC)
}
}
// A null cpu_temp_c decodes to a nil pointer (the UI renders "n/a").
func TestHostMetrics_NullCPUTemp(t *testing.T) {
s, ep := hostMetricsStub(true)
defer s.Close()
c := clientFor(t, s, ep)
resp, err := c.HostMetrics(context.Background())
if err != nil {
t.Fatal(err)
}
if resp.Host.CPUTempC != nil {
t.Fatalf("cpu_temp_c = %v, want nil (n/a)", resp.Host.CPUTempC)
}
}