From ae950e59332ddb77f0a61f52595e4c4ae13e579d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 11 Jul 2026 21:17:44 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Vvz1NCu22p8dGkRCpeX9re --- hub/internal/store/host_test.go | 27 ++ hub/internal/store/store.go | 21 ++ hub/internal/web/configs.go | 17 ++ hub/internal/web/customer_tabs_test.go | 72 ++++++ hub/internal/web/hosts.go | 41 ++-- .../web/templates/customer_unified.html | 9 +- hub/internal/web/templates/host_detail.html | 225 +---------------- .../web/templates/host_detail_body.html | 230 ++++++++++++++++++ 8 files changed, 400 insertions(+), 242 deletions(-) create mode 100644 hub/internal/web/templates/host_detail_body.html diff --git a/hub/internal/store/host_test.go b/hub/internal/store/host_test.go index 9ff30b1..86fbb7c 100644 --- a/hub/internal/store/host_test.go +++ b/hub/internal/store/host_test.go @@ -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 { diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index ae73c55..f44477d 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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`) diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 9b7221a..b3d3c82 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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") diff --git a/hub/internal/web/customer_tabs_test.go b/hub/internal/web/customer_tabs_test.go index 5057f4b..a742c43 100644 --- a/hub/internal/web/customer_tabs_test.go +++ b/hub/internal/web/customer_tabs_test.go @@ -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) { diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index fdf7e8c..23b5b34 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -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) } diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 47771d0..eb77fcc 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -862,14 +862,21 @@ - +
+ {{if .Hosts}} + {{range .Hosts}} +

Open host page: {{.HostID}} →

+ {{template "host_detail_body" .}} + {{end}} + {{else}}

No host enrolled yet.

A host appears here once it enrolls via the Day-0 bootstrap.

+ {{end}}