146d165c26
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
301 lines
17 KiB
HTML
301 lines
17 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>
|
|
<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>
|
|
|
|
<!-- 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 ≤ one report interval (~15 min),
|
|
agent ≈ 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>
|
|
|
|
<!-- 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}}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{{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…</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;">…</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}}">
|
|
Also delete the key escrow + 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…" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteSubmit('{{.HostID}}')">Confirm & 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}}
|