Files
felhom.eu/hub/internal/web/templates/host_detail_body.html
T
admin e07d90f0f4 hub v0.85.0 — Network card: a host's addresses are visible at last
Pairs with agent v0.119.0 and is useless without it.

A managed box's LAN IP was not shown anywhere in the hub, because nothing
reported it — the host report carried no address of any kind. The only IP
reachable from the UI at all was the WireGuard one, on /offsite's peer table
keyed by pubkey, so an operator could go peer->host and never host->peer, which
is the direction anyone actually asks in.

The host page grows a Network card: every routable address the box holds, one row
per (interface, address), plus a WireGuard row. On demo-felhom that is vmbr0
192.168.0.162/24 and tailscale0 100.70.170.35/32 — with the PVE web console at
https://<the LAN address>:8006, the thing the operator wanted and could not get.

WireGuard is rendered as TWO facts, deliberately. WGAssignedIP is the hub's own
allocation (wg_peers, authoritative desired state); WGConfirmed is whether the box
reports actually holding it. Showing the allocation alone would make a peer that
was never applied look healthy — the same shape as reading a timestamp that
records an attempt as if it recorded a result.

The split is keyed on the ALLOCATION, not the interface name: wg-felhom is the
agent's current unit name, and a UI keyed on that string would silently
mis-render the day it changes.

An old agent renders UNKNOWN, never "no addresses". Below agent 0.119.0 the field
is absent from the wire, and an absent signal is not a negative result — the page
says so and names the version needed. Rendering an empty list there would have
stated something false about the host.

No new store table and no new ingest path: the report is already stored opaquely
and GetWGPeerForHost already existed with no UI consumer. This is parse + render.

The report fixture in the tests is the REAL wire — the addresses block copied out
of `felhom-agent --selftest=hub` on demo-felhom running 0.119.0.

Tests 559 -> 566; four red-proofs (inert view-model, unconditional confirmation,
the old-agent branch, and the drift case) each run, observed failing, reverted.
2026-07-31 08:49:44 +02:00

495 lines
28 KiB
HTML

