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
+198
View File
@@ -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), "<button") {
t.Error("hosts list must not contain action buttons")
}
}
func TestHandleHostDetail(t *testing.T) {
s, st := newTestServer(t)
const secretKey = "SUPER-SECRET-HOST-KEY-42"
if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: secretKey}); 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", CloudflaredStatus: "healthy"}); err != nil {
t.Fatal(err)
}
// DR + escrow present (escrow row must exist before the DR bundle UPDATE).
if err := st.SaveHostEscrow("demo-felhom-01", []byte("opaque-escrow"), "fp", "posture", "2026-06-01T00:00:00Z"); err != nil {
t.Fatal(err)
}
if err := st.SaveHostDRBundle("demo-felhom-01", []byte("opaque-identity"), `{"v":1}`); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-01", nil), "demo-felhom-01")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"demo-felhom-01", // identity
"9201", // guest
"0.87.0", // controller version
"felhom-usb", // storage target
"PASSED", // SMART health
"41°C", // SMART temperature
"user-data", // role
"/customers/c1", // customer cross-link
} {
if !strings.Contains(body, want) {
t.Errorf("detail body missing %q", want)
}
}
// DR + escrow presence surfaced (booleans, not blobs).
if !strings.Contains(body, "DR / Backup") || strings.Count(body, "present") < 2 {
t.Error("DR/escrow presence not both shown")
}
// SECURITY: the host api_key must never be rendered.
if strings.Contains(body, secretKey) {
t.Errorf("SECRET LEAK: detail page rendered the host api_key")
}
// Read-only: no host action buttons.
if strings.Contains(strings.ToLower(body), "<button") {
t.Error("host detail must not contain action buttons")
}
}
func TestHandleHostDetail_Unknown(t *testing.T) {
s, _ := newTestServer(t)
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/nope", nil), "nope")
if rr.Code != http.StatusNotFound {
t.Errorf("unknown host: status = %d, want 404", rr.Code)
}
}
func TestHandleHostDetail_NoReport(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "fresh-host", CustomerID: "c9", APIKey: "k9"}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/fresh-host", nil), "fresh-host")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "waiting for first report") {
t.Error("no-report host should render 'waiting for first report'")
}
}