diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md
index f92ad1e..dd071e2 100644
--- a/hub/CHANGELOG.md
+++ b/hub/CHANGELOG.md
@@ -1,5 +1,44 @@
# Felhom Hub — Changelog
+## v0.27.0 — Hosts page: read-only fleet view (audit F-M1) (2026-07-01)
+
+Resolves audit finding **F-M1**: the agent enrolls as a *host* and the hub stores rich host state
+(identity, agent version, guests, storage targets with SMART, DR/escrow, staleness) and alerts on it —
+but the whole host domain was **invisible in the GUI** (email-only). Adds a **Hosts** nav section — a
+fleet list + a per-host detail page. **Read-only** (GET only, no host actions/mutation routes): this
+surfaces state the way the pull/desired-state model demands; it does not reintroduce inbound control
+(retired in v0.26.0).
+
+- **New store reader `ListGuestsForHost(hostID)` (`internal/store/store.go`).** `SELECT … FROM guests
+ WHERE host_id = ? ORDER BY vmid`, via a new `scanGuest` helper over `guestRealitySelectCols` — the
+ reality columns only. It deliberately **omits the secret/inert columns** (`api_key`,
+ `desired_spec_json`), so the read-only view can never surface them. Returns `[]` (never nil-error) on
+ no guests. (There was previously no guests *reader* — only `UpsertGuestFromReport`.)
+- **New handlers (`internal/web/hosts.go`).**
+ - `handleHostsList` — `ListHosts` + a per-host status badge from `hostStatus()` (which reuses
+ `s.staleThreshold` — the **same** thresholds as the `HostStalenessChecker`: stale after the
+ threshold, down at 2× — so the badge agrees with the alerting) + per-host guest counts
+ (`ListGuestsForHost`) + vitals parsed from `GetLatestHostReportJSON` + worst storage fill grouped
+ from `GetHostStorageTargets`.
+ - `handleHostDetail` — `GetHost` (404 if absent) + `ListGuestsForHost` + rich storage targets (role,
+ state, fill %, thin-pool, SMART health/temp/wear parsed from the latest report body) + vitals +
+ `GetHostDRBundle`/`GetHostEscrow` **presence booleans only** (never the opaque blobs). Nil/missing
+ (no report, no guests, no storage, no DR) render empty states — never a panic.
+- **New templates `hosts.html` + `host_detail.html`** (existing dark operator-console styling reused —
+ `data-table`, `status-badge-*`, `info-grid`, `empty-state`; no restyle). A no-report host shows a
+ STALE/NO-REPORT badge and "waiting for first report".
+- **Nav:** added the `Hosts` link (between Apps and Configuration) to every page's `` (the nav is
+ duplicated per page, not a shared partial) + the new `timeAgoPtr` template helper for `*time.Time`.
+- **Routes (`internal/web/server.go`):** `GET /hosts` (+ `/hosts/`) → list, `GET /hosts/{id}` → detail,
+ modelled on the `/apps` pair. GET only.
+- **Tests:** `ListGuestsForHost` (none→empty, multiple→vmid-ordered, secret column not surfaced); the
+ list handler (N rows, ONLINE + NO-REPORT badges, worst-fill, no action buttons); the detail handler
+ (guests + storage + SMART + DR present, customer cross-link, **no-secret assertion** that the host
+ `api_key` is absent from the rendered body, no buttons); unknown host → 404; no-report host renders
+ the waiting state. `hostStatus` band mapping unit-tested (pending/ok/stale/down).
+- **Remaining audit follow-ups (not this slice):** controller-side geo intent sync; a read-only
+ reported-vs-desired "Show Diff"; the cosmetic `controllerURL` cleanup in `configs.go`.
+
## v0.26.0 — pull-based config delivery + retire the inbound GUI controls (2026-06-30)
Closes audit `documentation/audits/AUDIT-hub-gui-2026-06-30.md` F-S1/F-S4 + the dead-template findings,
diff --git a/hub/internal/store/host_test.go b/hub/internal/store/host_test.go
index c03d8f8..9ff30b1 100644
--- a/hub/internal/store/host_test.go
+++ b/hub/internal/store/host_test.go
@@ -4,6 +4,7 @@ import (
"io"
"log"
"path/filepath"
+ "strconv"
"testing"
)
@@ -144,6 +145,56 @@ func TestUpsertGuestFromReport_PreservesInertColumns(t *testing.T) {
}
}
+func TestListGuestsForHost(t *testing.T) {
+ s := newTestStore(t)
+
+ // none → empty (never nil-error)
+ guests, err := s.ListGuestsForHost("h1")
+ if err != nil {
+ t.Fatalf("ListGuestsForHost (none): %v", err)
+ }
+ if len(guests) != 0 {
+ t.Errorf("no guests → want 0, got %d", len(guests))
+ }
+
+ // multiple, inserted out of vmid order → returned ordered by vmid
+ for _, vmid := range []int{300, 100, 200} {
+ g := &Guest{GuestID: GuestID("h1", vmid), CustomerID: "c1", HostID: "h1", VMID: vmid,
+ DisplayName: "g" + strconv.Itoa(vmid), Status: "running", ControllerVersion: "0.87.0"}
+ if err := s.UpsertGuestFromReport(g); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // a guest on a different host must not leak in
+ if err := s.UpsertGuestFromReport(&Guest{GuestID: GuestID("h2", 999), CustomerID: "c2", HostID: "h2", VMID: 999}); err != nil {
+ t.Fatal(err)
+ }
+ // set a secret column to prove the reader never surfaces it
+ if _, err := s.db.Exec(`UPDATE guests SET api_key='SECRET' WHERE host_id='h1'`); err != nil {
+ t.Fatal(err)
+ }
+
+ guests, err = s.ListGuestsForHost("h1")
+ if err != nil {
+ t.Fatalf("ListGuestsForHost (multi): %v", err)
+ }
+ if len(guests) != 3 {
+ t.Fatalf("want 3 guests for h1, got %d", len(guests))
+ }
+ wantOrder := []int{100, 200, 300}
+ for i, g := range guests {
+ if g.VMID != wantOrder[i] {
+ t.Errorf("guest[%d].VMID = %d, want %d (vmid order)", i, g.VMID, wantOrder[i])
+ }
+ if g.ControllerVersion != "0.87.0" || g.Status != "running" {
+ t.Errorf("guest[%d] reality wrong: %+v", i, g)
+ }
+ if g.APIKey != "" {
+ t.Errorf("guest[%d] APIKey must not be read, got %q", i, g.APIKey)
+ }
+ }
+}
+
func TestGetHostStaleness_SkipsNeverReported(t *testing.T) {
s := newTestStore(t)
s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"})
diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go
index c3012d6..18c595f 100644
--- a/hub/internal/store/store.go
+++ b/hub/internal/store/store.go
@@ -1620,6 +1620,50 @@ func (s *Store) UpsertGuestFromReport(g *Guest) error {
return err
}
+// guestRealitySelectCols are the report-driven reality columns (plus identity/timestamps)
+// of a guest. It deliberately OMITS the secret/inert columns (api_key, desired_spec_json):
+// the read-only Hosts view never renders them, so they are not selected.
+const guestRealitySelectCols = `guest_id, customer_id, host_id, vmid, display_name, status,
+ controller_version, last_seen_at, created_at, updated_at`
+
+func scanGuest(scan func(dest ...any) error) (*Guest, error) {
+ var g Guest
+ var lastSeen sql.NullString
+ var createdAt, updatedAt string
+ err := scan(&g.GuestID, &g.CustomerID, &g.HostID, &g.VMID, &g.DisplayName, &g.Status,
+ &g.ControllerVersion, &lastSeen, &createdAt, &updatedAt)
+ if err != nil {
+ return nil, err
+ }
+ if lastSeen.Valid && lastSeen.String != "" {
+ t := parseSQLiteTime(lastSeen.String)
+ g.LastSeenAt = &t
+ }
+ g.CreatedAt = parseSQLiteTime(createdAt)
+ g.UpdatedAt = parseSQLiteTime(updatedAt)
+ return &g, nil
+}
+
+// ListGuestsForHost returns the guests (controller LXCs) enrolled on a host, ordered by
+// vmid. Reads only reality columns (no api_key / desired_spec_json). Returns an empty
+// slice (never nil-error) when the host has no guests — the read-only Hosts detail view.
+func (s *Store) ListGuestsForHost(hostID string) ([]Guest, error) {
+ rows, err := s.db.Query(`SELECT `+guestRealitySelectCols+` FROM guests WHERE host_id = ? ORDER BY vmid`, hostID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ guests := []Guest{}
+ for rows.Next() {
+ g, err := scanGuest(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ guests = append(guests, *g)
+ }
+ return guests, rows.Err()
+}
+
// GetHostStaleness returns per-host recency for the dead-man's-switch. Hosts that
// have never reported (NULL last_report_at) are skipped — a freshly-minted host is
// not "down" until it has checked in at least once.
diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go
new file mode 100644
index 0000000..6490d86
--- /dev/null
+++ b/hub/internal/web/hosts.go
@@ -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)
+ }
+}
diff --git a/hub/internal/web/hosts_test.go b/hub/internal/web/hosts_test.go
new file mode 100644
index 0000000..514341d
--- /dev/null
+++ b/hub/internal/web/hosts_test.go
@@ -0,0 +1,198 @@
+package web
+
+import (
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "gitea.dooplex.hu/admin/felhom-hub/internal/store"
+)
+
+func newTestServer(t *testing.T) (*Server, *store.Store) {
+ t.Helper()
+ st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ t.Cleanup(func() { st.Close() })
+ // Empty passwordHash → RequireAuth/CSRF are bypassed; we call handlers directly anyway.
+ s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
+ return s, st
+}
+
+// A host-report body carrying vitals, cloudflared, and one rich storage target (with SMART).
+const testReportJSON = `{
+ "host": {"cpu_percent": 12.5, "memory_percent": 40.0, "disk_percent": 24.0},
+ "cloudflared": {"status": "healthy"},
+ "storage_targets": [
+ {"name": "felhom-usb", "type": "usb", "role": "user-data", "state": "active",
+ "mount_path": "/mnt/felhom-drives/felhom-usb", "reachable": true, "used_fraction": 0.73,
+ "smart": {"health": "PASSED", "temperature_c": 41, "percentage_used": 3}},
+ {"name": "local-lvm", "type": "lvmthin", "role": "docker-data", "state": "active",
+ "used_fraction": 0.31, "thin_pool": {"data_used_fraction": 0.31},
+ "smart": {"health": "PASSED"}}
+ ]
+}`
+
+// TestHostStatus exercises the staleness-band mapping deterministically (the badge must agree
+// with the HostStalenessChecker: stale after threshold, down after 2×).
+func TestHostStatus(t *testing.T) {
+ s, _ := newTestServer(t) // staleThreshold = 30m
+ now := time.Now()
+ cases := []struct {
+ name string
+ last *time.Time
+ want string
+ }{
+ {"never reported", nil, "pending"},
+ {"fresh", tptr(now.Add(-1 * time.Minute)), "ok"},
+ {"stale", tptr(now.Add(-45 * time.Minute)), "stale"},
+ {"down", tptr(now.Add(-3 * time.Hour)), "down"},
+ }
+ for _, c := range cases {
+ if got := s.hostStatus(c.last); got != c.want {
+ t.Errorf("%s: hostStatus = %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+func tptr(t time.Time) *time.Time { return &t }
+
+func TestHandleHostsList(t *testing.T) {
+ s, st := newTestServer(t)
+
+ // Host A: enrolled + reported (→ ONLINE, has vitals + storage).
+ if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: "k1"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("demo-felhom-01", 9201),
+ CustomerID: "c1", HostID: "demo-felhom-01", VMID: 9201, DisplayName: "acme", Status: "running",
+ ControllerVersion: "0.87.0"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SaveHostReport("demo-felhom-01", "c1", []byte(testReportJSON), store.HostReportDenorm{
+ AgentVersion: "0.43.0", CPUPercent: 12.5, MemoryPercent: 40, DiskPercent: 24,
+ GuestTotal: 1, GuestRunning: 1, CloudflaredStatus: "healthy"}); err != nil {
+ t.Fatal(err)
+ }
+ // Host B: enrolled, never reported (→ NO REPORT).
+ if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-02", CustomerID: "c2", APIKey: "k2"}); err != nil {
+ t.Fatal(err)
+ }
+
+ rr := httptest.NewRecorder()
+ s.handleHostsList(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ body := rr.Body.String()
+
+ // Two host rows.
+ if n := strings.Count(body, `window.location='/hosts/`); n != 2 {
+ t.Errorf("want 2 host rows, got %d", n)
+ }
+ // Statuses.
+ if !strings.Contains(body, "ONLINE") {
+ t.Error("reported host should show ONLINE")
+ }
+ if !strings.Contains(body, "NO REPORT") {
+ t.Error("never-reported host should show NO REPORT")
+ }
+ // Worst storage fill = 73% (felhom-usb), not the 31% lvmthin.
+ if !strings.Contains(body, "73%") {
+ t.Error("worst storage fill (73%) not rendered")
+ }
+ // No action buttons on a read-only page.
+ if strings.Contains(strings.ToLower(body), "Dashboard
Customers
Apps
+ Hosts
Configuration
diff --git a/hub/internal/web/templates/apps.html b/hub/internal/web/templates/apps.html
index 2b1c2f4..a014444 100644
--- a/hub/internal/web/templates/apps.html
+++ b/hub/internal/web/templates/apps.html
@@ -14,6 +14,7 @@
Dashboard
Customers
Apps
+ Hosts
Configuration
diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html
index 1b723ce..5a698b4 100644
--- a/hub/internal/web/templates/config_form.html
+++ b/hub/internal/web/templates/config_form.html
@@ -14,6 +14,7 @@
Dashboard
Customers
Apps
+ Hosts
Configuration
diff --git a/hub/internal/web/templates/configs.html b/hub/internal/web/templates/configs.html
index 344c1b6..b611b9d 100644
--- a/hub/internal/web/templates/configs.html
+++ b/hub/internal/web/templates/configs.html
@@ -14,6 +14,7 @@
Dashboard
Customers
Apps
+ Hosts
Configuration
diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html
index 5b3b7f1..b5354b5 100644
--- a/hub/internal/web/templates/configuration.html
+++ b/hub/internal/web/templates/configuration.html
@@ -14,6 +14,7 @@
Dashboard
Customers
Apps
+ Hosts
Configuration
diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html
index dd7afa8..1bddd4c 100644
--- a/hub/internal/web/templates/customer_unified.html
+++ b/hub/internal/web/templates/customer_unified.html
@@ -15,6 +15,7 @@
Dashboard
Customers
Apps
+ Hosts
Configuration
← All Customers
diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html
index e2efbfd..8064cc2 100644
--- a/hub/internal/web/templates/dashboard.html
+++ b/hub/internal/web/templates/dashboard.html
@@ -15,6 +15,7 @@
Dashboard
Customers
Apps
+ Hosts
Configuration
diff --git a/hub/internal/web/templates/host_detail.html b/hub/internal/web/templates/host_detail.html
new file mode 100644
index 0000000..a454b74
--- /dev/null
+++ b/hub/internal/web/templates/host_detail.html
@@ -0,0 +1,194 @@
+
+
+
+
+
+ {{.HostID}} — Felhom Hub
+
+
+
+
+
+
+
← Hosts
+
+
+
+
+
{{.HostID}}
+ {{.StatusLabel}}
+
+
+
+ Host ID
+ {{.HostID}}
+
+
+
+ Agent Version
+ {{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}}
+
+
+ Enrolled
+ {{timeAgo .CreatedAt}}
+
+
+ Last Report
+ {{if .HasReport}}{{timeAgoPtr .LastReportAt}}{{else}}waiting for first report{{end}}
+
+
+ Desired Generation
+ {{.DesiredGeneration}}
+
+ {{if .RecoveryMode}}
+
+ Recovery Mode
+ ACTIVE (until {{timeAgoPtr .RecoveryUntil}})
+
+ {{end}}
+
+
+
+ {{if .HasReport}}
+
+
+ Vitals
+
+
+ CPU
+ {{formatFloat .Vitals.CPUPercent}}%
+
+
+ Memory
+ {{formatFloat .Vitals.MemoryPercent}}%
+
+
+ Disk (root fs)
+ {{formatFloat .Vitals.DiskPercent}}%
+
+
+ Cloudflared
+ {{if .Vitals.CloudflaredStatus}}{{.Vitals.CloudflaredStatus}}{{else}}—{{end}}
+
+
+ Guests
+ {{.GuestRunning}}/{{.GuestTotal}} running
+
+
+
+ {{else}}
+
+
+
Waiting for first report.
+
Vitals, guests and storage appear once this host's agent sends a host-report.
+
+
+ {{end}}
+
+
+
+ Guests
+ {{if .Guests}}
+
+
+
+ VMID
+ Name
+ Status
+ Controller
+ Last Seen
+
+
+
+ {{range .Guests}}
+
+ {{.VMID}}
+ {{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}
+ {{if eq .Status "running"}}{{.Status}} {{else if eq .Status "stopped"}}{{.Status}} {{else}}{{.Status}}{{end}}
+ {{if .ControllerVersion}}{{.ControllerVersion}}{{else}}—{{end}}
+ {{timeAgoPtr .LastSeenAt}}
+
+ {{end}}
+
+
+ {{else}}
+
+
No guests reported on this host.
+
+ {{end}}
+
+
+
+
+ Storage Targets
+ {{if .StorageTargets}}
+
+
+
+ Name
+ Role
+ Type
+ State
+ Fill
+ Thin Pool
+ SMART
+ Temp
+ Wear
+
+
+
+ {{range .StorageTargets}}
+
+ {{.Name}}
+ {{if .Role}}{{.Role}}{{else}}—{{end}}
+ {{if .Type}}{{.Type}}{{else}}—{{end}}
+ {{if .State}}{{.State}}{{else}}—{{end}}
+ {{formatFloat .FillPct}}%
+ {{if .HasThin}}{{formatFloat .ThinDataPct}}%{{else}}—{{end}}
+ {{if .SmartHealth}}{{if eq .SmartHealth "PASSED"}}{{.SmartHealth}} {{else if eq .SmartHealth "FAILED"}}{{.SmartHealth}} {{else}}{{.SmartHealth}}{{end}}{{else}}—{{end}}
+ {{if .TempC}}{{.TempC}}°C{{else}}—{{end}}
+ {{if .WearPct}}{{.WearPct}}%{{else}}—{{end}}
+
+ {{end}}
+
+
+ {{else}}
+
+
No storage targets reported.
+
+ {{end}}
+
+
+
+
+ DR / Backup
+
+
+ DR Recipe
+ {{if .DRPresent}}present {{else}}none {{end}}
+
+
+ Key Escrow
+ {{if .EscrowPresent}}present {{else}}none {{end}}
+
+
+
+
+
+ Felhom Hub {{hubVersion}}
+
+
+
+
diff --git a/hub/internal/web/templates/hosts.html b/hub/internal/web/templates/hosts.html
new file mode 100644
index 0000000..ffbd553
--- /dev/null
+++ b/hub/internal/web/templates/hosts.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Hosts — Felhom Hub
+
+
+
+
+
+
+
Hosts
+
+ {{if .Hosts}}
+
+
+
+
+ Host
+ Customer
+ Agent
+ Status
+ Guests
+ CPU
+ Mem
+ Disk
+ Cloudflared
+ Worst Storage
+
+
+
+ {{range .Hosts}}
+
+ {{.HostID}}
+ {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}
+ {{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}}
+ {{.StatusLabel}}
+ {{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}}
+ {{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}
+ {{if .HasReport}}{{formatFloat .Vitals.MemoryPercent}}%{{else}}—{{end}}
+ {{if .HasReport}}{{formatFloat .Vitals.DiskPercent}}%{{else}}—{{end}}
+ {{if .Vitals.CloudflaredStatus}}{{.Vitals.CloudflaredStatus}}{{else}}—{{end}}
+ {{if .HasStorage}}{{formatFloat .WorstFillPct}}% {{.WorstFillName}} {{else}}—{{end}}
+
+ {{end}}
+
+
+
+ {{else}}
+
+
No hosts enrolled yet.
+
A host appears here after its agent enrolls and sends its first host-report.
+
+ {{end}}
+
+
+ Felhom Hub {{hubVersion}}
+
+
+
+