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:
@@ -267,6 +267,78 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- slice 9: host metrics (the customer host-health view) -------------------------------
|
||||
|
||||
// HostMetrics mirrors the agent's GET /host/metrics `host` block (shared HostMetrics wire shape).
|
||||
// CPUTempC is a pointer so a host with no temp sensor is null ("n/a"), distinct from a real 0.
|
||||
type HostMetrics struct {
|
||||
Node string `json:"node"`
|
||||
CPUPercent float64 `json:"cpu_percent"` // 0–100
|
||||
MemoryTotalBytes int64 `json:"memory_total_bytes"`
|
||||
MemoryUsedBytes int64 `json:"memory_used_bytes"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
DiskTotalBytes int64 `json:"disk_total_bytes"` // host root fs
|
||||
DiskUsedBytes int64 `json:"disk_used_bytes"`
|
||||
DiskPercent float64 `json:"disk_percent"`
|
||||
LoadAvg []string `json:"loadavg"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
CPUTempC *int `json:"cpu_temp_c"` // °C or null ("n/a")
|
||||
}
|
||||
|
||||
// ThinPoolFill mirrors the agent's lvmthin pool fill (a full thin-pool corrupts every guest on it).
|
||||
type ThinPoolFill struct {
|
||||
DataUsedFraction float64 `json:"data_used_fraction"`
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
type StorageTarget struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
State string `json:"state"`
|
||||
Reachable bool `json:"reachable"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
AvailBytes int64 `json:"avail_bytes"`
|
||||
UsedFraction float64 `json:"used_fraction"`
|
||||
Content string `json:"content"`
|
||||
MountPath string `json:"mount_path"`
|
||||
ClassHint string `json:"class_hint"`
|
||||
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
|
||||
Smart SmartSummary `json:"smart"`
|
||||
}
|
||||
|
||||
// HostMetricsResponse mirrors the agent's GET /host/metrics payload (host-wide health + per-storage
|
||||
// capacity). Host-wide and token-authed (one-customer-per-host); a fresh collect, not a snapshot.
|
||||
type HostMetricsResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Host HostMetrics `json:"host"`
|
||||
StorageTargets []StorageTarget `json:"storage_targets"`
|
||||
}
|
||||
|
||||
// HostMetrics calls GET /host/metrics and returns the host's live health + per-storage capacity.
|
||||
func (c *Client) HostMetrics(ctx context.Context) (HostMetricsResponse, error) {
|
||||
var out HostMetricsResponse
|
||||
body, err := c.get(ctx, "/host/metrics")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /host/metrics: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// get issues an authenticated GET and unwraps the {ok,data,error} envelope.
|
||||
func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user