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:
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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}} →</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>
|
||||
|
||||
@@ -23,230 +23,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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 ≤ one report interval (~15 min),
|
||||
agent ≈ 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 ≤ one report interval (~15 min),
|
||||
agent ≈ 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}}
|
||||
Reference in New Issue
Block a user