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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user