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}} + +
+ {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} + {{.OverallStatus}} + {{if .HasReports}} + Controller {{.Customer.ControllerVersion}} + Last report {{timeAgo .Customer.ReceivedAt}} + Containers {{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}} + {{end}} +
+ + {{if not .HasReports}} + {{if .HasConfig}} + +
+

Waiting for First Report

+

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.

+
+ {{end}} + {{end}} + + + + + +
+
@@ -130,6 +169,41 @@
{{if .HasReports}} + +
+

Health

+ {{if eq .OverallStatus "disabled"}} +

Reporting has been disabled on this node

+

Enable it in the controller's controller.yaml: hub.enabled: true

+ {{else if eq .OverallStatus "blocked"}} +

Customer is blocked

+ {{else}} + {{with .Report.health}} +

+ Status: {{index . "status"}} +

+ {{$issues := index . "issues"}} + {{if $issues}} +

Issues

+ + {{end}} + {{$warnings := index . "warnings"}} + {{if $warnings}} +

Warnings

+ + {{end}} + {{end}} + {{end}} +
+

System

@@ -184,6 +258,38 @@ {{end}}
+ +
+

Backup

+ {{with .Report.backup}} +
+
+ Enabled + {{if index . "enabled"}}Yes{{else}}No{{end}} +
+
+ Snapshots + {{index . "snapshot_count"}} +
+
+ Repo Size + {{index . "repo_size_mb"}} MB +
+
+ Integrity + {{if index . "integrity_ok"}}OK{{else}}Unknown{{end}} +
+
+ {{end}} +
+ {{end}} + +
+ + +
+ + {{if .HasReports}}

Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})

@@ -214,149 +320,91 @@ {{end}}
- + + {{if .HasAppTelemetry}}
-

Backup

- {{with .Report.backup}} -
-
- Enabled - {{if index . "enabled"}}Yes{{else}}No{{end}} -
-
- Snapshots - {{index . "snapshot_count"}} -
-
- Repo Size - {{index . "repo_size_mb"}} MB -
-
- Integrity - {{if index . "integrity_ok"}}OK{{else}}Unknown{{end}} -
-
- {{end}} -
- - - {{if .HasReports}} - {{with .Report.geo_restriction}} -
-

Geo-korlátozás

-
-
- Állapot - - {{if index . "enabled"}} - Aktív - {{else}} - Inaktív - {{end}} - -
- {{if index . "enabled"}} -
- Engedélyezett országok - - {{$countries := index . "allowed_countries"}} - {{if $countries}} - {{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}} - {{else}} - — - {{end}} - -
- {{end}} - {{if index . "last_sync"}} -
- Utolsó szinkron - {{index . "last_sync"}} -
- {{end}} - {{if index . "last_sync_error"}} -
- Szinkron hiba - {{index . "last_sync_error"}} -
- {{end}} -
- {{$overrides := index . "app_overrides"}} - {{if $overrides}} -

Alkalmazás felülírások

- - +

App Telemetry (last 7 days)

+
AlkalmazásEngedélyezett országok
+ + + + + + + + + + + + - {{range $app, $override := $overrides}} - - - - - {{end}} + {{range .AppTelemetry}} + + + + + + + + + + + {{end}}
AppMemory (current)Memory (avg 7d)Memory (peak 7d)Catalog LimitErrorsWarningsLogs
{{$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}} +
- {{end}} - {{if index . "enabled"}} -
- - -
- {{end}}
{{end}} - {{end}} - - + + {{if .HasLogTails}}
-

Health

- {{if eq .OverallStatus "disabled"}} -

Reporting has been disabled on this node

-

Enable it in the controller's controller.yaml: hub.enabled: true

- {{else if eq .OverallStatus "blocked"}} -

Customer is blocked

- {{else}} - {{with .Report.health}} -

- Status: {{index . "status"}} -

- {{$issues := index . "issues"}} - {{if $issues}} -

Issues

- - {{end}} - {{$warnings := index . "warnings"}} - {{if $warnings}} -

Warnings

- - {{end}} - {{end}} - {{end}} +

App Log Tails (on-demand, last 2 per app kept)

+ + + + + + + + + + + + {{range .LogTails}} + + + + + + + + {{end}} + +
AppCollectedReceivedLines
{{.AppName}}{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}}){{timeAgo .ReceivedAt}}{{len .Lines}} + View + Download .log +
- + {{end}} {{else}} - - {{if .HasConfig}} -
-

Waiting for First Report

-

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.

-
- {{end}} +

Container and app data appear once the first report arrives.

{{end}} - +
+ + +
+ {{if .HasConfig}}

Credentials

