e35b1ae0e6
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
340 lines
10 KiB
Go
340 lines
10 KiB
Go
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)
|
||
}
|
||
}
|