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
+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) {