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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 · {{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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user