@@ -485,8 +533,15 @@

Loading preview...

+ {{else}} +

No managed config yet — create one from the Overview tab to get setup commands.

{{end}} + + + +
+ {{if .HasReports}}
@@ -542,11 +597,129 @@

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.

+ + {{with .Report.geo_restriction}} +
+

Geo-korlátozás

+
+
+ Állapot + + {{if index . "enabled"}} + Aktív + {{else}} + Inaktív + {{end}} + +
+ {{if index . "enabled"}} +
+ Engedélyezett országok + + {{$countries := index . "allowed_countries"}} + {{if $countries}} + {{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}} + {{else}} + — + {{end}} + +
+ {{end}} + {{if index . "last_sync"}} +
+ Utolsó szinkron + {{index . "last_sync"}} +
+ {{end}} + {{if index . "last_sync_error"}} +
+ Szinkron hiba + {{index . "last_sync_error"}} +
+ {{end}} +
+ {{$overrides := index . "app_overrides"}} + {{if $overrides}} +

Alkalmazás felülírások

+ + + + {{range $app, $override := $overrides}} + + + + + {{end}} + +
AlkalmazásEngedélyezett országok
{{$app}} + {{$ac := index $override "allowed_countries"}} + {{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}} +
+ {{end}} + {{if index . "enabled"}} +
+ + +
+ {{end}} +
+ {{end}} + {{else}} +

Controller update and geo-restriction settings appear once the first report arrives.

+ {{end}} + +
+ + +
+ + {{if .HasReports}} + {{if .HasDRRecipe}} + +
+

DR Recipe (secret-free reconstruction plan)

+

+ 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. +

+
+
+ Storage / guest / PBS half (agent) + {{if .DRRecipeHasHost}}present{{else}}awaiting host-report{{end}} +
+
+ Customer / apps half (controller) + {{if .DRRecipeHasApps}}present{{else}}awaiting controller report{{end}} +
+
+ Last updated + {{if .DRRecipeUpdatedAt}}{{.DRRecipeUpdatedAt}}{{else}}—{{end}} +
+
+
+ Download recipe (JSON) +
+
+ {{else}} +

No DR recipe yet — it assembles from the host-report and controller report.

+ {{end}} + {{else}} +

Backup and DR data appear once the first report arrives.

+ {{end}} + +
+ + +
+ + {{if .HasReports}}

Events @@ -603,113 +776,45 @@ {{end}}

- - {{if .HasAppTelemetry}} + + {{if .History}}
-

App Telemetry (last 7 days)

- - - - - - - - - - - - - - - {{range .AppTelemetry}} - - - - - - - - - - - {{end}} - -
AppMemory (current)Memory (avg 7d)Memory (peak 7d)Catalog LimitErrorsWarningsLogs
{{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}} -
+

Report History (last 24h)

+
+ {{len .History}} reports + + + + + + + + + + + {{range .History}} + + + + + + + {{end}} + +
TimeStatusCPUMemory
{{.ReceivedAt.Format "Jan 02 15:04"}}{{.HealthStatus}}{{formatFloat .CPUPercent}}%{{formatFloat .MemoryPercent}}%
+
{{end}} - - - {{if .HasLogTails}} -
-

App Log Tails (on-demand, last 2 per app kept)

- - - - - - - - - - - - {{range .LogTails}} - - - - - - - - {{end}} - -
AppCollectedReceivedLines
{{.AppName}}{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}}){{timeAgo .ReceivedAt}}{{len .Lines}} - View - Download .log -
-
+ {{else}} +

Events appear once the first report arrives.

{{end}} - {{if .HasDRRecipe}} - -
-

DR Recipe (secret-free reconstruction plan)

-

- 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. -

-
-
- Storage / guest / PBS half (agent) - {{if .DRRecipeHasHost}}present{{else}}awaiting host-report{{end}} -
-
- Customer / apps half (controller) - {{if .DRRecipeHasApps}}present{{else}}awaiting controller report{{end}} -
-
- Last updated - {{if .DRRecipeUpdatedAt}}{{.DRRecipeUpdatedAt}}{{else}}—{{end}} -
-
-
- Download recipe (JSON) -
-
- {{end}} +
+ +
+ + {{if .HasReports}}

Notifications

@@ -751,37 +856,21 @@ {{end}}
+ {{else}} +

Notification data appears once the first report arrives.

+ {{end}} - - {{if .History}} +
+ + +
-

Report History (last 24h)

-
- {{len .History}} reports - - - - - - - - - - - {{range .History}} - - - - - - - {{end}} - -
TimeStatusCPUMemory
{{.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.

+
- {{end}} - {{end}} +