Files
felhom.eu/hub/internal/web/templates/customer_unified.html
T
admin f5c9411e5e R-331 (hub half): the Backup card reads offsite, not the dead backup fields (v0.109.0)
The customer page's Backup card read `Snapshots 0 / Repo Size 0 MB / Integrity
Unknown` for EVERY customer, indefinitely. Measured on demo-hp 2026-08-30 while
that night's controller log said `[offbox] backup OK: 8 app(s) backed up, 67
snapshot(s), 2m14s` and the box held snapshot_count:67, repo_size_bytes:
140829678, stats_known:true.

A card reading "no backups" over a working backup is worse than no card -- the
R-88 direction of failure (degrade to NO BACKUP rather than to UNKNOWN) on the
one screen that answers "is this customer protected?".

The data was never missing. The card rendered the report's `backup` object,
whose snapshot/size/integrity fields have had no producer since slice 8C. The
live numbers are in the `offsite` object, which THIS PACKAGE already reads for
the Offsite page and which monitor.OffsiteChecker already alarms from. Proof the
bytes were arriving: the Offsite page rendered demo-hp's usage as 0.1 GB from
that very object while the Backup card said 0 MB. So this is a render fix over
an existing feed, not a new pipeline.

Not a one-line swap, because snapshot_count:0 means two opposite things --
"holds nothing" and "never measured". R-225 measured that confusion one layer
down. backup_card.go resolves a three-way ruling in Go (a {{if}} chain over
map[string]interface{} float64s cannot keep the absent/zero distinction the card
is entirely about):
  no offsite object   -> "No off-site data reported", and says explicitly that
                         this is NOT the same as "no backups"
  disabled + state    -> names the blocker (needs_credential)
  stats_known:false   -> em-dash + "never been measured". NEVER 0
  stats_known:true    -> the real numbers, INCLUDING a real 0

A pre-v0.225.0 controller sends no stats_known -> false -> "unknown". That
direction is pinned: upgrading the hub ahead of the fleet must not report every
un-upgraded customer as having zero backups.

The Integrity row is DELETED, not re-sourced: nothing produces it, the
controller runs no integrity check, and NotifyIntegrityOK/Failed are called from
nowhere.

RED-PROOF: restore the pre-fix card markup -> all four tests fail, reporting 67
and 134.3 MB absent from the rendered page and the Integrity row present. The
tests drive handleCustomerUnified and grep the HTML on purpose: the defect was
the template's choice of source object, so a test one layer below it would have
been green against the shipped bug.

Green gate clean: 18 packages, rc 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
2026-08-30 18:39:15 +02:00

1478 lines
85 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?v={{hubVersion}}">
<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>
{{template "inline_confirm_js"}}
</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="/offsite" class="nav-link">Offsite</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}}
{{if .HostCause}}<span class="status-badge status-badge-warn">{{.HostCause}}</span>{{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>
<span id="refresh-paused-hint" class="toggle-label" style="display:none">(paused)</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 "offsite_reissued"}}Offsite credentials re-issued — a fresh one-time password is staged; the controller picks it up on its next config refresh.
{{else if eq .Flash "pbsdr_reissued"}}PBS DR credentials re-issued — a fresh one-time token secret is staged for the agent. Confirm it actually landed: the host's <code>pvesm status</code> must show the PBS entry <strong>active</strong>. A converged agent can report <code>applied</code> while the storage still authenticates 401 (R-39).
{{else if eq .Flash "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen.
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
{{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded.
{{else if eq .Flash "claim-resent"}}Code re-sent to the registered address. A kód a doboz következő jelentésekor (~15 percen belül) aktiválódik.
{{else if eq .Flash "claim-resend-failed"}}Claim code resend FAILED — check the hub log (email delivery / send error).
{{else if eq .Flash "selfbind-sent"}}Self-bind link sent to the registered address — valid for 7 days. The customer enters the box's console pairing code + their retrieval passphrase; no operator bind needed.
{{else if eq .Flash "selfbind-no-email"}}Self-bind link NOT sent — this customer has no registered email address. Set one first, or bind the appliance manually from the Hosts page.
{{else if eq .Flash "selfbind-send-failed"}}Self-bind link send FAILED — the link was invalidated (not left live). Check the hub log (email delivery / send error).
{{else if eq .Flash "reset_done"}}Customer RESET complete — every operational trace was destroyed (offsite repo, PBS namespace, DR recipe, claim state, retained escrow custody). Identity and basic config survive; the audit event stream records it.
{{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}}
{{if .StaleSinceReset}}
<div class="flash flash-warn">
<strong>No data since the RESET</strong> ({{.ResetAt}}) — „RESET óta nincs adat".
Every health figure below predates it and describes a lifecycle that no longer exists.
The box repopulates this page on its first report after re-onboarding.
</div>
{{end}}
{{if .OffsiteUnprovisioned}}
<div class="flash flash-warn">
<strong>Offsite is enabled but was never provisioned</strong> — no descriptor exists for
this customer, so nothing is being backed up offsite. Provisioning is <em>Save</em>-triggered:
open the Edit tab and press Save once to provision it, then verify on the Offsite page.
Re-enrollment will not fix this on its own — the auto-re-issue deliberately skips an
unprovisioned target. (R-36)
</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 .HostCause}}<span class="strip-item"><span class="status-badge status-badge-warn">{{.HostCause}}</span></span>{{end}}
{{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. data-live-tabs = the tabs whose content changes with incoming
reports; the 60s auto-refresh fires ONLY while one of them is active (v0.48.0). -->
<nav class="tab-nav" id="tab-nav" data-live-tabs="overview,applications,events,host">
<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=edit" data-tab="edit">Edit</a>
<a href="#tab=backup" data-tab="backup">Backup &amp; DR</a>
<a href="#tab=events" data-tab="events">Events{{with mapGet .EventCounts "error"}}<span class="tab-badge">{{.}}</span>{{end}}</a>
<a href="#tab=notifications" data-tab="notifications">Notifications</a>
<a href="#tab=host" data-tab="host">Host</a>
</nav>
<!-- ═══ Overview ═══ -->
<div class="tab-panel tab-panel-active" data-tab="overview">
<!-- Customer Info -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h2>Customer Info</h2>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{{/* v0.48.0 edit-a: Edit/Block/Delete moved to the Edit tab (form + danger
zone); only the config-less bootstrap action stays in the header. */}}
{{if not .HasConfig}}
<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}}
<!-- 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>
<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>
<!-- Backup (R-331: reads the report's `offsite` object; the `backup` object's
snapshot/size/integrity fields have had no producer since slice 8C) -->
<section class="card">
<h2>Backup</h2>
{{with .BackupCard}}
<div class="info-grid">
<div class="info-item">
<span class="label">App data (local)</span>
<span class="value">{{if .LocalEnabled}}Enabled{{else}}Disabled{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last DB dump</span>
<span class="value">{{if .LastDBDump}}{{.LastDBDump}}{{else}}&mdash;{{end}}</span>
</div>
</div>
{{if not .OffsiteReported}}
<p class="text-muted">No off-site data reported &mdash; the controller has never sent an
<code>offsite</code> object (no off-site tier, or a controller older than v0.109.0).
<strong>This is not the same as "no backups"</strong>; it means this page cannot say.</p>
{{else if not .OffsiteEnabled}}
<p class="text-muted">Off-site backup is not enabled for this customer.{{if .OffsiteState}}
Declared state: <code>{{.OffsiteState}}</code> &mdash; the box is waiting on a credential
or recovery key and cannot back up off-site until it has one.{{end}}</p>
{{else}}
<div class="info-grid">
<div class="info-item">
<span class="label">Off-site snapshots</span>
<span class="value">{{if .StatsKnown}}{{.Snapshots}}{{else}}&mdash;{{end}}</span>
</div>
<div class="info-item">
<span class="label">Repo size</span>
<span class="value">{{if .StatsKnown}}{{.RepoSize}}{{else}}&mdash;{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last successful run</span>
<span class="value">{{.LastSuccess}}</span>
</div>
{{if .QuotaStr}}
<div class="info-item">
<span class="label">Soft quota</span>
<span class="value">{{.QuotaStr}}</span>
</div>
{{end}}
</div>
{{if not .StatsKnown}}
<p class="text-muted">Snapshot count and repository size have <strong>never been
measured</strong> on this box &mdash; shown as &mdash; rather than 0, because zero would
claim the repository is empty and that is not what is known. They fill in after the next
off-site run reads the repository.</p>
{{end}}
{{end}}
{{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>
{{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>
<!-- 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>
<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>
</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>
{{end}}
{{else}}
<section class="card"><p class="text-muted">Container and app data appear once the first report arrives.</p></section>
{{end}}
</div>
<!-- ═══ Setup ═══ -->
<div class="tab-panel" data-tab="setup">
{{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" data-secret="{{.Config.RetrievalPassword}}">&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;</code>
<button type="button" class="copy-btn" id="reveal-retrieval-pw" onclick="toggleSecret('retrieval-pw')" title="Reveal / hide">Reveal</button>
<button type="button" class="copy-btn" onclick="copySecret('retrieval-pw')" title="Copy">&#x2398;</button>
</div>
<span class="form-hint">The per-customer secret that fetches the whole config — masked by default; never place it on a command line (the installer reads it at a no-echo prompt).</span>
</div>
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Regenerate retrieval password? The old password will stop working immediately.">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>Dashboard access — customer claim</h2>
<div class="credential-row">
<div>
<span class="label">Claim status</span>
<div style="margin: 0.3rem 0;">
{{if .Claim}}
{{if .Claim.ClaimedAt}}
<span class="badge badge-ok">Claimed {{timeAgoPtr .Claim.ClaimedAt}}</span>
{{else if .Claim.EmailedAt}}
<span class="badge badge-warn">Nyitott — kód kiküldve {{timeAgoPtr .Claim.EmailedAt}}</span>
{{else}}
<span class="badge badge-error">Nyitott — a kód e-mail NEM ment ki (resend!)</span>
{{end}}
<span class="form-hint" style="margin-left: 0.5rem;">generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}}</span>
{{else}}
<span class="badge badge-neutral">no code issued yet</span>
<span class="form-hint" style="margin-left: 0.5rem;">issued automatically at the first config pull or report</span>
{{end}}
</div>
<span class="form-hint">The customer sets + owns the dashboard password (claim code → own password). The code goes ONLY to the registered address ({{.Email}}); the hub stores a hash — no plaintext code exists to display.</span>
</div>
{{if .Claim}}
<form method="POST" action="/configs/{{.CustomerID}}/claim-resend" style="margin-top: 0.5rem;">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Send a fresh code to the registered address? The previous code stops working immediately (the box activates it on its next report, ~15 min).">{{if .Claim.ClaimedAt}}Beállító kód küldése{{else}}Kód újraküldése{{end}}</button>
</form>
{{end}}
<div class="form-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
<label class="form-label">Customer self-bind (R-27)</label>
<span class="form-hint">Let the customer bind their own freshly-installed appliance — no operator bind needed. Sends a 7-day capability link to the registered address ({{.Email}}); the customer opens it and enters the box's <strong>console pairing code</strong> + their <strong>retrieval passphrase</strong>. Wrong entries lock the link after 5 attempts. If the link expires, bind the appliance manually from the Hosts page.</span>
<form method="POST" action="/customers/{{.CustomerID}}/selfbind-link" style="margin-top: 0.5rem;">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Email a self-bind link to the registered address? Any previous self-bind link for this customer stops working immediately.">Send self-bind link</button>
</form>
</div>
</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. The command always fetches the <strong>current</strong>
felhom-host-install.sh from felhom.eu &mdash; there is no version to pick here. Run it
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>
<!-- Generator controls (client-side only; nothing is submitted) -->
<div class="gen-controls" id="gen-controls" data-customer-id="{{.CustomerID}}">
<div class="form-group">
<label>Mode <span style="color:var(--red,#E5534B)">*</span></label>
<div class="gen-radios">
<label class="gen-radio"><input type="radio" name="gen-mode" value="appliance" onchange="genUpdate()"> appliance <span class="form-hint">(Felhom-owned box)</span></label>
<label class="gen-radio"><input type="radio" name="gen-mode" value="byo" onchange="genUpdate()"> byo <span class="form-hint">(a host you do not own)</span></label>
</div>
</div>
<div class="form-grid" style="margin-top:0.75rem;">
<div class="form-group" id="gen-cores-grp">
<label>Cores <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
<input type="number" id="gen-cores" min="1" placeholder="e.g. 12" oninput="genUpdate()">
<span class="form-hint">byo: required — a conservative slice of the host</span>
</div>
<div class="form-group" id="gen-memory-grp">
<label>Memory (MiB) <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
<input type="number" id="gen-memory" min="256" placeholder="e.g. 32768 (= 32 GB)" oninput="genUpdate()">
<span class="form-hint">byo: required — MiB (32 GB → 32768)</span>
</div>
<div class="form-group">
<label>VMID <span class="form-hint">(optional)</span></label>
<input type="number" id="gen-vmid" min="100" placeholder="e.g. 9201" oninput="genUpdate()">
<span class="form-hint">pick from <code>pct list</code>+<code>qm list</code>; blank = default/auto</span>
</div>
<div class="form-group">
<label>Node <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-node" placeholder="e.g. pve1" oninput="genUpdate()">
<span class="form-hint">required only on a multi-node cluster</span>
</div>
<div class="form-group">
<label>ACL storages <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-acl" placeholder='e.g. local local-lvm' oninput="genUpdate()">
<span class="form-hint">grant the token write access on exactly these storages</span>
</div>
<div class="form-group">
<label>Operator pubkey file <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-pubkey" placeholder="/path/to/operator-keys" oninput="genUpdate()">
<span class="form-hint">arm self-update from day-0</span>
</div>
<div class="form-group">
<label>Preserve state from <span class="form-hint">(optional)</span></label>
<input type="text" id="gen-preserve" placeholder="/var/lib/felhom-agent.old" oninput="genUpdate()">
<span class="form-hint">reinstall keeping the leaf pin stable</span>
</div>
</div>
<div class="gen-checks">
<label class="gen-check"><input type="checkbox" id="gen-dry" onchange="genUpdate()"> <code>--dry-run</code> <span class="form-hint">print, don't execute</span></label>
<label class="gen-check"><input type="checkbox" id="gen-preflight" onchange="genUpdate()"> <code>--preflight-only</code> <span class="form-hint">checks + verdict, no install</span></label>
<label class="gen-check"><input type="checkbox" id="gen-skip" onchange="genUpdate()"> <code>--skip-provision</code> <span class="form-hint">agent only, no guest</span></label>
<label class="gen-check"><input type="checkbox" id="gen-leaf" onchange="genUpdate()"> <code>--allow-new-leaf</code> <span class="form-hint" id="gen-leaf-warn"></span></label>
</div>
</div>
<p class="gen-msg" id="gen-msg" style="display:none;"></p>
<h3 style="margin-top:1rem;">Option 1: Online install (recommended)</h3>
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">
Download-then-run (not <code>curl | sudo bash</code>) so you can inspect the script first —
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 \
&amp;&amp; sudo bash felhom-host-install.sh --customer-id {{.CustomerID}} --mode &lt;appliance|byo&gt;</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}} --mode &lt;appliance|byo&gt;</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. Replace the placeholder with the Retrieval Password above (Reveal to see it) — it is intentionally NOT baked into this command.</p>
<div class="credential-box">
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: &lt;YOUR-RETRIEVAL-PASSWORD&gt;" -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>
{{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>
<!-- ═══ Edit ═══ (v0.48.0 edit-a: the standalone config form embedded via the shared
config_form_body sub-template, plus the relocated Block/Delete danger zone. The
Controller Update / Geo / Danger zone cards are SIBLINGS after </form> — nesting
them inside the config <form> is invalid HTML and breaks the formaction sub-buttons. -->
<div class="tab-panel" data-tab="edit">
{{if .HasConfig}}
{{template "config_form_body" .ConfigForm}}
{{else}}
<section class="card"><p class="text-muted">No managed config yet — create one with the Create Config button above.</p></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 in
the form above; 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}}
{{if .HasConfig}}
<!-- Reset customer (v0.61.0): the MIDDLE lifecycle tier — host delete < RESET < customer Delete.
One action returns the customer to pre-first-install: every OPERATIONAL trace dies (offsite
repo, PBS namespace, DR recipe, one-time secret, claim state, retained escrow custody), while
IDENTITY and the basic config SURVIVE. Amber (--warn), deliberately distinct from the red
Danger-zone Delete below it. Refuses while any host row exists (delete hosts first). -->
<section class="card" style="border-color: var(--warn);">
<h2>Ügyfél-visszaállítás <span class="text-muted" style="font-size: 0.8em; font-weight: normal;">(RESET — pre-első-telepítés)</span></h2>
<p class="text-muted">Egyetlen művelettel visszaállítja az ügyfelet az első telepítés előtti állapotba: <strong>minden működési állapot törlődik</strong> (offsite tároló, PBS névtér, DR-recept, egyszeri jelszó, azonosítási állapot). Az <strong>azonosság és az alapkonfiguráció megmarad</strong> (ügyfélrekord, előzmények, események). Ez NEM törli a hostokat — ha még van host, előbb azt kell törölni. Kevesebb, mint a Danger zone Delete: az ügyfél megmarad, csak a működési nyomok tűnnek el.</p>
<button type="button" class="btn btn-sm" style="border-color: var(--warn); color: var(--warn);" onclick="customerResetConfirm('{{.CustomerID}}')">Ügyfél visszaállítása&hellip;</button>
<div id="cust-reset-confirm-{{.CustomerID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--warn); background: var(--warn-dim); border-radius: var(--radius); max-width: 46em;">
<p id="cust-reset-inv-{{.CustomerID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">&hellip;</p>
<label id="cust-reset-escrow-row-{{.CustomerID}}" style="display: none; margin: 0 0 0.6rem; font-size: 0.85em; color: var(--crit);">
<input type="checkbox" id="cust-reset-escrow-{{.CustomerID}}">
<strong>Megőrzött helyreállítási-kulcs letét megsemmisítése</strong> — külön megerősítés (ez visszafordíthatatlanul törli a megőrzött escrow blobokat).
</label>
<p style="margin: 0 0 0.4rem; font-size: 0.85em; color: var(--text-2);">Írd be az ügyfél azonosítóját a megerősítéshez:</p>
<form method="POST" action="/configs/{{.CustomerID}}/reset" id="cust-reset-form-{{.CustomerID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="hidden" name="confirm_id" id="cust-reset-confirm-hidden-{{.CustomerID}}" value="">
<input type="hidden" name="escrow_ack" id="cust-reset-escrow-hidden-{{.CustomerID}}" value="">
<input type="text" id="cust-reset-input-{{.CustomerID}}" placeholder="ügyfél-azonosító&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-sm" id="cust-reset-go-{{.CustomerID}}" style="border-color: var(--warn); color: var(--warn);" onclick="customerResetSubmit('{{.CustomerID}}')">Megerősítés &amp; visszaállítás</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('cust-reset-confirm-{{.CustomerID}}').style.display='none';">Mégse</button>
</form>
<p id="cust-reset-err-{{.CustomerID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function customerResetConfirm(cid) {
var box = document.getElementById('cust-reset-confirm-' + cid);
var inv = document.getElementById('cust-reset-inv-' + cid);
var go = document.getElementById('cust-reset-go-' + cid);
document.getElementById('cust-reset-input-' + cid).value = '';
document.getElementById('cust-reset-err-' + cid).textContent = '';
box.style.display = 'block';
inv.textContent = 'Leltár lekérése…';
go.disabled = false;
fetch('/configs/' + encodeURIComponent(cid) + '/reset')
.then(function(r){ return r.json(); })
.then(function(d){
if (d.refused) {
inv.innerHTML = '<strong style="color: var(--crit)">Elutasítva:</strong> ehhez az ügyfélhez még ' + d.host_count +
' host tartozik. A RESET soha nem töröl hostot — előbb töröld a host(oka)t.';
go.disabled = true;
document.getElementById('cust-reset-escrow-row-' + cid).style.display = 'none';
return;
}
var dies = [];
if (d.offsite_enabled) dies.push('offsite tároló' + (d.offsite_identifier ? ' (' + d.offsite_identifier + ')' : ''));
if (d.pbs_tenancy_configured) dies.push('PBS névtér + mentések');
if (d.dr_recipe_present) dies.push('DR-recept');
if (d.one_time_secret) dies.push('egyszeri jelszó');
if (d.claim_present) dies.push('azonosítási állapot (friss kód a következő onboardingnál)');
if (d.superseded_blobs > 0) dies.push(d.superseded_blobs + ' megőrzött escrow blob');
inv.innerHTML = '<strong>Törlődik:</strong> ' + (dies.length ? dies.join(', ') : 'nincs működési állapot') +
'. <strong>Megmarad:</strong> ügyfélrekord, alapkonfiguráció, előzmények, események.';
var escrowRow = document.getElementById('cust-reset-escrow-row-' + cid);
escrowRow.style.display = d.escrow_ack_required ? 'block' : 'none';
document.getElementById('cust-reset-escrow-' + cid).checked = false;
})
.catch(function(){ inv.textContent = 'A leltár nem kérhető le — a szerver minden feltételt így is kikényszerít.'; });
}
function customerResetSubmit(cid) {
var typed = document.getElementById('cust-reset-input-' + cid).value.trim();
var err = document.getElementById('cust-reset-err-' + cid);
if (typed !== cid) { err.textContent = 'A beírt azonosító nem egyezik.'; return; }
var escrowRow = document.getElementById('cust-reset-escrow-row-' + cid);
var escrowCb = document.getElementById('cust-reset-escrow-' + cid);
if (escrowRow.style.display !== 'none' && !escrowCb.checked) {
err.textContent = 'A megőrzött kulcs-letét megsemmisítéséhez pipáld be a külön megerősítést.';
return;
}
document.getElementById('cust-reset-confirm-hidden-' + cid).value = typed;
document.getElementById('cust-reset-escrow-hidden-' + cid).value = escrowCb.checked ? '1' : '';
document.getElementById('cust-reset-form-' + cid).submit();
}
</script>
{{end}}
<!-- Danger zone (v0.48.0 edit-a): the Block/Delete forms relocated verbatim from the
Customer Info header — endpoints and confirm() handlers unchanged.
v0.70.1: gated on .Deletable, NOT .HasConfig — a GHOST customer (config gone, hosts or
report/telemetry residue remain) must render the Delete button, or the whole v0.70.0
ghost-delete path is dead UI (found live on demo-vm-felhom). RESET stays HasConfig-gated
above (identity-preserving re-onboarding — a ghost has no identity to preserve). -->
{{if .Deletable}}
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}}
{{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">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Block this customer? They will be hidden from the Dashboard.">Block</button>
</form>
{{end}}
{{end}}
<button type="button" class="btn btn-danger btn-sm" onclick="customerDeleteOpen('{{.CustomerID}}')">Delete customer&hellip;</button>
</div>
<!-- Guided full-teardown cascade (v0.69.0, R-25b). The inventory panel is fetched from
GET /configs/{id}/delete; the three acknowledgements + typed customer-id are ALSO
enforced server-side (this is convenience, never the gate). -->
<div id="cust-del-box-{{.CustomerID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); border-radius: var(--radius); max-width: 52em;">
<p id="cust-del-inv-{{.CustomerID}}" style="margin: 0 0 0.75rem; font-size: 0.9em;">&hellip;</p>
<div id="cust-del-journal-{{.CustomerID}}" style="display: none; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--warn); font-size: 0.85em;"></div>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack1-{{.CustomerID}}">
<strong>1.</strong> <span id="cust-del-ack1-text-{{.CustomerID}}">The host(s) will be deleted</span> — recovery-key custody is <strong>demoted</strong> to retained custody, not destroyed.
</label>
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="cust-del-ack2-{{.CustomerID}}">
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong>, PBS credentials are revoked, tunnel and zone are removed.
</label>
<label style="display: block; margin: 0 0 0.75rem; font-size: 0.85em; color: var(--crit);">
<input type="checkbox" id="cust-del-ack3-{{.CustomerID}}">
<strong>3.</strong> The customer record and <strong>ALL escrow ciphertext</strong> are <strong>PURGED</strong> — unrecoverable.
</label>
<p style="margin: 0 0 0.4rem; font-size: 0.85em; color: var(--text-2);">Type the customer-id to confirm:</p>
<form method="POST" action="/configs/{{.CustomerID}}/delete" id="cust-del-form-{{.CustomerID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
{{.CSRFField}}
<input type="hidden" name="confirm_id" id="cust-del-cid-{{.CustomerID}}" value="">
<input type="hidden" name="ack_hosts" id="cust-del-h1-{{.CustomerID}}" value="">
<input type="hidden" name="ack_reset" id="cust-del-h2-{{.CustomerID}}" value="">
<input type="hidden" name="ack_purge" id="cust-del-h3-{{.CustomerID}}" value="">
<input type="hidden" name="expect_hosts" id="cust-del-exp-{{.CustomerID}}" value="">
<input type="text" id="cust-del-input-{{.CustomerID}}" placeholder="customer-id&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-danger btn-sm" id="cust-del-go-{{.CustomerID}}" onclick="customerDeleteSubmit('{{.CustomerID}}')">Confirm &amp; delete everything</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('cust-del-box-{{.CustomerID}}').style.display='none';">Cancel</button>
</form>
<p id="cust-del-err-{{.CustomerID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function customerDeleteOpen(cid) {
var box = document.getElementById('cust-del-box-' + cid);
var inv = document.getElementById('cust-del-inv-' + cid);
var go = document.getElementById('cust-del-go-' + cid);
var jr = document.getElementById('cust-del-journal-' + cid);
box.style.display = 'block';
inv.textContent = 'Loading inventory…';
jr.style.display = 'none';
document.getElementById('cust-del-input-' + cid).value = '';
document.getElementById('cust-del-err-' + cid).textContent = '';
document.getElementById('cust-del-exp-' + cid).value = '';
['ack1', 'ack2', 'ack3'].forEach(function(a) { document.getElementById('cust-del-' + a + '-' + cid).checked = false; });
go.disabled = false;
fetch('/configs/' + encodeURIComponent(cid) + '/delete')
.then(function(r){ return r.json(); })
.then(function(d){
document.getElementById('cust-del-exp-' + cid).value = String(d.host_count);
var hostNames = (d.hosts || []).map(function(h){ return h.host_id + ' (' + h.status + ')'; });
document.getElementById('cust-del-ack1-text-' + cid).textContent =
d.host_count + ' host(s) will be deleted' + (hostNames.length ? ': ' + hostNames.join(', ') : '');
var dies = [];
if (d.host_count) dies.push(d.host_count + ' host row(s)');
if (d.offsite_enabled) dies.push('offsite repository' + (d.offsite_identifier ? ' (' + d.offsite_identifier + ')' : ''));
if (d.pbs_tenancy_configured) dies.push('PBS namespace + backups + token');
if (d.dr_recipe_present) dies.push('DR recipe');
if (d.one_time_secret) dies.push('one-time password');
if (d.claim_present) dies.push('claim state');
if (d.residue && d.residue.reports) dies.push(d.residue.reports + ' report row(s)');
if (d.residue && d.residue.app_telemetry) dies.push(d.residue.app_telemetry + ' app-telemetry row(s)');
if (d.residue && d.residue.appliance_registrations) dies.push('appliance registration (token)');
if (d.residue && d.residue.selfbind_tokens) dies.push('self-bind token(s)');
if (d.residue && d.residue.notification_prefs) dies.push('notification preferences');
dies.push('customer record');
var custody = d.superseded_blobs > 0
? d.superseded_blobs + ' retained escrow blob(s) + every current host escrow'
: 'every current host escrow';
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
'<strong>Survives:</strong> the audit event stream, the notification log and the deletion provenance.';
if (d.has_config === false) {
inv.innerHTML = '<strong style="color: var(--warn)">Ghost customer:</strong> the configuration record ' +
'is already gone, but ' + d.residue_total + ' row(s) of report/telemetry state keep it on the ' +
'Customers list and keep it raising staleness alerts. This clears it.<br>' + inv.innerHTML;
}
if (d.online_host_present) {
inv.innerHTML += '<br><strong style="color: var(--crit)">Refused:</strong> a host is ONLINE. ' +
'Decommission the box first — the cascade never deletes a live host.';
go.disabled = true;
}
if (d.pending_journal) {
var legs = d.pending_journal.legs || {};
var parts = Object.keys(legs).map(function(k){ return k + '=' + legs[k]; });
jr.innerHTML = '<strong>An earlier cascade stopped mid-way</strong> (journal #' + d.pending_journal.id +
', started ' + d.pending_journal.started_at + '). Legs: ' + (parts.length ? parts.join(', ') : 'none recorded') +
'. Confirming again RESUMES it — completed legs are no-ops.';
jr.style.display = 'block';
document.getElementById('cust-del-go-' + cid).textContent = 'Confirm & resume teardown';
}
})
.catch(function(){ inv.textContent = 'Inventory unavailable — the server enforces every gate regardless.'; });
}
function customerDeleteSubmit(cid) {
var err = document.getElementById('cust-del-err-' + cid);
var a1 = document.getElementById('cust-del-ack1-' + cid).checked;
var a2 = document.getElementById('cust-del-ack2-' + cid).checked;
var a3 = document.getElementById('cust-del-ack3-' + cid).checked;
if (!a1 || !a2 || !a3) { err.textContent = 'All three acknowledgements are required.'; return; }
var typed = document.getElementById('cust-del-input-' + cid).value.trim();
if (typed !== cid) { err.textContent = 'The typed customer-id does not match.'; return; }
document.getElementById('cust-del-cid-' + cid).value = typed;
document.getElementById('cust-del-h1-' + cid).value = '1';
document.getElementById('cust-del-h2-' + cid).value = '1';
document.getElementById('cust-del-h3-' + cid).value = '1';
document.getElementById('cust-del-form-' + cid).submit();
}
</script>
{{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
{{if .EventCounts}}
{{with mapGet .EventCounts "critical"}}<span class="severity-badge severity-critical">{{.}} critical</span>{{end}}
{{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>
<!-- 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}}
{{else}}
<section class="card"><p class="text-muted">Events appear once the first report arrives.</p></section>
{{end}}
</div>
<!-- ═══ Notifications ═══ -->
<div class="tab-panel" data-tab="notifications">
{{if .HasReports}}
<!-- 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>
{{else}}
<section class="card"><p class="text-muted">Notification data appears once the first report arrives.</p></section>
{{end}}
</div>
<!-- ═══ Host ═══ (a LIST by design — 1 host today, N for a later HA cluster) -->
<div class="tab-panel" data-tab="host">
{{if .Hosts}}
{{range .Hosts}}
<p style="margin: 0 0 0.5rem;"><a href="/hosts/{{.HostID}}" class="back-link">Open host page: {{.HostID}} &rarr;</a></p>
{{template "host_detail_body" .}}
{{end}}
{{else}}
<section class="card">
<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}}
</div>
<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);
});
}
// Masked-secret controls (retrieval password): the value lives in data-secret and is masked in
// the visible node by default; Reveal toggles it, Copy copies the real value. It is never baked
// into a copyable command (see the Option-3 placeholder).
var _secretMask = '•'.repeat(16);
function toggleSecret(elementId) {
var el = document.getElementById(elementId);
var btn = document.getElementById('reveal-' + elementId);
if (el.dataset.revealed === '1') {
el.textContent = _secretMask; el.dataset.revealed = '0';
if (btn) btn.textContent = 'Reveal';
} else {
el.textContent = el.getAttribute('data-secret') || ''; el.dataset.revealed = '1';
if (btn) btn.textContent = 'Hide';
}
}
function copySecret(elementId) {
var el = document.getElementById(elementId);
var val = (el.getAttribute('data-secret') || '').trim();
navigator.clipboard.writeText(val).then(function() {
var btn = el.parentElement.querySelectorAll('.copy-btn');
var b = btn[btn.length - 1]; var orig = b.innerHTML;
b.innerHTML = '&#x2713;';
setTimeout(function() { b.innerHTML = orig; }, 1500);
});
}
// ── Install-command generator (GL-7) — client-side only; assembles a command from the controls
// and only ever emits real host-install flags. Nothing is submitted.
function genFlags() {
// returns {flags: "<assembled>", err: "<message or empty>"}
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
if (!mode) return { flags: '', err: 'Pick a mode (appliance or byo) to generate the command.' };
var f = ['--mode ' + mode];
var cores = (document.getElementById('gen-cores').value || '').trim();
var memory = (document.getElementById('gen-memory').value || '').trim();
var isInt = function (v) { return v !== '' && /^[0-9]+$/.test(v) && parseInt(v, 10) > 0; };
if (mode === 'byo') {
if (!isInt(cores) || !isInt(memory)) {
return { flags: '', err: 'byo mode requires --cores and --memory (noisy-neighbor caps on a host you do not own).' };
}
f.push('--cores ' + cores, '--memory ' + memory);
} else {
// appliance: caps optional; emit only if both are valid positive integers
if (isInt(cores)) f.push('--cores ' + cores);
if (isInt(memory)) f.push('--memory ' + memory);
}
var vmid = (document.getElementById('gen-vmid').value || '').trim();
if (vmid !== '' && isInt(vmid)) f.push('--vmid ' + vmid);
var node = (document.getElementById('gen-node').value || '').trim();
if (node) f.push('--node ' + node);
var acl = (document.getElementById('gen-acl').value || '').trim().replace(/\s+/g, ' ');
if (acl) f.push('--acl-storages "' + acl + '"');
var pub = (document.getElementById('gen-pubkey').value || '').trim();
if (pub) f.push('--operator-pubkey-file ' + pub);
var pre = (document.getElementById('gen-preserve').value || '').trim();
if (pre) f.push('--preserve-state-from ' + pre);
if (document.getElementById('gen-skip').checked) f.push('--skip-provision');
if (document.getElementById('gen-dry').checked) f.push('--dry-run');
if (document.getElementById('gen-preflight').checked) f.push('--preflight-only');
if (document.getElementById('gen-leaf').checked) f.push('--allow-new-leaf');
return { flags: f.join(' '), err: '' };
}
function genUpdate() {
var ctrl = document.getElementById('gen-controls');
if (!ctrl) return;
var cid = ctrl.getAttribute('data-customer-id') || '';
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
// byo requires caps → show the required markers only in byo
var reqOn = (mode === 'byo');
document.querySelectorAll('.gen-req').forEach(function (e) { e.style.display = reqOn ? 'inline' : 'none'; });
// allow-new-leaf inline warning
var lw = document.getElementById('gen-leaf-warn');
lw.textContent = document.getElementById('gen-leaf').checked
? '⚠ regenerates the agent leaf — every provisioned guest must then be re-bootstrapped' : '';
var r = genFlags();
var msg = document.getElementById('gen-msg');
var online = document.getElementById('cmd-online');
var local = document.getElementById('cmd-setup');
var base = 'felhom-host-install.sh --customer-id ' + cid;
if (r.err) {
msg.textContent = r.err; msg.style.display = 'block';
// Do NOT emit a runnable command — show the incomplete shape with a placeholder.
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' --mode <appliance|byo>';
local.textContent = 'sudo ./' + base + ' --mode <appliance|byo>';
return;
}
msg.style.display = 'none';
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' ' + r.flags;
local.textContent = 'sudo ./' + base + ' ' + r.flags;
}
// initialize on load (also gives JS-enabled users the "pick a mode" prompt state)
if (document.getElementById('gen-controls')) { genUpdate(); }
function disableGeo(customerID) {
var geoBtn = document.getElementById('btn-geo-disable');
felhomConfirm(geoBtn, 'Összes geo-korlátozás eltávolítása? Ez közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.', function () {
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>
<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-]+)$/);
var t = m ? m[1] : '';
if (t === 'settings') t = 'edit'; // legacy alias: the Settings tab became Edit (v0.48.0)
return known[t] ? t : '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 {
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>
// Scoped auto-refresh (v0.48.0): the 60s reload fires ONLY while a live tab (the nav's
// data-live-tabs list) is active AND no form on the page is dirty. Dirty = any input/change
// anywhere (delegated listener, never reset — a reload resets it naturally). A skipped tick
// reschedules, so switching back to a live tab resumes on the next tick. The toggle +
// localStorage key + 60s cadence + default-on behavior are unchanged.
(function() {
var toggle = document.getElementById('autoRefreshToggle');
if (!toggle) return;
var key = 'hub_auto_refresh';
var enabled = localStorage.getItem(key) !== 'off';
var timer = null;
var dirty = false;
var nav = document.getElementById('tab-nav');
var liveTabs = ((nav && nav.getAttribute('data-live-tabs')) || '').split(',');
var hint = document.getElementById('refresh-paused-hint');
toggle.checked = enabled;
function activeTab() {
var m = (location.hash || '').match(/^#tab=([a-z-]+)$/);
var t = m ? m[1] : 'overview';
if (t === 'settings') t = 'edit'; // legacy alias, same as the tabs script
return t;
}
function onLiveTab() { return liveTabs.indexOf(activeTab()) !== -1; }
function updateHint() {
if (hint) hint.style.display = (!onLiveTab() || dirty) ? 'inline' : 'none';
}
function tick() {
if (toggle.checked && onLiveTab() && !dirty) { location.reload(); return; }
schedule(); // skipped (non-live tab or dirty form) — try again next tick
}
function schedule() {
clearTimeout(timer);
if (toggle.checked) timer = setTimeout(tick, 60000);
}
function markDirty(e) {
if (e.target === toggle) return; // the auto-refresh toggle itself is not form input
dirty = true;
updateHint();
}
document.addEventListener('input', markDirty);
document.addEventListener('change', markDirty);
window.addEventListener('hashchange', updateHint);
toggle.addEventListener('change', function() {
localStorage.setItem(key, this.checked ? 'on' : 'off');
updateHint();
schedule();
});
updateHint();
schedule();
})();
</script>
{{end}}
</body>
</html>