Files
felhom.eu/hub/internal/web/templates/customer_unified.html
T
admin c136308aad D4 Part 2: hub style.css v2 + template sweep
- style.css: slate tokens -> the canonical navy block + @font-face
  (self-hosted, latin-ext for Hungarian customer names); single 2px
  radius; hairline table rows (--line-soft dividers, density kept);
  status classes re-expressed per the design-system addendum:
  .status-badge = outline tag + CSS ::before dot keyed by the raw
  status the templates emit (ok=blue, warn/blocked/stale=amber,
  down/fail=red, pending/disabled=quiet neutral); .status-dot =
  class-based 8px dot; severity badges stay filled amber/red
  (exceptions stay loud); config badges = filled informational chips
  in v2 colors; row tint only for warn/down (warn-dim/crit-dim).
- Two-tone brand heading on all 8 'Felhom Hub' pages
  (<h1>Felhom <span>Hub</span></h1>, span = blue-bright).
- app_detail chart retinted: avg #2EA8F5 (primary data), peak #8E7CE8
  (secondary DATA series — not status red), catalog-limit line #E0A93E
  (threshold marker); legend/tick/grid -> v2 literals.
- customer_unified: JS status-message colors (success -> blue-bright,
  error -> crit), all inline slate hexes -> tokens.
