hub: shared host_detail_body sub-template + customer Host tab (v0.47.0 part 3)

- host_detail_body.html: {{define}}'d body sections extracted from
  host_detail.html; the standalone page is now chrome + the sub-template
- hosts.go: hostDetailData(host, r) view-model builder extracted from
  handleHostDetail (reused by both surfaces)
- store: ListHostsByCustomer (host_id order; the Host tab is a list by
  design - N hosts for a future HA cluster)
- customer Host tab renders one host_detail_body per host + cross-link;
  empty state when no host is enrolled
- tests: TestTemplates_CustomerHostTab(+_Empty), TestListHostsByCustomer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vvz1NCu22p8dGkRCpeX9re
This commit is contained in:
2026-07-11 21:17:44 +02:00
parent 9f29bf34c0
commit ae950e5933
8 changed files with 400 additions and 242 deletions
+27
View File
@@ -86,6 +86,33 @@ func TestGetHostByCustomer(t *testing.T) {
}
}
// v0.47.0 Host tab: ListHostsByCustomer returns ONLY the customer's hosts, ordered by
// host_id, and leaves other customers' hosts out (isolation).
func TestListHostsByCustomer(t *testing.T) {
s := newTestStore(t)
for _, h := range []Host{
{HostID: "c1-bbb", CustomerID: "c1", APIKey: "k2"},
{HostID: "c1-aaa", CustomerID: "c1", APIKey: "k1"},
{HostID: "c2-zzz", CustomerID: "c2", APIKey: "k3"},
} {
h := h
if err := s.UpsertHost(&h); err != nil {
t.Fatal(err)
}
}
got, err := s.ListHostsByCustomer("c1")
if err != nil {
t.Fatalf("ListHostsByCustomer: %v", err)
}
if len(got) != 2 || got[0].HostID != "c1-aaa" || got[1].HostID != "c1-bbb" {
t.Fatalf("c1 hosts = %+v (want [c1-aaa c1-bbb])", got)
}
none, err := s.ListHostsByCustomer("c3")
if err != nil || len(none) != 0 {
t.Fatalf("c3 hosts = %+v / %v (want empty)", none, err)
}
}
func TestSaveHostReport_BumpsRealityPreservesIntent(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
+21
View File
@@ -1597,6 +1597,27 @@ func (s *Store) GetHostByCustomer(customerID string) (*Host, error) {
return h, err
}
// ListHostsByCustomer returns the customer's hosts ordered by host_id (v0.47.0 — the
// customer page's Host tab is a LIST by design: 1 host today, N for a later HA cluster).
// Uses the idx_hosts_customer index.
func (s *Store) ListHostsByCustomer(customerID string) ([]Host, error) {
rows, err := s.db.Query(`SELECT `+hostSelectCols+
` FROM hosts WHERE customer_id = ? ORDER BY host_id`, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []Host
for rows.Next() {
h, err := scanHost(rows.Scan)
if err != nil {
return nil, err
}
hosts = append(hosts, *h)
}
return hosts, rows.Err()
}
// ListHosts returns all hosts (debug / host-domain views).
func (s *Store) ListHosts() ([]Host, error) {
rows, err := s.db.Query(`SELECT ` + hostSelectCols + ` FROM hosts ORDER BY host_id`)
+17
View File
@@ -328,6 +328,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
// ScriptVersion drives the install-command generator's header (GL-7). Display-only.
ScriptVersion string
// Hosts (v0.47.0): the customer's enrolled hosts for the Host tab — a LIST by design
// (1 today, N for a later HA cluster). Each entry is the hostDetailData view-model map
// the shared host_detail_body sub-template renders.
Hosts []map[string]interface{}
}
pendingSet := make(map[string]bool, len(pendingTails))
@@ -335,6 +340,16 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
pendingSet[app] = true
}
// Host tab (v0.47.0): per-host view models via the shared hostDetailData builder.
var hostViews []map[string]interface{}
if hosts, err := s.store.ListHostsByCustomer(customerID); err != nil {
s.logger.Printf("[ERROR] ListHostsByCustomer %s: %v", customerID, err)
} else {
for i := range hosts {
hostViews = append(hostViews, s.hostDetailData(&hosts[i], r))
}
}
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
// either half has landed (host-report and/or controller report).
var hasDR, drHost, drApps bool
@@ -396,6 +411,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
CSRFToken: s.csrfToken(r),
ScriptVersion: hostInstallVersion,
Hosts: hostViews,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+72
View File
@@ -89,6 +89,78 @@ func TestTemplates_CustomerTabs(t *testing.T) {
}
}
// Group B — the Host tab renders the SHARED host_detail_body sub-template: the same body
// the standalone /hosts/{id} page renders (one instance per host; a list by design).
func TestTemplates_CustomerHostTab(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: "host-key-secret"}); 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)
}
html := renderCustomerPage(t, s, "c1")
// The Host tab panel carries the host_detail_body content.
for _, want := range []string{
"demo-felhom-01", // host identity
"Storage Targets", // shared body section
"felhom-usb", // storage target from the report
">9201<", // guest vmid
`href="/hosts/demo-felhom-01"`, // cross-link to the standalone page
} {
if !strings.Contains(html, want) {
t.Errorf("Host tab missing %q", want)
}
}
// The two request-logs forms with a CSRF field each.
if got := strings.Count(html, `action="/hosts/demo-felhom-01/request-logs"`); got != 2 {
t.Errorf("Host tab has %d request-logs forms, want 2", got)
}
if !strings.Contains(html, `name="_csrf"`) {
t.Error("request-logs forms missing the CSRF field")
}
// SECURITY: the host api_key must never reach the customer page either.
if strings.Contains(html, "host-key-secret") {
t.Error("SECRET LEAK: customer page rendered the host api_key")
}
// /hosts/{id} renders the IDENTICAL body (same sub-template) — one shared marker that
// only host_detail_body emits must appear in both renders.
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest("GET", "/hosts/demo-felhom-01", nil), "demo-felhom-01")
hostPage := rr.Body.String()
const marker = "Diagnostics — Log Bundles"
if !strings.Contains(html, marker) || !strings.Contains(hostPage, marker) {
t.Errorf("shared host_detail_body marker %q missing from one of the surfaces", marker)
}
}
func TestTemplates_CustomerHostTab_Empty(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c2", CustomerName: "NoHost", RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "c2")
if !strings.Contains(html, "No host enrolled yet.") {
t.Error("hostless customer must show the Host tab empty state")
}
}
// A customer with a config but no reports keeps the waiting banner ABOVE the tab bar
// (always visible) and shows no Events badge.
func TestTemplates_CustomerTabs_NoReports(t *testing.T) {
+24 -17
View File
@@ -276,22 +276,13 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
}
}
// 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
}
// hostDetailData assembles the view-model map the shared host_detail_body sub-template
// renders — used by BOTH the standalone /hosts/{id} page and the customer page's Host tab
// (v0.47.0). Booleans/counts only for DR/escrow; never api_key or blob contents.
func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]interface{} {
status := s.hostStatus(host.LastReportAt)
guests, _ := s.store.ListGuestsForHost(hostID)
guests, _ := s.store.ListGuestsForHost(host.HostID)
guestRunning := 0
for _, g := range guests {
if g.Status == "running" {
@@ -305,10 +296,10 @@ func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID
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)
drBundle, _ := s.store.GetHostDRBundle(host.HostID)
escrow, _ := s.store.GetHostEscrow(host.HostID)
data := map[string]interface{}{
return map[string]interface{}{
"HostID": host.HostID,
"CustomerID": host.CustomerID,
"CustomerName": s.customerName(host.CustomerID),
@@ -333,6 +324,22 @@ func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID
"LogBundles": s.hostLogBundleRows(host),
"CSRFToken": s.getCSRFToken(r),
}
}
// 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
}
data := s.hostDetailData(host, r)
if err := s.templates.ExecuteTemplate(w, "host_detail.html", data); err != nil {
s.logger.Printf("[ERROR] host_detail.html template: %v", err)
}
@@ -862,14 +862,21 @@
</div>
<!-- ═══ Host ═══ -->
<!-- ═══ Host ═══ (a LIST by design — 1 host today, N for a later HA cluster) -->
<div class="tab-panel" data-tab="host">
{{if .Hosts}}
{{range .Hosts}}
<p style="margin: 0 0 0.5rem;"><a href="/hosts/{{.HostID}}" class="back-link">Open host page: {{.HostID}} &rarr;</a></p>
{{template "host_detail_body" .}}
{{end}}
{{else}}
<section class="card">
<div class="empty-state" style="border: none;">
<p>No host enrolled yet.</p>
<p class="hint">A host appears here once it enrolls via the Day-0 bootstrap.</p>
</div>
</section>
{{end}}
</div>
<footer>
+1 -224
View File
@@ -23,230 +23,7 @@
<a href="/hosts" class="back-link">&larr; 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>
<!-- Diagnostics: component log bundles (v0.46.0) -->
<section class="card">
<h2>Diagnostics — Log Bundles</h2>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
Pull-based: the box ships its debug ring on its own next cycle — controller &le; one report interval (~15 min),
agent &asymp; one heartbeat. The pull is recorded in the box's own log (customer-visible). Bundles expire after 72 h.
</p>
<div style="display: flex; gap: 0.5rem; margin: 0.75rem 0;">
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="controller">
<button type="submit" class="btn btn-sm">Request controller logs</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="agent">
<button type="submit" class="btn btn-sm">Request agent logs</button>
</form>
</div>
{{if .LogBundles}}
<table class="data-table">
<thead>
<tr>
<th>Component</th>
<th>State</th>
<th>Collected</th>
<th>Received</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogBundles}}
<tr>
<td>{{.Component}}</td>
<td>
{{if eq .State "pending"}}<span class="badge badge-neutral" title="Waiting for the box's next cycle (requested {{timeAgo .RequestedAt}})">pending</span>
{{else if eq .State "blocked"}}<span class="badge badge-error" title="{{.BlockedNote}}">blocked: possible secret</span>
{{else}}<span class="badge badge-ok">available</span>{{end}}
</td>
<td>{{if .CollectedAt.IsZero}}—{{else}}{{timeAgo .CollectedAt}}{{end}}</td>
<td>{{if .ReceivedAt.IsZero}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
<td>{{if .SizeBytes}}{{.SizeBytes}} B{{else}}—{{end}}</td>
<td>
{{if eq .State "available"}}
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}" class="btn btn-sm">View</a>
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}?download=1" class="btn btn-sm">Download</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No log bundles. Use the request buttons above — the box delivers on its next cycle.</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>
{{template "host_detail_body" .}}
<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>
@@ -0,0 +1,230 @@
{{/* host_detail_body — the shared per-host detail sections, rendered by BOTH the
standalone /hosts/{id} page (host_detail.html) and the customer page's Host tab
(customer_unified.html, one instance per host). Data = the map built by
hostDetailData (web/hosts.go); never carries api_key/escrow/PBS secret values. */}}
{{define "host_detail_body"}}
<!-- 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>
<!-- Diagnostics: component log bundles (v0.46.0) -->
<section class="card">
<h2>Diagnostics — Log Bundles</h2>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
Pull-based: the box ships its debug ring on its own next cycle — controller &le; one report interval (~15 min),
agent &asymp; one heartbeat. The pull is recorded in the box's own log (customer-visible). Bundles expire after 72 h.
</p>
<div style="display: flex; gap: 0.5rem; margin: 0.75rem 0;">
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="controller">
<button type="submit" class="btn btn-sm">Request controller logs</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="agent">
<button type="submit" class="btn btn-sm">Request agent logs</button>
</form>
</div>
{{if .LogBundles}}
<table class="data-table">
<thead>
<tr>
<th>Component</th>
<th>State</th>
<th>Collected</th>
<th>Received</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogBundles}}
<tr>
<td>{{.Component}}</td>
<td>
{{if eq .State "pending"}}<span class="badge badge-neutral" title="Waiting for the box's next cycle (requested {{timeAgo .RequestedAt}})">pending</span>
{{else if eq .State "blocked"}}<span class="badge badge-error" title="{{.BlockedNote}}">blocked: possible secret</span>
{{else}}<span class="badge badge-ok">available</span>{{end}}
</td>
<td>{{if .CollectedAt.IsZero}}—{{else}}{{timeAgo .CollectedAt}}{{end}}</td>
<td>{{if .ReceivedAt.IsZero}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
<td>{{if .SizeBytes}}{{.SizeBytes}} B{{else}}—{{end}}</td>
<td>
{{if eq .State "available"}}
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}" class="btn btn-sm">View</a>
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}?download=1" class="btn btn-sm">Download</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No log bundles. Use the request buttons above — the box delivers on its next cycle.</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>
{{end}}