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) } }