hub v0.27.0: Hosts page — read-only fleet view (audit F-M1)

New Hosts nav section: a fleet list (/hosts) + per-host detail (/hosts/{id}),
read-only (GET only, no host actions). Surfaces identity, agent version,
online/stale status (reusing the HostStalenessChecker threshold), guests,
vitals, storage targets with SMART/thin-pool, and DR/escrow presence.

- store: new ListGuestsForHost reader (reality cols only; omits api_key/
  desired_spec_json) + scanGuest helper.
- web: handleHostsList + handleHostDetail (hosts.go); hosts.html +
  host_detail.html; Hosts nav link on every page; timeAgoPtr helper; routes.
- tests: store getter, both handlers, no-secret (api_key) assertion, 404,
  no-report empty state, status-band mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 06:10:02 +02:00
parent 73b3f6ac71
commit 2289fc907c
15 changed files with 932 additions and 1 deletions
+315
View File
@@ -0,0 +1,315 @@
package web
import (
"encoding/json"
"net/http"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// hostStatus computes the host's liveness state from its last-report recency, using the
// SAME thresholds as the HostStalenessChecker (s.staleThreshold; "down" at 2×). This keeps
// the GUI badge in agreement with the alerting — there is no second definition of "stale".
// Returns one of: "ok" (online), "stale", "down", "pending" (never reported).
func (s *Server) hostStatus(lastReport *time.Time) string {
if lastReport == nil {
return "pending"
}
age := time.Since(*lastReport)
switch {
case age > 2*s.staleThreshold:
return "down"
case age > s.staleThreshold:
return "stale"
default:
return "ok"
}
}
// hostStatusClass maps the internal status to the existing status-badge-* CSS class.
// "stale" reuses the amber -warn class (there is no dedicated -stale class), keeping the
// styling consistent with the rest of the console.
func hostStatusClass(status string) string {
switch status {
case "ok":
return "status-badge-ok"
case "stale":
return "status-badge-warn"
case "down":
return "status-badge-down"
default:
return "status-badge-pending"
}
}
// hostStatusLabel maps the internal status to the operator-facing badge label.
func hostStatusLabel(status string) string {
switch status {
case "ok":
return "ONLINE"
case "stale":
return "STALE"
case "down":
return "DOWN"
default:
return "NO REPORT"
}
}
// hostVitals are the report-body fields the Hosts views surface (CPU/mem/disk +
// cloudflared). Parsed from the latest host-report's report_json — the same body the
// checkers read; no new ingestion path. Zero values when there is no report.
type hostVitals struct {
CPUPercent float64
MemoryPercent float64
DiskPercent float64
CloudflaredStatus string
}
// parseHostVitals extracts the vitals block from a host-report body. A missing/malformed
// body yields zero vitals (never a panic) — the "waiting for first report" path.
func parseHostVitals(reportJSON string) hostVitals {
var v hostVitals
if reportJSON == "" {
return v
}
var body struct {
Host struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryPercent float64 `json:"memory_percent"`
DiskPercent float64 `json:"disk_percent"`
} `json:"host"`
Cloudflared struct {
Status string `json:"status"`
} `json:"cloudflared"`
}
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
return v
}
v.CPUPercent = body.Host.CPUPercent
v.MemoryPercent = body.Host.MemoryPercent
v.DiskPercent = body.Host.DiskPercent
v.CloudflaredStatus = body.Cloudflared.Status
return v
}
// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
// mirrors the fields the read-only page shows). Never carries a secret.
type storageTargetView struct {
Name string
Type string
Role string
State string
MountPath string
Reachable bool
FillPct float64 // used_fraction × 100
HasThin bool
ThinDataPct float64
// SMART (pointers → "n/a" when the drive/agent doesn't report the metric)
SmartHealth string
TempC *int
WearPct *int // NVMe percentage_used
}
// parseHostStorageTargets extracts the rich storage-target rows from a report body. A
// missing/malformed body yields an empty slice.
func parseHostStorageTargets(reportJSON string) []storageTargetView {
out := []storageTargetView{}
if reportJSON == "" {
return out
}
var body struct {
StorageTargets []struct {
Name string `json:"name"`
Type string `json:"type"`
Role string `json:"role"`
State string `json:"state"`
MountPath string `json:"mount_path"`
Reachable bool `json:"reachable"`
UsedFraction float64 `json:"used_fraction"`
ThinPool *struct {
DataUsedFraction float64 `json:"data_used_fraction"`
} `json:"thin_pool"`
Smart struct {
Health string `json:"health"`
TemperatureC *int `json:"temperature_c"`
PercentageUsed *int `json:"percentage_used"`
} `json:"smart"`
} `json:"storage_targets"`
}
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
return out
}
for _, t := range body.StorageTargets {
v := storageTargetView{
Name: t.Name, Type: t.Type, Role: t.Role, State: t.State,
MountPath: t.MountPath, Reachable: t.Reachable,
FillPct: t.UsedFraction * 100,
SmartHealth: t.Smart.Health, TempC: t.Smart.TemperatureC, WearPct: t.Smart.PercentageUsed,
}
if t.ThinPool != nil {
v.HasThin = true
v.ThinDataPct = t.ThinPool.DataUsedFraction * 100
}
out = append(out, v)
}
return out
}
// hostListRow is the per-host view model for the fleet list.
type hostListRow struct {
HostID string
CustomerID string
CustomerName string
AgentVersion string
Status string // ok | stale | down | pending
StatusLabel string
StatusClass string
LastReportAt *time.Time
HasReport bool
GuestRunning int
GuestTotal int
Vitals hostVitals
WorstFillPct float64
WorstFillName string
HasStorage bool
}
// customerName resolves a display name for a customer id (config first, then the last
// report's embedded name), falling back to the id. Read-only convenience for the Hosts views.
func (s *Server) customerName(customerID string) string {
if cfg, _ := s.store.GetCustomerConfig(customerID); cfg != nil && cfg.CustomerName != "" {
return cfg.CustomerName
}
if c, _ := s.store.GetCustomer(customerID); c != nil && c.CustomerName != "" {
return c.CustomerName
}
return customerID
}
// handleHostsList renders the read-only fleet list of enrolled hosts (audit F-M1). GET only.
func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
hosts, err := s.store.ListHosts()
if err != nil {
s.logger.Printf("[ERROR] Hosts list: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Worst storage fill per host, from every host's latest report.
targets, _ := s.store.GetHostStorageTargets()
worstFill := make(map[string]store.HostStorageTargetRow)
for _, t := range targets {
if cur, ok := worstFill[t.HostID]; !ok || t.Percent > cur.Percent {
worstFill[t.HostID] = t
}
}
rows := make([]hostListRow, 0, len(hosts))
for _, h := range hosts {
status := s.hostStatus(h.LastReportAt)
row := hostListRow{
HostID: h.HostID,
CustomerID: h.CustomerID,
CustomerName: s.customerName(h.CustomerID),
AgentVersion: h.AgentVersion,
Status: status,
StatusLabel: hostStatusLabel(status),
StatusClass: hostStatusClass(status),
LastReportAt: h.LastReportAt,
HasReport: h.LastReportAt != nil,
}
// Guest counts from the reality table (per-host accurate).
guests, _ := s.store.ListGuestsForHost(h.HostID)
row.GuestTotal = len(guests)
for _, g := range guests {
if g.Status == "running" {
row.GuestRunning++
}
}
// Vitals from the latest report body.
if reportJSON, _ := s.store.GetLatestHostReportJSON(h.CustomerID); reportJSON != "" {
row.Vitals = parseHostVitals(reportJSON)
}
if wf, ok := worstFill[h.HostID]; ok {
row.HasStorage = true
row.WorstFillPct = wf.Percent
row.WorstFillName = wf.Name
}
rows = append(rows, row)
}
data := map[string]interface{}{
"Hosts": rows,
}
if err := s.templates.ExecuteTemplate(w, "hosts.html", data); err != nil {
s.logger.Printf("[ERROR] hosts.html template: %v", err)
}
}
// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
host, err := s.store.GetHost(hostID)
if err != nil {
s.logger.Printf("[ERROR] Host detail %s: %v", hostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if host == nil {
http.NotFound(w, r)
return
}
status := s.hostStatus(host.LastReportAt)
guests, _ := s.store.ListGuestsForHost(hostID)
guestRunning := 0
for _, g := range guests {
if g.Status == "running" {
guestRunning++
}
}
reportJSON, _ := s.store.GetLatestHostReportJSON(host.CustomerID)
vitals := parseHostVitals(reportJSON)
storageTargets := parseHostStorageTargets(reportJSON)
sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
// DR / backup presence — booleans only, never the opaque blobs.
drBundle, _ := s.store.GetHostDRBundle(hostID)
escrow, _ := s.store.GetHostEscrow(hostID)
data := map[string]interface{}{
"HostID": host.HostID,
"CustomerID": host.CustomerID,
"CustomerName": s.customerName(host.CustomerID),
"AgentVersion": host.AgentVersion,
"CreatedAt": host.CreatedAt,
"Status": status,
"StatusLabel": hostStatusLabel(status),
"StatusClass": hostStatusClass(status),
"LastReportAt": host.LastReportAt,
"HasReport": host.LastReportAt != nil,
"RecoveryMode": host.InRecoveryMode(time.Now()),
"RecoveryUntil": host.RecoveryModeUntil,
"DesiredGeneration": host.DesiredGeneration,
"Vitals": vitals,
"Guests": guests,
"GuestRunning": guestRunning,
"GuestTotal": len(guests),
"StorageTargets": storageTargets,
"DRPresent": drBundle != nil,
"EscrowPresent": escrow != nil,
}
if err := s.templates.ExecuteTemplate(w, "host_detail.html", data); err != nil {
s.logger.Printf("[ERROR] host_detail.html template: %v", err)
}
}