{{/* host_detail_body — the shared per-host detail sections, rendered by BOTH the
standalone /hosts/{id} page (host_detail.html) and the customer page's Host tab
(customer_unified.html, one instance per host). Data = the map built by
hostDetailData (web/hosts.go); never carries api_key/escrow/PBS secret values. */}}
{{define "host_detail_body"}}
<!-- Identity + Status -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">{{.HostID}}</h2>
<span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span>
</div>
<div class="info-grid" style="margin-top: 1rem;">
<div class="info-item">
<span class="label">Host ID</span>
<span class="value" style="font-family: var(--font-mono)">{{.HostID}}</span>
</div>
<div class="info-item">
<span class="label">Customer</span>
<span class="value"><a href="/customers/{{.CustomerID}}">{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</a></span>
</div>
<div class="info-item">
<span class="label">Agent Version</span>
<span class="value">{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</span>
</div>
{{if .WrapperDrift}}
<div class="info-item">
<span class="label">PBS wrapper</span>
{{if eq .WrapperDrift "ok"}}
<span class="value">matches vouched <code>{{slice .ReportedWrapperSHA 0 12}}…</code></span>
{{else}}
<span class="value" style="color: var(--yellow)">DRIFT — installed <code>{{slice .ReportedWrapperSHA 0 12}}…</code>, vouched <code>{{slice .VouchedWrapperSHA 0 12}}…</code><br>
<span style="font-size:.85em">/usr/local/sbin/felhom-pbs-apply differs from the manifest. It is delivered unversioned from <code>main</code> (R-50b), so this host may be running privileged code from a different commit.</span></span>
{{end}}
</div>
{{end}}
<div class="info-item">
<span class="label">Enrolled</span>
<span class="value">{{timeAgo .CreatedAt}}</span>
</div>
<div class="info-item">
<span class="label">Last Report</span>
<span class="value">{{if .HasReport}}{{timeAgoPtr .LastReportAt}}{{else}}waiting for first report{{end}}</span>
</div>
<div class="info-item">
<span class="label">Desired Generation</span>
<span class="value">{{.DesiredGeneration}}</span>
</div>
{{if .RecoveryMode}}
<div class="info-item">
<span class="label">Recovery Mode</span>
<span class="value" style="color: var(--yellow)">ACTIVE (until {{timeAgoPtr .RecoveryUntil}})</span>
</div>
{{end}}
</div>
</section>
{{if .HasReport}}
<!-- Vitals -->
<section class="card">
<h2>Vitals</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">CPU</span>
<span class="value">{{formatFloat .Vitals.CPUPercent}}%</span>
</div>
<div class="info-item">
<span class="label">Memory</span>
<span class="value">{{formatFloat .Vitals.MemoryPercent}}%</span>
</div>
<div class="info-item">
<span class="label">Disk (root fs)</span>
<span class="value">{{formatFloat .Vitals.DiskPercent}}%</span>
</div>
<div class="info-item">
<span class="label">Cloudflared</span>
<span class="value">{{if .Vitals.CloudflaredStatus}}{{.Vitals.CloudflaredStatus}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Guests</span>
<span class="value">{{.GuestRunning}}/{{.GuestTotal}} running</span>
</div>
</div>
</section>
{{else}}
<section class="card">
<div class="empty-state">
<p>Waiting for first report.</p>
<p class="hint">Vitals, guests and storage appear once this host's agent sends a host-report.</p>
</div>
</section>
{{end}}
<!-- Guests -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Guests</h2>
{{if .Guests}}
<table class="data-table">
<thead>
<tr>
<th>VMID</th>
<th>Name</th>
<th>Status</th>
<th>Controller</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{{range .Guests}}
<tr>
<td>{{.VMID}}</td>
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
<td>{{if eq .Status "running"}}<span style="color: var(--green)">{{.Status}}</span>{{else if eq .Status "stopped"}}<span style="color: var(--red)">{{.Status}}</span>{{else}}{{.Status}}{{end}}</td>
<td>{{if .ControllerVersion}}<code>{{.ControllerVersion}}</code>{{else}}—{{end}}</td>
<td>{{timeAgoPtr .LastSeenAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No guests reported on this host.</p>
</div>
{{end}}
</section>
<!-- Storage Targets -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Storage Targets</h2>
{{if .StorageTargets}}
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Type</th>
<th>State</th>
<th>Fill</th>
<th>Thin Pool</th>
<th>SMART</th>
<th>Temp</th>
<th>Wear</th>
</tr>
</thead>
<tbody>
{{range .StorageTargets}}
<tr>
<td>{{.Name}}</td>
<td>{{if .Role}}{{.Role}}{{else}}—{{end}}</td>
<td>{{if .Type}}{{.Type}}{{else}}—{{end}}</td>
<td>{{if .State}}{{.State}}{{else}}—{{end}}</td>
<td>{{formatFloat .FillPct}}%</td>
<td>{{if .HasThin}}{{formatFloat .ThinDataPct}}%{{else}}—{{end}}</td>
<td>{{if .SmartHealth}}{{if eq .SmartHealth "PASSED"}}<span style="color: var(--green)">{{.SmartHealth}}</span>{{else if eq .SmartHealth "FAILED"}}<span style="color: var(--red)">{{.SmartHealth}}</span>{{else}}{{.SmartHealth}}{{end}}{{else}}—{{end}}</td>
<td>{{if .TempC}}{{.TempC}}°C{{else}}—{{end}}</td>
<td>{{if .WearPct}}{{.WearPct}}%{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No storage targets reported.</p>
</div>
{{end}}
</section>
<!-- Capabilities (v0.51.0): the agent's privileged-capability self-check, rendered.
inactive = disabled by configuration (neutral, DR tier off) — NOT degraded. -->
<section class="card" style="padding: 0; overflow: hidden;">
<h2 style="padding: 1.25rem 1.25rem 0.5rem;">Capabilities</h2>
{{if .NeedsDRMigration}}
<p class="hint" style="padding: 0 1.25rem; color: var(--text-muted); font-size: 0.85rem;">
This box predates the uniform DR plumbing (host-install &lt; v1.15.0) — the pbsdr
wrapper is missing. Migration one-liner set (as root on the host): fetch
<code>configs/felhom-pbs-apply</code> from the agent repo to
<code>/usr/local/sbin/felhom-pbs-apply</code> (0755), <code>apt-get install -y age</code>,
set <code>wg_tunnel.enabled: true</code> in agent.json, restart felhom-agent —
see documentation/runbooks/day0-install.md (retrofit).
</p>
{{end}}
{{if .Capabilities}}
<table class="data-table">
<thead>
<tr>
<th>Capability</th>
<th>Status</th>
<th>Feature / reason</th>
</tr>
</thead>
<tbody>
{{range .Capabilities}}
<tr>
<td>{{.Name}}{{if .Critical}} <span class="badge badge-neutral">critical</span>{{end}}</td>
<td><span class="badge {{.Class}}">{{.Status}}</span></td>
<td>{{.Feature}}{{if .Reason}} — {{.Reason}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No capability report yet (waiting for the first host report).</p>
</div>
{{end}}
</section>
<!-- Diagnostics: component log bundles (v0.46.0) -->
<section class="card">
<h2>Diagnostics — Log Bundles</h2>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
Pull-based: the box ships its debug ring on its own next cycle — controller &le; one report interval (~15 min),
agent &asymp; one heartbeat. The pull is recorded in the box's own log (customer-visible). Bundles expire after 72 h.
</p>
<div style="display: flex; gap: 0.5rem; margin: 0.75rem 0;">
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="controller">
<button type="submit" class="btn btn-sm">Request controller logs</button>
</form>
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="component" value="agent">
<button type="submit" class="btn btn-sm">Request agent logs</button>
</form>
</div>
{{if .LogBundles}}
<table class="data-table">
<thead>
<tr>
<th>Component</th>
<th>State</th>
<th>Collected</th>
<th>Received</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogBundles}}
<tr>
<td>{{.Component}}</td>
<td>
{{if eq .State "pending"}}<span class="badge badge-neutral" title="Waiting for the box's next cycle (requested {{timeAgo .RequestedAt}})">pending</span>
{{else if eq .State "blocked"}}<span class="badge badge-error" title="{{.BlockedNote}}">blocked: possible secret</span>
{{else}}<span class="badge badge-ok">available</span>{{end}}
</td>
<td>{{if .CollectedAt.IsZero}}—{{else}}{{timeAgo .CollectedAt}}{{end}}</td>
<td>{{if .ReceivedAt.IsZero}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
<td>{{if .SizeBytes}}{{.SizeBytes}} B{{else}}—{{end}}</td>
<td>
{{if eq .State "available"}}
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}" class="btn btn-sm">View</a>
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}?download=1" class="btn btn-sm">Download</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state" style="border: none;">
<p>No log bundles. Use the request buttons above — the box delivers on its next cycle.</p>
</div>
{{end}}
</section>
<!-- Network (v0.85.0): where this box actually is. Addresses come from the agent's
addresses[] (agent >= 0.119.0); the WireGuard row pairs the HUB's allocation with
whether the box confirms holding it, because an allocation alone cannot tell a live
tunnel from a peer that was never applied. -->
<section class="card">
<h2>Network</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">WireGuard</span>
<span class="value">
{{if .Network.WGAssignedIP}}<code>{{.Network.WGAssignedIP}}</code>
{{if .Network.WGConfirmed}}<span class="badge badge-ok" title="The box reports holding this address">confirmed</span>
{{else if .Network.AgentTooOld}}<span class="badge badge-neutral" title="This agent does not report its addresses">allocated</span>
{{else}}<span class="badge badge-warn" title="The hub allocated this peer but the box does not report holding the address">not confirmed by the box</span>{{end}}
{{else}}<span class="text-muted">no peer allocated</span>{{end}}
</span>
</div>
</div>
{{if .Network.AgentTooOld}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
This host's agent (<code>{{.AgentVersion}}</code>) does not report its addresses &mdash; they are
<strong>unknown</strong>, not absent. Needs agent <code>0.119.0</code> or newer.
</p>
{{else if .Network.Addresses}}
<table class="data-table" style="margin-top: 0.75rem;">
<thead>
<tr><th>Interface</th><th>Address</th></tr>
</thead>
<tbody>
{{range .Network.Addresses}}
<tr>
<td><code>{{.Iface}}</code></td>
<td><code>{{.CIDR}}</code></td>
</tr>
{{end}}
</tbody>
</table>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
Every routable address the box holds, as the kernel sees it. Loopback and link-local are
excluded &mdash; including the <code>169.254.253.1</code> local-API island, which is identical on
every box. The PVE web console is at <code>https://&lt;the LAN address&gt;:8006</code>.
</p>
{{else if .HasReport}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
The agent reports no routable address on this host.
</p>
{{else}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
Waiting for the first host report.
</p>
{{end}}
</section>
<!-- DR / Backup -->
<section class="card">
<h2>DR / Backup</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">DR Recipe</span>
<span class="value">{{if .DRPresent}}<span style="color: var(--green)">present</span>{{else}}<span class="text-muted">none</span>{{end}}</span>
</div>
<div class="info-item">
<span class="label">Key Escrow</span>
<span class="value">{{if .EscrowPresent}}<span style="color: var(--green)">present</span>{{else}}<span class="text-muted">none</span>{{end}}{{if gt .SupersededEscrowCount 0}} <span class="text-muted">· {{.SupersededEscrowCount}} superseded escrow blob(s) retained</span>{{end}}</span>
</div>
</div>
</section>
<!-- Console access (v0.84.0): the break-glass root@pam credential. The page carries
presence + username + set_at ONLY — the plaintext NEVER enters this document and is
fetched on demand from POST /hosts/{id}/reveal-recovery-credential. Deliberately NOT
the customer_unified data-secret widget, which embeds the plaintext on every load. -->
<section class="card">
<h2>Console access</h2>
{{if .RecoveryVaulted}}
<div class="info-grid">
<div class="info-item">
<span class="label">User</span>
<span class="value"><code>{{.RecoveryUsername}}</code></span>
</div>
<div class="info-item">
<span class="label">Password set</span>
<span class="value">{{timeAgo .RecoverySetAt}}</span>
</div>
</div>
<div class="credential-box">
<code id="console-pw-{{.HostID}}">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
<button type="button" class="copy-btn" id="console-reveal-{{.HostID}}" data-reveal-url="/hosts/{{.HostID}}/reveal-recovery-credential" onclick="revealConsolePassword('{{.HostID}}')">Reveal</button>
<button type="button" class="copy-btn" id="console-copy-{{.HostID}}" onclick="copyConsolePassword('{{.HostID}}')" disabled>Copy</button>
</div>
<p class="hint" id="console-hint-{{.HostID}}" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
Break-glass credential for the PVE web console at https://&lt;host-ip&gt;:8006 (realm: Linux PAM standard authentication). Revealing it is recorded on the customer's event timeline. Last vaulted value — if root@pam was changed on the box without re-vaulting, this is stale.
</p>
{{else}}
<p><span class="badge badge-neutral">not vaulted</span></p>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
No console credential is vaulted for this host. Expected for a byo host — the owner manages root@pam. Otherwise the installer's step 4b did not run; re-run felhom-host-install.sh, or set and vault one per the break-glass runbook &sect;5.
</p>
{{end}}
</section>
{{if .RecoveryVaulted}}
<script>
// Fetch-on-demand: the password exists in this document ONLY between a Reveal and the next
// mask, and only in a local variable — never in localStorage, a data- attribute or the URL.
// `|| {}` because the customer page renders this sub-template once per host: a plain
// re-assignment would drop a sibling card's mask timer on the floor.
var consolePwState = typeof consolePwState !== 'undefined' ? consolePwState : {};
var consolePwMask = '••••••••••••••••';
function maskConsolePassword(hostID) {
var st = consolePwState[hostID];
if (st && st.timer) { clearTimeout(st.timer); }
consolePwState[hostID] = null; // CLEARS the retained plaintext
var code = document.getElementById('console-pw-' + hostID);
if (code) { code.textContent = consolePwMask; }
var reveal = document.getElementById('console-reveal-' + hostID);
if (reveal) { reveal.textContent = 'Reveal'; }
var copy = document.getElementById('console-copy-' + hostID);
if (copy) { copy.disabled = true; }
}
function revealConsolePassword(hostID) {
if (consolePwState[hostID]) { maskConsolePassword(hostID); return; } // second click hides
var hint = document.getElementById('console-hint-' + hostID);
var code = document.getElementById('console-pw-' + hostID);
var btn = document.getElementById('console-reveal-' + hostID);
code.textContent = 'Revealing…';
// The endpoint comes from the button's data-reveal-url — the SAME string a render test
// asserts, so the assertion cannot pass while the fetch targets somewhere else.
fetch(btn.getAttribute('data-reveal-url'), {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'}
}).then(function(r){
if (!r.ok) { throw new Error('HTTP ' + r.status); }
return r.json();
}).then(function(d){
code.textContent = d.password;
consolePwState[hostID] = {pw: d.password, timer: setTimeout(function(){ maskConsolePassword(hostID); }, 60000)};
document.getElementById('console-reveal-' + hostID).textContent = 'Hide';
document.getElementById('console-copy-' + hostID).disabled = false;
}).catch(function(e){
code.textContent = consolePwMask;
hint.textContent = 'Could not reveal the credential (' + e.message + '). The global-key curl path in the break-glass runbook §3.1 still works.';
});
}
function copyConsolePassword(hostID) {
var st = consolePwState[hostID];
if (!st) { return; }
var pw = st.pw;
if (navigator.clipboard) { navigator.clipboard.writeText(pw); }
maskConsolePassword(hostID); // re-mask after use
}
document.addEventListener('visibilitychange', function(){
if (document.visibilityState === 'hidden') {
for (var id in consolePwState) { if (consolePwState[id]) { maskConsolePassword(id); } }
}
});
</script>
{{end}}
{{if .Deletable}}
<!-- Danger zone (v0.47.0): rendered ONLY for non-online hosts — deleting a live
host would brick its heartbeat channel, so the affordance never exists for one.
Impact + type-to-confirm dialog per the global-floor confirm pattern. -->
<section class="card" style="border-color: var(--crit);">
<h2>Danger zone</h2>
<p class="text-muted" style="font-size: 0.85rem;">
Removing this host deletes its reports, guests, log bundles and WireGuard peer, and
permanently invalidates its API key — a still-running agent would receive 401s.
Re-enrollment requires the Day-0 passphrase flow.
</p>
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteConfirm('{{.HostID}}')">Remove host&hellip;</button>
<div id="host-delete-confirm-{{.HostID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
<p id="host-delete-impact-{{.HostID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">&hellip;</p>
<label id="host-delete-escrow-row-{{.HostID}}" style="display: none; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="host-delete-escrow-{{.HostID}}">
Move key escrow to retained custody (required when escrow present) + remove DR bundle for this host
</label>
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: var(--text-2);">Type the host id to confirm:</p>
<form method="POST" action="/hosts/{{.HostID}}/delete" id="host-delete-form-{{.HostID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="confirm_host_id" id="host-delete-confirm-hidden-{{.HostID}}" value="">
<input type="hidden" name="delete_escrow" id="host-delete-escrow-hidden-{{.HostID}}" value="">
<input type="text" id="host-delete-confirm-input-{{.HostID}}" placeholder="retype the host id&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteSubmit('{{.HostID}}')">Confirm &amp; remove</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('host-delete-confirm-{{.HostID}}').style.display='none';">Cancel</button>
</form>
<p id="host-delete-err-{{.HostID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function hostDeleteConfirm(hostID) {
var box = document.getElementById('host-delete-confirm-' + hostID);
var impact = document.getElementById('host-delete-impact-' + hostID);
document.getElementById('host-delete-confirm-input-' + hostID).value = '';
document.getElementById('host-delete-err-' + hostID).textContent = '';
box.style.display = 'block';
impact.textContent = 'Checking impact…';
fetch('/hosts/' + encodeURIComponent(hostID) + '/delete-impact')
.then(function(r){ return r.json(); })
.then(function(d){
var parts = ['Deleting ' + hostID + ' removes ' + d.guests + ' guest row(s), ' +
d.reports + ' host report(s), ' + d.log_bundles + ' agent log bundle(s)'];
if (d.wg_peer_bound) parts.push('the bound WireGuard peer');
if (d.pbs_secret_present) parts.push('the staged PBS secret');
if (d.recovery_present) parts.push('the break-glass recovery credential');
impact.textContent = parts.join(', ') + '. Host status: ' + d.status + '.' +
(d.deletable ? '' : ' Host is ONLINE — deletion will be refused.');
document.getElementById('host-delete-escrow-row-' + hostID).style.display =
d.escrow_present ? 'block' : 'none';
})
.catch(function(){ impact.textContent = 'Could not compute the impact — the server will still enforce every gate.'; });
}
function hostDeleteSubmit(hostID) {
var typed = document.getElementById('host-delete-confirm-input-' + hostID).value.trim();
var err = document.getElementById('host-delete-err-' + hostID);
if (typed !== hostID) { err.textContent = 'Confirmation does not match the host id.'; return; }
var escrowRow = document.getElementById('host-delete-escrow-row-' + hostID);
var escrowCb = document.getElementById('host-delete-escrow-' + hostID);
if (escrowRow.style.display !== 'none' && !escrowCb.checked) {
err.textContent = 'This host has a key escrow — tick the acknowledgement to delete it too.';
return;
}
document.getElementById('host-delete-confirm-hidden-' + hostID).value = typed;
document.getElementById('host-delete-escrow-hidden-' + hostID).value = escrowCb.checked ? '1' : '';
document.getElementById('host-delete-form-' + hostID).submit();
}
</script>
{{end}}
{{end}}