diff --git a/hub/internal/web/customer_tabs_test.go b/hub/internal/web/customer_tabs_test.go new file mode 100644 index 0000000..5057f4b --- /dev/null +++ b/hub/internal/web/customer_tabs_test.go @@ -0,0 +1,117 @@ +package web + +// Group A (hub v0.47.0 UI reorganization) — the customer page's client-side hash tabs. +// Graceful degradation is load-bearing: panels are hidden ONLY by a JS-added body class, +// so the no-JS render (what these tests see) must still contain EVERY section's markup. + +import ( + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// A controller-report body exercising every Overview/Applications section. +const tabsTestReportJSON = `{ + "health": {"status": "ok", "warnings": ["disk filling"]}, + "system": {"hostname": "felhom-box", "os": "Debian 12", "kernel": "6.8", "cpu_model": "N100", "cpu_cores": 4}, + "storage": [{"mount": "/", "percent": 41.0, "used_gb": 12.3, "total_gb": 30.0}], + "containers": {"running": 3, "total": 4, "list": [{"name": "vaultwarden", "state": "running", "cpu_percent": 1.2, "memory_mb": 90}]}, + "backup": {"enabled": true, "snapshot_count": 7, "repo_size_mb": 1200, "integrity_ok": true}, + "geo_restriction": {"enabled": true, "allowed_countries": ["HU"]} +}` + +func renderCustomerPage(t *testing.T, s *Server, customerID string) string { + t.Helper() + rr := httptest.NewRecorder() + s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/"+customerID, nil), customerID) + if rr.Code != 200 { + t.Fatalf("customer page status = %d", rr.Code) + } + return rr.Body.String() +} + +func TestTemplates_CustomerTabs(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + }); err != nil { + t.Fatal(err) + } + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + // One error event → the Events tab label carries the red count badge. + if _, err := st.SaveEvent("acme", "app_crash_loop", "error", "vaultwarden restarting", "{}", "controller"); err != nil { + t.Fatal(err) + } + + html := renderCustomerPage(t, s, "acme") + + // The tab nav renders all 8 tabs (hash links — plain anchors without JS). + for _, tab := range []string{ + "overview", "applications", "setup", "settings", "backup", "events", "notifications", "host", + } { + if !strings.Contains(html, `href="#tab=`+tab+`"`) { + t.Errorf("tab nav missing tab %q", tab) + } + } + + // No-JS completeness: every section's markup is still in the body (hiding is done by a + // JS-added body class — the server render carries everything). + for _, want := range []string{ + "Customer Info", "Health", "System", "Storage", "Backup", + "Containers (3/4)", "vaultwarden", + "Credentials", "Setup Command", "YAML Preview", + "Controller Update", "Geo-korlátozás", + "Events", "Notifications", "Report History", + } { + if !strings.Contains(html, want) { + t.Errorf("no-JS body missing section content %q", want) + } + } + + // Sticky summary strip with the always-visible vitals. + if !strings.Contains(html, `class="summary-strip"`) { + t.Error("summary strip missing") + } + + // Events tab badge: 1 error event → a red count on the tab label. + if !strings.Contains(html, `1`) { + t.Error("Events tab badge missing despite 1 error event") + } + + // Panels are hidden only via the js-tabs body class — no inline hiding in the markup. + if strings.Contains(html, `tab-panel" data-tab="events" style=`) { + t.Error("tab panel carries inline style — hiding must be class-driven") + } +} + +// 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) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "fresh", CustomerName: "Fresh", RetrievalPassword: "pw", APIKey: "k", Status: "active", + }); err != nil { + t.Fatal(err) + } + html := renderCustomerPage(t, s, "fresh") + + banner := strings.Index(html, "Waiting for First Report") + nav := strings.Index(html, `id="tab-nav"`) + if banner < 0 { + t.Fatal("waiting banner missing") + } + if nav < 0 { + t.Fatal("tab nav missing") + } + if banner > nav { + t.Error("waiting banner renders BELOW the tab bar — must stay above (always visible)") + } + if strings.Contains(html, `class="tab-badge"`) { + t.Error("Events badge rendered with no events") + } +} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index c1b48c4..47771d0 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -60,6 +60,45 @@ {{end}} + +
{{.Customer.ControllerVersion}}
+ Last report {{timeAgo .Customer.ReceivedAt}}
+ Containers {{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}}
+ {{end}}
+ This customer has been configured but no controller report has been received yet.
+Use one of the setup commands below to deploy the controller on the customer node.
+Reporting has been disabled on this node
+Enable it in the controller's controller.yaml: hub.enabled: true
Customer is blocked
+ {{else}} + {{with .Report.health}} ++ Status: {{index . "status"}} +
+ {{$issues := index . "issues"}} + {{if $issues}} +| Alkalmazás | Engedélyezett országok |
|---|
| App | +Memory (current) | +Memory (avg 7d) | +Memory (peak 7d) | +Catalog Limit | +Errors | +Warnings | +Logs | +
|---|---|---|---|---|---|---|---|
| {{$app}} | -- {{$ac := index $override "allowed_countries"}} - {{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}} - | -||||||
| {{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}} | +{{formatFloat .MemoryCurrentMB}} MB | +{{formatFloat .MemoryAvgMB}} MB | +{{formatFloat .MemoryPeakMB}} MB | +{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}} | +{{if gt .LogErrors 0}}{{.LogErrors}}{{else}}0{{end}} | +{{if gt .LogWarnings 0}}{{.LogWarnings}}{{else}}0{{end}} | ++ {{if index $.PendingTails .AppName}} + tail pending + {{else}} + + {{end}} + | +
Reporting has been disabled on this node
-Enable it in the controller's controller.yaml: hub.enabled: true
Customer is blocked
- {{else}} - {{with .Report.health}} -- Status: {{index . "status"}} -
- {{$issues := index . "issues"}} - {{if $issues}} -| App | +Collected | +Received | +Lines | ++ |
|---|---|---|---|---|
| {{.AppName}} | +{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}}) | +{{timeAgo .ReceivedAt}} | +{{len .Lines}} | ++ View + Download .log + | +
This customer has been configured but no controller report has been received yet.
-Use one of the setup commands below to deploy the controller on the customer node.
-Container and app data appear once the first report arrives.
Loading preview...
No managed config yet — create one from the Overview tab to get setup commands.
Controller updates are agent-driven (the version floor above) and config is delivered by the box pulling it on a config change — the hub never connects into the box. Edit the config via - the Edit button (top of page); the controller re-pulls and restarts on its + the Edit button (Overview tab); the controller re-pulls and restarts on its next report.
| Alkalmazás | Engedélyezett országok |
|---|---|
| {{$app}} | ++ {{$ac := index $override "allowed_countries"}} + {{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}} + | +
Controller update and geo-restriction settings appear once the first report arrives.
+ The non-secret re-provision plan — guest sizing, drive inventory (durable-id → role → mount → intent), + PVE storage defs, PBS coordinates, and app inventory + storage bindings. It complements escrow (keys) + and PBS/restic (bytes): it contains no key, password, or token. Use it to rebuild the + host/guest/storage scaffolding before the PBS bytes land. +
+No DR recipe yet — it assembles from the host-report and controller report.
Backup and DR data appear once the first report arrives.
| App | -Memory (current) | -Memory (avg 7d) | -Memory (peak 7d) | -Catalog Limit | -Errors | -Warnings | -Logs | -
|---|---|---|---|---|---|---|---|
| {{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}} | -{{formatFloat .MemoryCurrentMB}} MB | -{{formatFloat .MemoryAvgMB}} MB | -{{formatFloat .MemoryPeakMB}} MB | -{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}} | -{{if gt .LogErrors 0}}{{.LogErrors}}{{else}}0{{end}} | -{{if gt .LogWarnings 0}}{{.LogWarnings}}{{else}}0{{end}} | -- {{if index $.PendingTails .AppName}} - tail pending - {{else}} - - {{end}} - | -
| Time | +Status | +CPU | +Memory | +
|---|---|---|---|
| {{.ReceivedAt.Format "Jan 02 15:04"}} | +{{.HealthStatus}} | +{{formatFloat .CPUPercent}}% | +{{formatFloat .MemoryPercent}}% | +
| App | -Collected | -Received | -Lines | -- |
|---|---|---|---|---|
| {{.AppName}} | -{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}}) | -{{timeAgo .ReceivedAt}} | -{{len .Lines}} | -- View - Download .log - | -
Events appear once the first report arrives.
- The non-secret re-provision plan — guest sizing, drive inventory (durable-id → role → mount → intent), - PVE storage defs, PBS coordinates, and app inventory + storage bindings. It complements escrow (keys) - and PBS/restic (bytes): it contains no key, password, or token. Use it to rebuild the - host/guest/storage scaffolding before the PBS bytes land. -
-Notification data appears once the first report arrives.
| Time | -Status | -CPU | -Memory | -
|---|---|---|---|
| {{.ReceivedAt.Format "Jan 02 15:04"}} | -{{.HealthStatus}} | -{{formatFloat .CPUPercent}}% | -{{formatFloat .MemoryPercent}}% | -
No host enrolled yet.
+A host appears here once it enrolls via the Day-0 bootstrap.
+