Files
felhom.eu/hub/internal/web/hosts.go
T
admin e35b1ae0e6 hub v0.46.0: observability pass — per-box log pulls, bundle custody, 72h TTL + secret gate
log_bundle_requests + log_bundles store (gzip, newest-3, 72h TTL purged on the
60s sweep); SaveLogBundle secret gate fail-closed (blocked flag row, no payload;
REDACTED/checksums pass). Report ACK gains controller_log_requested + ingests
controller_log_tail; heartbeat envelope gains log_tail_requested + ingests
log_tail (consume-once on arrival; pre-0.83 agents stay visibly pending). Host
detail Diagnostics section: request buttons (controller/agent), state rows with
honest latency hints, View/Download endpoint. Red-proofs: gate disabled and
clear-on-arrival removed both FAIL their tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 16:57:47 +02:00

340 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// agentOrUnknown renders an agent version for operator text, mapping the empty (never-reported)
// value to a readable token.
func agentOrUnknown(v string) string {
if v == "" {
return "unknown"
}
return v
}
// 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
// FloorHeld (Part D): the managed controller-version floor is being WITHHELD because this box's
// agent is below the current golden's MinAgent. HeldReason carries the operator-facing text.
FloorHeld bool
HeldReason string
}
// 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,
}
// Part D: surface a held managed floor (agent below the golden's MinAgent) so a held box is
// never silently stale.
if fd := s.store.ResolveManagedFloor(h.CustomerID); fd.Held {
row.FloorHeld = true
row.HeldReason = fmt.Sprintf("held: agent %s < MinAgent %s", agentOrUnknown(fd.AgentVersion), fd.MinAgent)
}
// 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,
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
"LogBundles": s.hostLogBundleRows(host),
"CSRFToken": s.getCSRFToken(r),
}
if err := s.templates.ExecuteTemplate(w, "host_detail.html", data); err != nil {
s.logger.Printf("[ERROR] host_detail.html template: %v", err)
}
}