- Login page inline HTML retinted (#0088cc -> #0083D8).
- Grep gate: every slate hex (#0f172a #1e293b #334155 #60a5fa #4ade80
  #facc15 #f87171 #94a3b8 #64748b #475569 #e2e8f0) at ZERO across
  hub/internal/web (non-test); statusIcon 0; inline statusColor
  style 0. go build/vet/test green; api/ + store/ untouched.
2026-07-02 22:38:37 +02:00

806 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub</title>
<link rel="stylesheet" href="/style.css">
<meta name="csrf-token" content="{{.CSRFToken}}">
<script>function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}</script>
</head>
<body>
{{template "icon_sprite"}}
<div class="container">
<header>
<nav class="nav-links" style="margin-bottom: 0.5rem;">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Customers</a>
<a href="/apps" class="nav-link">Apps</a>
<a href="/hosts" class="nav-link">Hosts</a>
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
<a href="/configs" class="back-link">&larr; All Customers</a>
<h1>
<span class="status-dot status-dot-{{statusColor .OverallStatus}}"></span>
{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}
</h1>
{{if .HasReports}}
<p class="subtitle">
Last report: {{timeAgo .Customer.ReceivedAt}} &middot; Controller {{.Customer.ControllerVersion}}
<label class="auto-refresh-toggle" title="Auto-refresh every 60s">
<input type="checkbox" id="autoRefreshToggle">
<span class="toggle-slider"></span>
<span class="toggle-label">Auto-refresh</span>
</label>
</p>
{{else}}
<p class="subtitle">No reports received yet</p>
{{end}}
</header>
{{if .Flash}}
<div class="flash flash-success">
{{if eq .Flash "created"}}Configuration created successfully.
{{else if eq .Flash "updated"}}Configuration updated.
{{else if eq .Flash "password_regenerated"}}Retrieval password regenerated.
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
{{end}}
</div>
{{end}}
{{if .IsBlocked}}
<div class="flash flash-blocked">
This customer is blocked — reports are accepted but not shown on the Dashboard.
</div>
{{end}}
<!-- Customer Info -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h2>Customer Info</h2>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{{if .HasConfig}}
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Unblock</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Create Config</button>
</form>
{{end}}
</div>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">Customer ID</span>
<span class="value"><code>{{.CustomerID}}</code></span>
</div>
<div class="info-item">
<span class="label">Name</span>
<span class="value">{{if .CustomerName}}{{.CustomerName}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Domain</span>
<span class="value">{{if .Domain}}{{.Domain}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{if .Email}}{{.Email}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Config</span>
<span class="value">
{{if .HasConfig}}
<span class="config-badge config-badge-managed">MANAGED</span>
{{else}}
<span class="config-badge config-badge-manual">MANUAL</span>
{{end}}
{{if .IsBlocked}}<span class="config-badge config-badge-blocked">BLOCKED</span>{{end}}
</span>
</div>
{{if .HasConfig}}
<div class="info-item">
<span class="label">Config Created</span>
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
</div>
{{end}}
</div>
</section>
{{if .HasReports}}
<!-- System Info -->
<section class="card">
<h2>System</h2>
<div class="info-grid">
{{with .Report.system}}
<div class="info-item">
<span class="label">Hostname</span>
<span class="value">{{index . "hostname"}}</span>
</div>
<div class="info-item">
<span class="label">OS</span>
<span class="value">{{index . "os"}}</span>
</div>
<div class="info-item">
<span class="label">Kernel</span>
<span class="value">{{index . "kernel"}}</span>
</div>
<div class="info-item">
<span class="label">CPU</span>
<span class="value">{{index . "cpu_model"}} ({{index . "cpu_cores"}} cores)</span>
</div>
{{end}}
</div>
<div class="metrics-grid">
<div class="metric">
<span class="metric-label">CPU</span>
<span class="metric-value">{{formatFloat .Customer.CPUPercent}}%</span>
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.CPUPercent}}%"></div></div>
</div>
<div class="metric">
<span class="metric-label">Memory</span>
<span class="metric-value">{{formatFloat .Customer.MemoryPercent}}%</span>
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.MemoryPercent}}%"></div></div>
</div>
</div>
</section>
<!-- Storage -->
<section class="card">
<h2>Storage</h2>
{{with .Report.storage}}
<div class="metrics-grid">
{{range .}}
<div class="metric">
<span class="metric-label">{{with index . "label"}}{{.}}{{else}}{{index . "mount"}}{{end}}</span>
<span class="metric-value">{{printf "%.0f" (index . "percent")}}%</span>
<div class="bar"><div class="bar-fill" style="width: {{printf "%.0f" (index . "percent")}}%"></div></div>
<span class="metric-detail">{{printf "%.1f" (index . "used_gb")}} / {{printf "%.1f" (index . "total_gb")}} GB</span>
</div>
{{end}}
</div>
{{end}}
</section>
<!-- Containers -->
<section class="card">
<h2>Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})</h2>
{{with .Report.containers}}
{{$list := index . "list"}}
{{if $list}}
<table class="container-table">
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>CPU</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
{{range $list}}
<tr>
<td>{{index . "name"}}</td>
<td><span class="container-state container-state-{{index . "state"}}">{{index . "state"}}</span></td>
<td>{{printf "%.1f" (index . "cpu_percent")}}%</td>
<td>{{printf "%.0f" (index . "memory_mb")}} MB</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{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>
<!-- 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>
<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}}
{{end}}
<!-- 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>
{{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}}
{{end}}
<!-- Config Management -->
{{if .HasConfig}}
<section class="card">
<h2>Credentials</h2>
<div class="credential-row">
<div>
<span class="label">Retrieval Password</span>
<div class="credential-box">
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">&#x2398;</button>
</div>
</div>
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
</form>
</div>
<div class="credential-row" style="margin-top: 1rem;">
<div>
<span class="label">API Key</span>
<div class="credential-box">
<code id="api-key">{{.Config.APIKey}}</code>
<button type="button" class="copy-btn" onclick="copyText('api-key')" title="Copy">&#x2398;</button>
</div>
</div>
<span class="form-hint">Used by the controller for ongoing hub communication (reports, notifications, backups)</span>
</div>
</section>
<section class="card">
<h2>Setup Command</h2>
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
Day-0 host bootstrap. Run on a freshly-PVE-installed Proxmox <strong>host</strong> as root
(create the customer in the hub first). It enrolls the host, installs + verifies the agent,
and provisions the guest; the in-guest controller then pulls its own <code>controller.yaml</code>.
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
</p>
<h3>Option 1: Online install (recommended)</h3>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">
Fetches the script over TLS from felhom.eu, then runs it. Download-then-run (not
<code>curl | sudo bash</code>) so you can inspect the script before executing it — the
right default for a sovereignty product. The passphrase is entered at the no-echo prompt.
</p>
<div class="credential-box">
<code id="cmd-online">curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \
&& sudo bash felhom-host-install.sh --customer-id {{.CustomerID}}</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-online')" title="Copy">&#x2398;</button>
</div>
<h3 style="margin-top: 1rem;">Option 2: Local install (script already on host)</h3>
<div class="credential-box">
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">&#x2398;</button>
</div>
<h3 style="margin-top: 1rem;">Option 3: Manual config fetch (debug only)</h3>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">The same payload the controller pulls itself — for inspection, not normal provisioning.</p>
<div class="credential-box">
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">&#x2398;</button>
</div>
</section>
<section class="card">
<h2>YAML Preview</h2>
<div id="yaml-preview" class="yaml-preview">
<p class="text-muted">Loading preview...</p>
</div>
</section>
{{end}}
{{if .HasReports}}
<!-- Controller Update -->
<section class="card">
<h2>Controller Update</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Controller version</span>
<span class="value">{{.Customer.ControllerVersion}}</span>
</div>
{{if .LatestVersion}}
<div class="info-item">
<span class="label">Registry latest</span>
<span class="value">
v{{.LatestVersion}}
{{if .UpdateAvailable}}
<span style="color: var(--blue-bright); margin-left: 0.3em;">● update available</span>
{{else}}
<span style="color: var(--text-2); margin-left: 0.3em;">— up to date</span>
{{end}}
</span>
</div>
{{end}}
{{if .ControllerURL}}
<div class="info-item">
<span class="label">Controller URL</span>
<span class="value"><a href="{{.ControllerURL}}" target="_blank" style="color: var(--blue-bright);">{{.ControllerURL}}</a></span>
</div>
{{end}}
</div>
<!-- Phase 2 managed-update floor (operator-enforced minimum) -->
<div class="info-grid" style="margin-top: 0.75rem; border-top: 1px solid var(--line); padding-top: 0.75rem;">
<div class="info-item">
<span class="label">Effective floor (min. version)</span>
<span class="value">
{{if .EffectiveFloor}}v{{.EffectiveFloor}}
{{if .BelowFloor}}<span style="color: #f59e0b; margin-left: 0.3em;">● below floor — will auto-update</span>
{{else}}<span style="color: var(--text-2); margin-left: 0.3em;">— at/above floor</span>{{end}}
{{else}}<span style="color: var(--text-2);">none (Phase 2 inert)</span>{{end}}
</span>
</div>
<div class="info-item">
<span class="label">Global floor</span>
<span class="value">{{if .GlobalFloor}}v{{.GlobalFloor}}{{else}}<span style="color: var(--text-2);">unset</span>{{end}}</span>
</div>
</div>
<form method="POST" action="/customers/{{.CustomerID}}/floor" style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<label style="font-size: 0.85em; color: #cbd5e1;">Per-customer override</label>
<input type="text" name="min_controller_version" value="{{.FloorOverride}}" placeholder="e.g. 0.87.0 (blank = use global)" style="padding: 0.3em 0.5em; font-size: 0.85em; width: 14em;">
<button class="btn btn-outline btn-sm" type="submit">Save floor</button>
<span style="font-size: 0.8em; color: var(--text-2);">Boxes below the effective floor auto-update on their next report. Blank clears the override.</span>
</form>
<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
next report.
</p>
</section>
<!-- Events -->
<section class="card">
<h2>Events
{{if .EventCounts}}
{{with mapGet .EventCounts "error"}}<span class="severity-badge severity-error">{{.}} error{{if gt . 1}}s{{end}}</span>{{end}}
{{with mapGet .EventCounts "warning"}}<span class="severity-badge severity-warning">{{.}} warning{{if gt . 1}}s{{end}}</span>{{end}}
{{end}}
<span class="text-muted" style="font-size: 0.7em; font-weight: normal;"> (last 24h)</span>
</h2>
{{if .Events}}
<div style="margin-bottom: 0.5rem;">
<button class="btn btn-sm btn-outline event-filter active" data-filter="all">All</button>
<button class="btn btn-sm btn-outline event-filter" data-filter="error">Errors</button>
<button class="btn btn-sm btn-outline event-filter" data-filter="warning">Warnings</button>
<button class="btn btn-sm btn-outline event-filter" data-filter="info">Info</button>
</div>
<table class="history-table" id="events-table">
<thead>
<tr>
<th>Time</th>
<th>Severity</th>
<th>Type</th>
<th>Message</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{{range .Events}}
<tr data-severity="{{.Severity}}">
<td title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">{{.CreatedAt.Format "Jan 02 15:04"}}</td>
<td><span class="severity-badge severity-{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.EventType}}</code></td>
<td>{{.Message}}</td>
<td>{{.Source}}</td>
</tr>
{{end}}
</tbody>
</table>
<script>
document.querySelectorAll('.event-filter').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.event-filter').forEach(b => b.classList.remove('active'));
this.classList.add('active');
const filter = this.dataset.filter;
document.querySelectorAll('#events-table tbody tr').forEach(row => {
row.style.display = (filter === 'all' || row.dataset.severity === filter) ? '' : 'none';
});
});
});
</script>
{{else}}
<p class="text-muted">No events recorded yet.</p>
{{end}}
</section>
<!-- App telemetry -->
{{if .HasAppTelemetry}}
<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>
</tr>
</thead>
<tbody>
{{range .AppTelemetry}}
<tr>
<td><a href="/apps/{{.AppName}}">{{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>
</tr>
{{end}}
</tbody>
</table>
</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}}
<!-- Notifications -->
<section class="card">
<h2>Notifications</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}</span>
</div>
{{if .NotifPrefs}}
<div class="info-item">
<span class="label">Events</span>
<span class="value">{{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}}</span>
</div>
{{end}}
</div>
{{if .RecentNotifications}}
<h3>Recent (last 10)</h3>
<table class="history-table">
<thead>
<tr>
<th>Time</th>
<th>Channel</th>
<th>Event</th>
<th>Status</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{{range .RecentNotifications}}
<tr>
<td>{{.CreatedAt.Format "Jan 02 15:04"}}</td>
<td><span class="status-badge status-badge-{{.Channel}}">{{.Channel}}</span></td>
<td>{{.EventType}}</td>
<td><span class="status-badge status-badge-{{.Status}}">{{.Status}}</span></td>
<td>{{.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</section>
<!-- Report History -->
{{if .History}}
<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>
</section>
{{end}}
{{end}}
<footer>
{{if .HasReports}}<p>Auto-refreshes every 60 seconds &middot; {{end}}<a href="/">Felhom Hub</a> {{hubVersion}}{{if .HasReports}}</p>{{end}}
</footer>
</div>
<script>
function copyText(elementId) {
var el = document.getElementById(elementId);
var text = el.textContent || el.innerText;
navigator.clipboard.writeText(text.trim()).then(function() {
var btn = el.parentElement.querySelector('.copy-btn');
var orig = btn.innerHTML;
btn.innerHTML = '&#x2713;';
setTimeout(function() { btn.innerHTML = orig; }, 1500);
});
}
function disableGeo(customerID) {
if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return;
var btn = document.getElementById('btn-geo-disable');
var msg = document.getElementById('geo-msg');
btn.disabled = true;
btn.textContent = 'Eltávolítás...';
msg.style.display = 'none';
fetch('/customers/' + customerID + '/geo/disable', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
msg.textContent = data.message || 'Geo-korlátozás eltávolítva';
msg.style.display = 'inline';
msg.style.color = '#2EA8F5';
setTimeout(function() { location.reload(); }, 2000);
} else {
msg.textContent = data.error || 'Hiba történt';
msg.style.display = 'inline';
msg.style.color = '#E5534B';
btn.disabled = false;
btn.textContent = 'Összes geo-korlátozás eltávolítása';
}
})
.catch(function() {
msg.textContent = 'Kapcsolódási hiba';
msg.style.display = 'inline';
msg.style.color = '#E5534B';
btn.disabled = false;
btn.textContent = 'Összes geo-korlátozás eltávolítása';
});
}
{{if .HasConfig}}
// Load YAML preview
fetch('/configs/{{.CustomerID}}/preview')
.then(function(r) { return r.text(); })
.then(function(yaml) {
document.getElementById('yaml-preview').innerHTML = '<pre>' + yaml.replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</pre>';
})
.catch(function() {
document.getElementById('yaml-preview').innerHTML = '<p class="text-muted">Failed to load preview.</p>';
});
{{end}}
</script>
{{if .HasReports}}
<style>
.auto-refresh-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: 1rem;
cursor: pointer;
vertical-align: middle;
font-size: 0.8rem;
user-select: none;
}
.auto-refresh-toggle input { display: none; }
.toggle-slider {
position: relative;
width: 32px;
height: 18px;
background: var(--bg-2);
border-radius: 9px;
transition: background 0.2s;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--text-2);
border-radius: 50%;
transition: transform 0.2s, background 0.2s;
}
.auto-refresh-toggle input:checked + .toggle-slider {
background: #22c55e;
}
.auto-refresh-toggle input:checked + .toggle-slider::after {
transform: translateX(14px);
background: #fff;
}
.toggle-label { color: var(--text-2); }
</style>
<script>
(function() {
var toggle = document.getElementById('autoRefreshToggle');
if (!toggle) return;
var key = 'hub_auto_refresh';
var enabled = localStorage.getItem(key) !== 'off';
var timer = null;
toggle.checked = enabled;
function setRefresh(on) {
clearTimeout(timer);
if (on) timer = setTimeout(function() { location.reload(); }, 60000);
}
toggle.addEventListener('change', function() {
localStorage.setItem(key, this.checked ? 'on' : 'off');
setRefresh(this.checked);
});
setRefresh(enabled);
})();
</script>
{{end}}
</body>
</html>