hub: fix .data-table btn contrast + customer page hash tabs (v0.47.0 part 1-2)

- style.css: .data-table td a -> :not(.btn) so <a class=btn> keeps the .btn
  palette (View/Download buttons were blue-on-blue invisible)
- customer_unified.html: 8 client-side hash tabs (#tab=...) + sticky summary
  strip; all sections preserved in DOM, hiding is a JS-added body class only
  (no-JS = everything visible); Events tab gets a red error-count badge
- new render tests: TestTemplates_CustomerTabs (+_NoReports)

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:13:23 +02:00
parent 8e1a3f0d82
commit 9f29bf34c0
3 changed files with 556 additions and 263 deletions
+117
View File
@@ -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, `<span class="tab-badge">1</span>`) {
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")
}
}
+376 -261
View File
@@ -60,6 +60,45 @@
</div>
{{end}}
<!-- Sticky summary strip: identity + liveness at a glance while any tab is scrolled.
Values mirror fields already rendered in the panels — no extra handler data. -->
<div class="summary-strip">
<span class="strip-name">{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</span>
<span class="strip-item"><span class="status-dot status-dot-{{statusColor .OverallStatus}}"></span> {{.OverallStatus}}</span>
{{if .HasReports}}
<span class="strip-item">Controller <code>{{.Customer.ControllerVersion}}</code></span>
<span class="strip-item">Last report {{timeAgo .Customer.ReceivedAt}}</span>
<span class="strip-item">Containers {{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}}</span>
{{end}}
</div>
{{if not .HasReports}}
{{if .HasConfig}}
<!-- Always above the tab bar — visible whichever tab is active. -->
<section class="card">
<h2>Waiting for First Report</h2>
<p class="text-muted">This customer has been configured but no controller report has been received yet.</p>
<p class="text-muted" style="margin-top: 0.5rem;">Use one of the setup commands below to deploy the controller on the customer node.</p>
</section>
{{end}}
{{end}}
<!-- Tab nav: hash-based (#tab=<name>); with JS off it is plain anchors and every panel
below stays visible. -->
<nav class="tab-nav" id="tab-nav">
<a href="#tab=overview" data-tab="overview" class="active">Overview</a>
<a href="#tab=applications" data-tab="applications">Applications</a>
<a href="#tab=setup" data-tab="setup">Setup</a>
<a href="#tab=settings" data-tab="settings">Settings</a>
<a href="#tab=backup" data-tab="backup">Backup &amp; DR</a>
<a href="#tab=events" data-tab="events">Events{{with mapGet .EventCounts "error"}}<span class="tab-badge">{{.}}</span>{{end}}</a>
<a href="#tab=notifications" data-tab="notifications">Notifications</a>
<a href="#tab=host" data-tab="host">Host</a>
</nav>
<!-- ═══ Overview ═══ -->
<div class="tab-panel tab-panel-active" data-tab="overview">
<!-- Customer Info -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
@@ -130,6 +169,41 @@
</section>
{{if .HasReports}}
<!-- Health -->
<section class="card">
<h2>Health</h2>
{{if eq .OverallStatus "disabled"}}
<p class="health-status health-status-disabled">Reporting has been disabled on this node</p>
<p class="hint">Enable it in the controller's <code>controller.yaml</code>: <code>hub.enabled: true</code></p>
{{else if eq .OverallStatus "blocked"}}
<p class="health-status health-status-disabled">Customer is blocked</p>
{{else}}
{{with .Report.health}}
<p class="health-status health-status-{{index . "status"}}">
Status: {{index . "status"}}
</p>
{{$issues := index . "issues"}}
{{if $issues}}
<h3>Issues</h3>
<ul class="issue-list">
{{range $issues}}
<li class="issue">{{.}}</li>
{{end}}
</ul>
{{end}}
{{$warnings := index . "warnings"}}
{{if $warnings}}
<h3>Warnings</h3>
<ul class="warning-list">
{{range $warnings}}
<li class="warning">{{.}}</li>
{{end}}
</ul>
{{end}}
{{end}}
{{end}}
</section>
<!-- System Info -->
<section class="card">
<h2>System</h2>
@@ -184,6 +258,38 @@
{{end}}
</section>
<!-- Backup -->
<section class="card">
<h2>Backup</h2>
{{with .Report.backup}}
<div class="info-grid">
<div class="info-item">
<span class="label">Enabled</span>
<span class="value">{{if index . "enabled"}}Yes{{else}}No{{end}}</span>
</div>
<div class="info-item">
<span class="label">Snapshots</span>
<span class="value">{{index . "snapshot_count"}}</span>
</div>
<div class="info-item">
<span class="label">Repo Size</span>
<span class="value">{{index . "repo_size_mb"}} MB</span>
</div>
<div class="info-item">
<span class="label">Integrity</span>
<span class="value">{{if index . "integrity_ok"}}OK{{else}}Unknown{{end}}</span>
</div>
</div>
{{end}}
</section>
{{end}}
</div>
<!-- ═══ Applications ═══ -->
<div class="tab-panel" data-tab="applications">
{{if .HasReports}}
<!-- Containers -->
<section class="card">
<h2>Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})</h2>
@@ -214,149 +320,91 @@
{{end}}
</section>
<!-- Backup -->
<!-- App telemetry -->
{{if .HasAppTelemetry}}
<section class="card">
<h2>Backup</h2>
{{with .Report.backup}}
<div class="info-grid">
<div class="info-item">
<span class="label">Enabled</span>
<span class="value">{{if index . "enabled"}}Yes{{else}}No{{end}}</span>
</div>
<div class="info-item">
<span class="label">Snapshots</span>
<span class="value">{{index . "snapshot_count"}}</span>
</div>
<div class="info-item">
<span class="label">Repo Size</span>
<span class="value">{{index . "repo_size_mb"}} MB</span>
</div>
<div class="info-item">
<span class="label">Integrity</span>
<span class="value">{{if index . "integrity_ok"}}OK{{else}}Unknown{{end}}</span>
</div>
</div>
{{end}}
</section>
<!-- Geo-restriction -->
{{if .HasReports}}
{{with .Report.geo_restriction}}
<section class="card">
<h2>Geo-korlátozás</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Állapot</span>
<span class="value">
{{if index . "enabled"}}
<span class="severity-badge severity-critical">Aktív</span>
{{else}}
<span class="severity-badge severity-ok">Inaktív</span>
{{end}}
</span>
</div>
{{if index . "enabled"}}
<div class="info-item">
<span class="label">Engedélyezett országok</span>
<span class="value">
{{$countries := index . "allowed_countries"}}
{{if $countries}}
{{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}}
{{else}}
{{end}}
</span>
</div>
{{end}}
{{if index . "last_sync"}}
<div class="info-item">
<span class="label">Utolsó szinkron</span>
<span class="value">{{index . "last_sync"}}</span>
</div>
{{end}}
{{if index . "last_sync_error"}}
<div class="info-item">
<span class="label">Szinkron hiba</span>
<span class="value" style="color: var(--crit)">{{index . "last_sync_error"}}</span>
</div>
{{end}}
</div>
{{$overrides := index . "app_overrides"}}
{{if $overrides}}
<h3 style="margin-top: 1rem; font-size: 0.95rem;">Alkalmazás felülírások</h3>
<table class="data-table" style="margin-top: 0.5rem;">
<thead><tr><th>Alkalmazás</th><th>Engedélyezett országok</th></tr></thead>
<h2>App Telemetry <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(last 7 days)</span></h2>
<table class="data-table">
<thead>
<tr>
<th>App</th>
<th>Memory (current)</th>
<th>Memory (avg 7d)</th>
<th>Memory (peak 7d)</th>
<th>Catalog Limit</th>
<th>Errors</th>
<th>Warnings</th>
<th>Logs</th>
</tr>
</thead>
<tbody>
{{range $app, $override := $overrides}}
<tr>
<td>{{$app}}</td>
<td>
{{$ac := index $override "allowed_countries"}}
{{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}}
</td>
</tr>
{{end}}
{{range .AppTelemetry}}
<tr>
<td><a href="/apps/{{.AppName}}?customer={{$.CustomerID}}" title="Known issues filtered to this customer">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
<td>{{formatFloat .MemoryAvgMB}} MB</td>
<td>{{formatFloat .MemoryPeakMB}} MB</td>
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
<td>
{{if index $.PendingTails .AppName}}
<span class="badge badge-neutral" title="The controller delivers the tail on its next report cycle">tail pending</span>
{{else}}
<form method="POST" action="/customers/{{$.CustomerID}}/request-log-tail" style="display: inline;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="app" value="{{.AppName}}">
<button type="submit" class="btn btn-sm" title="Pull-based: the controller ships the last 200 log lines on its next report; a customer-visible event line is recorded">Request log tail</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if index . "enabled"}}
<div style="margin-top: 1rem;">
<button class="btn btn-danger" id="btn-geo-disable" onclick="disableGeo('{{$.Customer.CustomerID}}')">Összes geo-korlátozás eltávolítása</button>
<span id="geo-msg" style="display:none; margin-left: 0.75rem;"></span>
</div>
{{end}}
</section>
{{end}}
{{end}}
<!-- Health -->
<!-- Received log tails (on-demand, transient — last 2 per app) -->
{{if .HasLogTails}}
<section class="card">
<h2>Health</h2>
{{if eq .OverallStatus "disabled"}}
<p class="health-status health-status-disabled">Reporting has been disabled on this node</p>
<p class="hint">Enable it in the controller's <code>controller.yaml</code>: <code>hub.enabled: true</code></p>
{{else if eq .OverallStatus "blocked"}}
<p class="health-status health-status-disabled">Customer is blocked</p>
{{else}}
{{with .Report.health}}
<p class="health-status health-status-{{index . "status"}}">
Status: {{index . "status"}}
</p>
{{$issues := index . "issues"}}
{{if $issues}}
<h3>Issues</h3>
<ul class="issue-list">
{{range $issues}}
<li class="issue">{{.}}</li>
{{end}}
</ul>
{{end}}
{{$warnings := index . "warnings"}}
{{if $warnings}}
<h3>Warnings</h3>
<ul class="warning-list">
{{range $warnings}}
<li class="warning">{{.}}</li>
{{end}}
</ul>
{{end}}
{{end}}
{{end}}
<h2>App Log Tails <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(on-demand, last 2 per app kept)</span></h2>
<table class="data-table">
<thead>
<tr>
<th>App</th>
<th>Collected</th>
<th>Received</th>
<th>Lines</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogTails}}
<tr>
<td style="font-family: var(--font-mono);">{{.AppName}}</td>
<td>{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}})</td>
<td>{{timeAgo .ReceivedAt}}</td>
<td>{{len .Lines}}</td>
<td style="white-space: nowrap;">
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}" class="btn btn-sm">View</a>
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}?download=1" class="btn btn-sm">Download .log</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{else}}
<!-- No reports yet -->
{{if .HasConfig}}
<section class="card">
<h2>Waiting for First Report</h2>
<p class="text-muted">This customer has been configured but no controller report has been received yet.</p>
<p class="text-muted" style="margin-top: 0.5rem;">Use one of the setup commands below to deploy the controller on the customer node.</p>
</section>
{{end}}
<section class="card"><p class="text-muted">Container and app data appear once the first report arrives.</p></section>
{{end}}
<!-- Config Management -->
</div>
<!-- ═══ Setup ═══ -->
<div class="tab-panel" data-tab="setup">
{{if .HasConfig}}
<section class="card">
<h2>Credentials</h2>
@@ -485,8 +533,15 @@
<p class="text-muted">Loading preview...</p>
</div>
</section>
{{else}}
<section class="card"><p class="text-muted">No managed config yet — create one from the Overview tab to get setup commands.</p></section>
{{end}}
</div>
<!-- ═══ Settings ═══ -->
<div class="tab-panel" data-tab="settings">
{{if .HasReports}}
<!-- Controller Update -->
<section class="card">
@@ -542,11 +597,129 @@
<p class="text-muted" style="margin-top: 0.75em; font-size: 0.8em;">
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 <strong>Edit</strong> button (top of page); the controller re-pulls and restarts on its
the <strong>Edit</strong> button (Overview tab); the controller re-pulls and restarts on its
next report.
</p>
</section>
<!-- Geo-restriction -->
{{with .Report.geo_restriction}}
<section class="card">
<h2>Geo-korlátozás</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Állapot</span>
<span class="value">
{{if index . "enabled"}}
<span class="severity-badge severity-critical">Aktív</span>
{{else}}
<span class="severity-badge severity-ok">Inaktív</span>
{{end}}
</span>
</div>
{{if index . "enabled"}}
<div class="info-item">
<span class="label">Engedélyezett országok</span>
<span class="value">
{{$countries := index . "allowed_countries"}}
{{if $countries}}
{{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}}
{{else}}
{{end}}
</span>
</div>
{{end}}
{{if index . "last_sync"}}
<div class="info-item">
<span class="label">Utolsó szinkron</span>
<span class="value">{{index . "last_sync"}}</span>
</div>
{{end}}
{{if index . "last_sync_error"}}
<div class="info-item">
<span class="label">Szinkron hiba</span>
<span class="value" style="color: var(--crit)">{{index . "last_sync_error"}}</span>
</div>
{{end}}
</div>
{{$overrides := index . "app_overrides"}}
{{if $overrides}}
<h3 style="margin-top: 1rem; font-size: 0.95rem;">Alkalmazás felülírások</h3>
<table class="data-table" style="margin-top: 0.5rem;">
<thead><tr><th>Alkalmazás</th><th>Engedélyezett országok</th></tr></thead>
<tbody>
{{range $app, $override := $overrides}}
<tr>
<td>{{$app}}</td>
<td>
{{$ac := index $override "allowed_countries"}}
{{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if index . "enabled"}}
<div style="margin-top: 1rem;">
<button class="btn btn-danger" id="btn-geo-disable" onclick="disableGeo('{{$.Customer.CustomerID}}')">Összes geo-korlátozás eltávolítása</button>
<span id="geo-msg" style="display:none; margin-left: 0.75rem;"></span>
</div>
{{end}}
</section>
{{end}}
{{else}}
<section class="card"><p class="text-muted">Controller update and geo-restriction settings appear once the first report arrives.</p></section>
{{end}}
</div>
<!-- ═══ Backup & DR ═══ -->
<div class="tab-panel" data-tab="backup">
{{if .HasReports}}
{{if .HasDRRecipe}}
<!-- DR recipe (secret-free reconstruction recipe) -->
<section class="card">
<h2>DR Recipe <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(secret-free reconstruction plan)</span></h2>
<p class="text-muted" style="margin-top: 0;">
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): <strong>it contains no key, password, or token</strong>. Use it to rebuild the
host/guest/storage scaffolding before the PBS bytes land.
</p>
<div class="info-grid">
<div class="info-item">
<span class="label">Storage / guest / PBS half (agent)</span>
<span class="value">{{if .DRRecipeHasHost}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting host-report</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Customer / apps half (controller)</span>
<span class="value">{{if .DRRecipeHasApps}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting controller report</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last updated</span>
<span class="value">{{if .DRRecipeUpdatedAt}}{{.DRRecipeUpdatedAt}}{{else}}—{{end}}</span>
</div>
</div>
<div style="margin-top: 1rem;">
<a href="/customers/{{.CustomerID}}/dr-recipe.json" class="btn" download>Download recipe (JSON)</a>
</div>
</section>
{{else}}
<section class="card"><p class="text-muted">No DR recipe yet — it assembles from the host-report and controller report.</p></section>
{{end}}
{{else}}
<section class="card"><p class="text-muted">Backup and DR data appear once the first report arrives.</p></section>
{{end}}
</div>
<!-- ═══ Events ═══ -->
<div class="tab-panel" data-tab="events">
{{if .HasReports}}
<!-- Events -->
<section class="card">
<h2>Events
@@ -603,113 +776,45 @@
{{end}}
</section>
<!-- App telemetry -->
{{if .HasAppTelemetry}}
<!-- Report History -->
{{if .History}}
<section class="card">
<h2>App Telemetry <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(last 7 days)</span></h2>
<table class="data-table">
<thead>
<tr>
<th>App</th>
<th>Memory (current)</th>
<th>Memory (avg 7d)</th>
<th>Memory (peak 7d)</th>
<th>Catalog Limit</th>
<th>Errors</th>
<th>Warnings</th>
<th>Logs</th>
</tr>
</thead>
<tbody>
{{range .AppTelemetry}}
<tr>
<td><a href="/apps/{{.AppName}}?customer={{$.CustomerID}}" title="Known issues filtered to this customer">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
<td>{{formatFloat .MemoryAvgMB}} MB</td>
<td>{{formatFloat .MemoryPeakMB}} MB</td>
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
<td>
{{if index $.PendingTails .AppName}}
<span class="badge badge-neutral" title="The controller delivers the tail on its next report cycle">tail pending</span>
{{else}}
<form method="POST" action="/customers/{{$.CustomerID}}/request-log-tail" style="display: inline;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="app" value="{{.AppName}}">
<button type="submit" class="btn btn-sm" title="Pull-based: the controller ships the last 200 log lines on its next report; a customer-visible event line is recorded">Request log tail</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<h2>Report History (last 24h)</h2>
<details>
<summary>{{len .History}} reports</summary>
<table class="history-table">
<thead>
<tr>
<th>Time</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
{{range .History}}
<tr>
<td>{{.ReceivedAt.Format "Jan 02 15:04"}}</td>
<td><span class="status-badge status-badge-{{.HealthStatus}}">{{.HealthStatus}}</span></td>
<td>{{formatFloat .CPUPercent}}%</td>
<td>{{formatFloat .MemoryPercent}}%</td>
</tr>
{{end}}
</tbody>
</table>
</details>
</section>
{{end}}
<!-- Received log tails (on-demand, transient — last 2 per app) -->
{{if .HasLogTails}}
<section class="card">
<h2>App Log Tails <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(on-demand, last 2 per app kept)</span></h2>
<table class="data-table">
<thead>
<tr>
<th>App</th>
<th>Collected</th>
<th>Received</th>
<th>Lines</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogTails}}
<tr>
<td style="font-family: var(--font-mono);">{{.AppName}}</td>
<td>{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}})</td>
<td>{{timeAgo .ReceivedAt}}</td>
<td>{{len .Lines}}</td>
<td style="white-space: nowrap;">
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}" class="btn btn-sm">View</a>
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}?download=1" class="btn btn-sm">Download .log</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{else}}
<section class="card"><p class="text-muted">Events appear once the first report arrives.</p></section>
{{end}}
{{if .HasDRRecipe}}
<!-- DR recipe (secret-free reconstruction recipe) -->
<section class="card">
<h2>DR Recipe <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(secret-free reconstruction plan)</span></h2>
<p class="text-muted" style="margin-top: 0;">
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): <strong>it contains no key, password, or token</strong>. Use it to rebuild the
host/guest/storage scaffolding before the PBS bytes land.
</p>
<div class="info-grid">
<div class="info-item">
<span class="label">Storage / guest / PBS half (agent)</span>
<span class="value">{{if .DRRecipeHasHost}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting host-report</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Customer / apps half (controller)</span>
<span class="value">{{if .DRRecipeHasApps}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting controller report</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last updated</span>
<span class="value">{{if .DRRecipeUpdatedAt}}{{.DRRecipeUpdatedAt}}{{else}}—{{end}}</span>
</div>
</div>
<div style="margin-top: 1rem;">
<a href="/customers/{{.CustomerID}}/dr-recipe.json" class="btn" download>Download recipe (JSON)</a>
</div>
</section>
{{end}}
</div>
<!-- ═══ Notifications ═══ -->
<div class="tab-panel" data-tab="notifications">
{{if .HasReports}}
<!-- Notifications -->
<section class="card">
<h2>Notifications</h2>
@@ -751,37 +856,21 @@
</table>
{{end}}
</section>
{{else}}
<section class="card"><p class="text-muted">Notification data appears once the first report arrives.</p></section>
{{end}}
<!-- Report History -->
{{if .History}}
</div>
<!-- ═══ Host ═══ -->
<div class="tab-panel" data-tab="host">
<section class="card">
<h2>Report History (last 24h)</h2>
<details>
<summary>{{len .History}} reports</summary>
<table class="history-table">
<thead>
<tr>
<th>Time</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
{{range .History}}
<tr>
<td>{{.ReceivedAt.Format "Jan 02 15:04"}}</td>
<td><span class="status-badge status-badge-{{.HealthStatus}}">{{.HealthStatus}}</span></td>
<td>{{formatFloat .CPUPercent}}%</td>
<td>{{formatFloat .MemoryPercent}}%</td>
</tr>
{{end}}
</tbody>
</table>
</details>
<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}}
{{end}}
</div>
<footer>
{{if .HasReports}}<p>Auto-refreshes every 60 seconds &middot; {{end}}<a href="/">Felhom Hub</a> {{hubVersion}}{{if .HasReports}}</p>{{end}}
@@ -939,6 +1028,32 @@
{{end}}
</script>
<script>
// Client-side hash tabs (v0.47.0). Without JS this never runs — body never gets
// .js-tabs, so every panel stays visible and the page reads top-to-bottom.
// The 60s auto-refresh (location.reload) preserves the hash, so the active tab
// survives a refresh; an unknown/absent hash falls back to Overview.
(function() {
var panels = document.querySelectorAll('.tab-panel');
var links = document.querySelectorAll('#tab-nav a');
if (!panels.length || !links.length) return;
document.body.classList.add('js-tabs');
var known = {};
panels.forEach(function(p) { known[p.getAttribute('data-tab')] = true; });
function currentTab() {
var m = (location.hash || '').match(/^#tab=([a-z-]+)$/);
return (m && known[m[1]]) ? m[1] : 'overview';
}
function activate() {
var tab = currentTab();
panels.forEach(function(p) { p.classList.toggle('tab-panel-active', p.getAttribute('data-tab') === tab); });
links.forEach(function(a) { a.classList.toggle('active', a.getAttribute('data-tab') === tab); });
}
window.addEventListener('hashchange', activate);
activate();
})();
</script>
{{if .HasReports}}
<style>
.auto-refresh-toggle {
+63 -2
View File
@@ -864,12 +864,14 @@ code {
font-family: var(--font-data);
font-size: 0.875rem;
}
.data-table td a {
/* Plain links only — :not(.btn) keeps <a class="btn"> buttons on the .btn palette
(blue-bright link text on a blue-bright button background is invisible). */
.data-table td a:not(.btn) {
color: var(--blue-bright);
text-decoration: none;
font-family: var(--font-sans, sans-serif);
}
.data-table td a:hover { text-decoration: underline; }
.data-table td a:not(.btn):hover { text-decoration: underline; }
.data-table th a {
color: var(--text-2);
text-decoration: none;
@@ -908,6 +910,65 @@ header h1 span { color: var(--blue-bright); }
:focus-visible { outline: 2px solid var(--blue-bright); outline-offset: 1px; }
/* ── Customer page tabs (v0.47.0) ──────────────────────────────────────────
Hash-based client-side tabs. Graceful degradation is load-bearing: panels
are hidden ONLY under body.js-tabs (added by JS on DOMContentLoaded) — with
JS off every panel stays visible and the page reads top-to-bottom. */
.summary-strip {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding: 0.5rem 0.75rem;
margin: 0.75rem 0 0;
background: var(--bg-0);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: 0.85rem;
}
.summary-strip .strip-name { font-weight: 600; color: var(--text-1); }
.summary-strip .strip-item { color: var(--text-2); white-space: nowrap; }
.summary-strip .strip-item code { font-size: 0.85em; }
.tab-nav {
display: flex;
gap: 0.25rem;
margin: 0.75rem 0 1rem;
border-bottom: 1px solid var(--line);
overflow-x: auto;
white-space: nowrap;
}
.tab-nav a {
color: var(--text-2);
text-decoration: none;
font-size: 0.9rem;
padding: 0.45rem 0.75rem;
border-bottom: 2px solid transparent;
flex-shrink: 0;
}
.tab-nav a:hover { color: var(--text-1); }
.tab-nav a.active {
color: var(--blue-bright);
border-bottom-color: var(--blue-bright);
font-weight: 600;
}
.tab-badge {
display: inline-block;
min-width: 1.2em;
margin-left: 0.35em;
padding: 0 0.35em;
background: var(--crit);
color: #fff;
border-radius: var(--radius);
font-size: 0.75em;
font-weight: 600;
text-align: center;
}
body.js-tabs .tab-panel:not(.tab-panel-active) { display: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}