Files
felhom-controller/controller/internal/web/agent_host_metrics_handler.go
T

107 lines
4.5 KiB
Go

package web
import (
"net/http"
"sort"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// Agent-backed host metrics (slice 9).
//
// The de-privileged controller (slice 8C) sees only its own cgroup, so it cannot read host
// health itself. This thin proxy forwards GET /api/host-metrics to the agent's GET /host/metrics
// and returns the host-wide view (cpu%/mem/load/uptime/cpu-temp + per-storage capacity) for the
// monitoring page. It reuses the same pinned agentapi.Client + {ok,data,error} envelope as the
// disk proxy (agent_disk_handlers.go). Read-only; no CSRF mutation.
// ServeHostMetricsAPI proxies GET /api/host-metrics → agent GET /host/metrics.
// Wired in main.go behind RequireAuth.
func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeHostMetricsAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
}
if r.Method != http.MethodGet {
writeDiskJSON(w, http.StatusMethodNotAllowed, false, "method not allowed", nil)
return
}
client, err := s.agentClient()
if err != nil {
// Unprovisioned guest / no local API configured — the UI shows "host metrics unavailable".
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
return
}
resp, err := client.HostMetrics(r.Context())
if err != nil {
s.logger.Printf("[ERROR] [web] host metrics via agent failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
return
}
// The agent enumerates storages via `pvesm` in a non-deterministic order, so #host-storage-bars
// reordered on every poll (item 2). Stabilise the order Go-side and attach friendly labels + a
// one-line purpose per entry in the reader's language (W26, rehearsal 2026-10-20: they were
// Hungarian on the English dashboard) — display-only; we NEVER rename the PVE storage ids.
lang := s.langFor(r)
enrichHostStorageTargets(resp.StorageTargets, func(key string) string { return s.msgLang(lang, key) })
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// enrichHostStorageTargets sorts the host's storage targets into a stable, customer-meaningful
// order (user-data → system+apps → backup → other; alphabetical by id within a tier) and fills in
// a friendly label + purpose per entry. Mirrors the disk-overview's sortDisksForView contract:
// a Go-side ordering beats relying on the agent's enumeration order or template JS.
// msg turns a message key into the reader's text.
func enrichHostStorageTargets(targets []agentapi.StorageTarget, msg func(key string) string) {
sort.SliceStable(targets, func(i, j int) bool {
if ri, rj := storageTypeRank(targets[i].Type), storageTypeRank(targets[j].Type); ri != rj {
return ri < rj
}
return targets[i].Name < targets[j].Name
})
for i := range targets {
label, purpose := storageLabelAndPurpose(targets[i], msg)
targets[i].Label = label
targets[i].Purpose = purpose
}
}
// storageTypeRank orders storage by what the customer cares about: where their app data lives
// first, then the system/app disk, then backup targets. Lower sorts first.
func storageTypeRank(typ string) int {
switch typ {
case "usb", "local-dir":
return 0 // external user-data drives (where browsable app data lives)
case "lvmthin", "lvm":
return 1 // the internal SSD: OS + the guest/app volumes
case "local":
return 2 // builtin dir: templates + local vzdump backups
case "pbs", "nfs", "cifs":
return 3 // backup targets (offsite / network)
default:
return 4
}
}
// storageLabelAndPurpose maps a storage target to a friendly label + one-line purpose (message keys
// host_storage.*). Falls back to the raw id for unrecognised types. The raw id stays in Name (rendered muted).
func storageLabelAndPurpose(t agentapi.StorageTarget, msg func(key string) string) (string, string) {
switch t.Type {
case "usb":
return msg("host_storage.usb.label"), msg("host_storage.userdata.purpose")
case "local-dir":
return msg("host_storage.local_dir.label"), msg("host_storage.userdata.purpose")
case "lvmthin", "lvm":
return msg("host_storage.system.label"), msg("host_storage.system.purpose")
case "local":
return msg("host_storage.local.label"), msg("host_storage.local.purpose")
case "pbs":
return msg("host_storage.pbs.label"), msg("host_storage.pbs.purpose")
case "nfs":
return msg("host_storage.nfs.label"), msg("host_storage.network.purpose")
case "cifs":
return msg("host_storage.cifs.label"), msg("host_storage.network.purpose")
default:
return t.Name, ""
}
}