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:
@@ -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 `<nav>` (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,
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,13 @@ type Server struct {
|
||||
// New creates a new web server.
|
||||
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
||||
funcMap := template.FuncMap{
|
||||
"timeAgo": timeAgo,
|
||||
"timeAgo": timeAgo,
|
||||
"timeAgoPtr": func(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "—"
|
||||
}
|
||||
return timeAgo(*t)
|
||||
},
|
||||
"statusColor": statusColor,
|
||||
"statusIcon": statusIcon,
|
||||
"formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) },
|
||||
@@ -171,6 +177,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case strings.HasPrefix(path, "/apps/"):
|
||||
appName := strings.TrimPrefix(path, "/apps/")
|
||||
s.handleAppDetail(w, r, appName)
|
||||
// Hosts — read-only fleet view (audit F-M1). GET only; no host actions.
|
||||
case path == "/hosts" || path == "/hosts/":
|
||||
s.handleHostsList(w, r)
|
||||
case strings.HasPrefix(path, "/hosts/"):
|
||||
hostID := strings.TrimPrefix(path, "/hosts/")
|
||||
s.handleHostDetail(w, r, hostID)
|
||||
case path == "/login":
|
||||
s.handleLogin(w, r)
|
||||
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"):
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link active">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link active">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link active">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
<a href="/configs" class="back-link">← All Customers</a>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="/" class="nav-link active">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.HostID}} — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link active">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<a href="/hosts" class="back-link">← Hosts</a>
|
||||
|
||||
<!-- Identity + Status -->
|
||||
<section class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h2 style="margin: 0;">{{.HostID}}</h2>
|
||||
<span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
</div>
|
||||
<div class="info-grid" style="margin-top: 1rem;">
|
||||
<div class="info-item">
|
||||
<span class="label">Host ID</span>
|
||||
<span class="value" style="font-family: var(--font-mono)">{{.HostID}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Customer</span>
|
||||
<span class="value"><a href="/customers/{{.CustomerID}}">{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</a></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Agent Version</span>
|
||||
<span class="value">{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Enrolled</span>
|
||||
<span class="value">{{timeAgo .CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Last Report</span>
|
||||
<span class="value">{{if .HasReport}}{{timeAgoPtr .LastReportAt}}{{else}}waiting for first report{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Desired Generation</span>
|
||||
<span class="value">{{.DesiredGeneration}}</span>
|
||||
</div>
|
||||
{{if .RecoveryMode}}
|
||||
<div class="info-item">
|
||||
<span class="label">Recovery Mode</span>
|
||||
<span class="value" style="color: var(--yellow)">ACTIVE (until {{timeAgoPtr .RecoveryUntil}})</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .HasReport}}
|
||||
<!-- Vitals -->
|
||||
<section class="card">
|
||||
<h2>Vitals</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">CPU</span>
|
||||
<span class="value">{{formatFloat .Vitals.CPUPercent}}%</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Memory</span>
|
||||
<span class="value">{{formatFloat .Vitals.MemoryPercent}}%</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Disk (root fs)</span>
|
||||
<span class="value">{{formatFloat .Vitals.DiskPercent}}%</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Cloudflared</span>
|
||||
<span class="value">{{if .Vitals.CloudflaredStatus}}{{.Vitals.CloudflaredStatus}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Guests</span>
|
||||
<span class="value">{{.GuestRunning}}/{{.GuestTotal}} running</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="card">
|
||||
<div class="empty-state">
|
||||
<p>Waiting for first report.</p>
|
||||
<p class="hint">Vitals, guests and storage appear once this host's agent sends a host-report.</p>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Guests -->
|
||||
<section class="card" style="padding: 0; overflow: hidden;">
|
||||
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Guests</h2>
|
||||
{{if .Guests}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VMID</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Controller</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Guests}}
|
||||
<tr>
|
||||
<td>{{.VMID}}</td>
|
||||
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
|
||||
<td>{{if eq .Status "running"}}<span style="color: var(--green)">{{.Status}}</span>{{else if eq .Status "stopped"}}<span style="color: var(--red)">{{.Status}}</span>{{else}}{{.Status}}{{end}}</td>
|
||||
<td>{{if .ControllerVersion}}<code>{{.ControllerVersion}}</code>{{else}}—{{end}}</td>
|
||||
<td>{{timeAgoPtr .LastSeenAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>No guests reported on this host.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Storage Targets -->
|
||||
<section class="card" style="padding: 0; overflow: hidden;">
|
||||
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Storage Targets</h2>
|
||||
{{if .StorageTargets}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Type</th>
|
||||
<th>State</th>
|
||||
<th>Fill</th>
|
||||
<th>Thin Pool</th>
|
||||
<th>SMART</th>
|
||||
<th>Temp</th>
|
||||
<th>Wear</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .StorageTargets}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{if .Role}}{{.Role}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Type}}{{.Type}}{{else}}—{{end}}</td>
|
||||
<td>{{if .State}}{{.State}}{{else}}—{{end}}</td>
|
||||
<td>{{formatFloat .FillPct}}%</td>
|
||||
<td>{{if .HasThin}}{{formatFloat .ThinDataPct}}%{{else}}—{{end}}</td>
|
||||
<td>{{if .SmartHealth}}{{if eq .SmartHealth "PASSED"}}<span style="color: var(--green)">{{.SmartHealth}}</span>{{else if eq .SmartHealth "FAILED"}}<span style="color: var(--red)">{{.SmartHealth}}</span>{{else}}{{.SmartHealth}}{{end}}{{else}}—{{end}}</td>
|
||||
<td>{{if .TempC}}{{.TempC}}°C{{else}}—{{end}}</td>
|
||||
<td>{{if .WearPct}}{{.WearPct}}%{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>No storage targets reported.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- DR / Backup -->
|
||||
<section class="card">
|
||||
<h2>DR / Backup</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">DR Recipe</span>
|
||||
<span class="value">{{if .DRPresent}}<span style="color: var(--green)">present</span>{{else}}<span class="text-muted">none</span>{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Key Escrow</span>
|
||||
<span class="value">{{if .EscrowPresent}}<span style="color: var(--green)">present</span>{{else}}<span class="text-muted">none</span>{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hosts — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link active">Hosts</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">Hosts</h2>
|
||||
|
||||
{{if .Hosts}}
|
||||
<section class="card" style="padding: 0; overflow: hidden;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Customer</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Guests</th>
|
||||
<th>CPU</th>
|
||||
<th>Mem</th>
|
||||
<th>Disk</th>
|
||||
<th>Cloudflared</th>
|
||||
<th>Worst Storage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Hosts}}
|
||||
<tr onclick="window.location='/hosts/{{.HostID}}'" style="cursor: pointer;">
|
||||
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a></td>
|
||||
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</td>
|
||||
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</td>
|
||||
<td><span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span></td>
|
||||
<td>{{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}}</td>
|
||||
<td>{{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}</td>
|
||||
<td>{{if .HasReport}}{{formatFloat .Vitals.MemoryPercent}}%{{else}}—{{end}}</td>
|
||||
<td>{{if .HasReport}}{{formatFloat .Vitals.DiskPercent}}%{{else}}—{{end}}</td>
|
||||
<td>{{if .Vitals.CloudflaredStatus}}{{.Vitals.CloudflaredStatus}}{{else}}—{{end}}</td>
|
||||
<td>{{if .HasStorage}}{{formatFloat .WorstFillPct}}% <span class="text-muted">{{.WorstFillName}}</span>{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>No hosts enrolled yet.</p>
|
||||
<p class="hint">A host appears here after its agent enrolls and sends its first host-report.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user