Files
heartbeat/hbd/server/templates/plugins.html
T
Andreas Wrede fa317a3b78 feat: add dark mode with light/dark/auto theme setting
Theme preference stored in localStorage (auto follows the OS setting).
The chosen data-theme attribute is applied synchronously in <head> to
avoid any flash of unstyled content. CSS custom properties handle all
surface, text, border and input colours across every page. The
Appearance section on the profile page lets each user switch modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 22:33:37 -04:00

1454 lines
58 KiB
HTML

<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
body { overflow: hidden; }
.container {
max-width: 1400px;
margin: 0 auto;
max-height: calc(100vh - 120px);
overflow-y: auto;
padding-right: 10px;
}
h1 {
color: #333;
margin-bottom: 5px;
margin-top: 15px;
font-size: 1.5em;
}
.subtitle {
color: #666;
margin-bottom: 15px;
font-size: 0.9em;
}
/* ── Host cards ─────────────────────────────────────────────── */
.host-card {
background: white;
border-radius: 6px;
padding: 0;
margin-bottom: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.host-header {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
padding: 10px 15px;
border-radius: 6px;
}
.host-header:hover { background: #f9f9f9; border-radius: 6px 6px 0 0; }
.host-card.collapsed .host-header:hover { border-radius: 6px; }
.host-left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.collapse-icon {
font-size: 1em;
color: #888;
transition: transform 0.2s;
min-width: 16px;
}
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
.host-name {
font-size: 1.05em;
font-weight: bold;
color: #333;
white-space: nowrap;
}
/* ── Glance strip ───────────────────────────────────────────── */
.glance-strip {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
padding: 0 12px;
}
.glance-chip {
font-size: 0.78em;
padding: 2px 9px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
background: #e8f5e9;
color: #2e7d32;
}
.glance-chip.warn { background: #fff3e0; color: #e65100; }
.glance-chip.crit { background: #ffebee; color: #b71c1c; }
.glance-chip.neutral { background: #f5f5f5; color: #555; }
.glance-loading { font-size: 0.8em; color: #bbb; font-style: italic; }
/* ── Host right zone ────────────────────────────────────────── */
.host-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.nagios-badge {
font-size: 0.75em;
font-weight: bold;
padding: 2px 10px;
border-radius: 10px;
background: #9e9e9e;
color: white;
text-transform: uppercase;
white-space: nowrap;
}
.nagios-badge.ok { background: #4caf50; }
.nagios-badge.warning { background: #ff9800; }
.nagios-badge.critical { background: #f44336; }
.os-label {
font-size: 0.75em;
color: #999;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.host-action-btn {
font-size: 0.75em;
font-weight: bold;
padding: 3px 10px;
border-radius: 4px;
border: none;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.host-action-btn.update-btn {
background: #e3f2fd;
color: #1565c0;
}
.host-action-btn.update-btn:hover { background: #bbdefb; }
.host-action-btn.delete-btn {
background: #ffebee;
color: #c62828;
}
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
/* ── Action result toast ───────────────────────────────────── */
#action-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #323232;
color: #fff;
padding: 12px 22px;
border-radius: 6px;
font-size: 0.9em;
max-width: 480px;
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 9000;
white-space: pre-wrap;
}
#action-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#action-toast.error { background: #c62828; }
/* ── Host body ──────────────────────────────────────────────── */
.host-body {
padding: 8px 15px 12px;
border-top: 1px solid #f0f0f0;
}
.host-card.collapsed .host-body { display: none; }
/* ── Plugin accordions ──────────────────────────────────────── */
.plugin-accordion {
border: 1px solid #e8e8e8;
border-radius: 4px;
margin-bottom: 5px;
overflow: hidden;
}
.plugin-acc-header {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
cursor: pointer;
background: #fafafa;
user-select: none;
}
.plugin-acc-header:hover { background: #f0f4ff; }
.acc-icon {
font-size: 0.7em;
color: #999;
transition: transform 0.15s;
min-width: 12px;
}
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
.plugin-label {
font-weight: 600;
font-size: 1.00em;
color: #444;
min-width: 140px;
}
.plugin-summary {
font-size: 0.82em;
color: #888;
flex: 1;
}
.plugin-accordion.collapsed .plugin-acc-body { display: none; }
.plugin-acc-body { padding: 10px 12px; }
/* ── Tables ─────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 1.00em;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.data-table thead { background: #2196f3; color: white; }
.data-table th {
padding: 7px 10px;
text-align: left;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75em;
letter-spacing: 0.4px;
}
.data-table th.num { text-align: right; }
.data-table th.center { text-align: center; }
.data-table td {
/* padding: 6px 10px; */
border-top: 1px solid #e8e8e8;
color: #333;
}
.data-table td.num {
text-align: right;
font-family: 'Courier New', monospace;
font-size: 0.95em;
}
.data-table td.center { text-align: center; }
.data-table td.key { color: #666; font-weight: 500; width: 38%; }
.data-table tbody tr:nth-child(even) { background: #fafafa; }
.data-table tbody tr:hover { background: #f0f4ff; }
.iface-name { font-weight: bold; color: #2196f3; }
/* ── Percent bars ───────────────────────────────────────────── */
.bar-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
display: inline-block;
width: 70px;
height: 6px;
background: #e0e0e0;
border-radius: 3px;
vertical-align: middle;
flex-shrink: 0;
}
.bar-fill {
height: 6px;
border-radius: 3px;
background: #4caf50;
max-width: 100%;
}
.bar-fill.warn { background: #ff9800; }
.bar-fill.crit { background: #f44336; }
/* ── Disk two-table layout ──────────────────────────────────── */
.flex-tables {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.flex-tables > div { flex: 1 1 380px; }
.table-section-label {
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
color: #888;
letter-spacing: 0.4px;
margin-bottom: 4px;
}
/* ── Status / misc ──────────────────────────────────────────── */
.status-up { color: #4caf50; font-weight: bold; }
.status-down { color: #f44336; font-weight: bold; }
.pct-ok { color: #2e7d32; font-weight: bold; }
.pct-warn { color: #e65100; font-weight: bold; }
.pct-crit { color: #b71c1c; font-weight: bold; }
.check-ok { background: #e8f5e9; }
.check-warning { background: #fff3e0; }
.check-critical { background: #ffebee; }
.check-unknown { background: #f5f5f5; }
.check-status-ok { color: #2e7d32; font-weight: bold; }
.check-status-warning { color: #e65100; font-weight: bold; }
.check-status-critical { color: #b71c1c; font-weight: bold; }
.check-status-unknown { color: #777; font-weight: bold; }
.check-output { font-size: 0.9em; color: #555; }
.timestamp {
color: #bbb;
font-size: 0.75em;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
.no-data {
text-align: center;
padding: 20px;
color: #aaa;
font-style: italic;
font-size: 0.9em;
}
.loading {
text-align: center;
padding: 12px;
color: #aaa;
font-size: 1.00em;
}
.error {
background: #ffebee;
border-left: 3px solid #f44336;
padding: 8px 12px;
margin: 8px 0;
border-radius: 3px;
color: #c62828;
font-size: 1.00em;
}
/* ── Scrollbar ──────────────────────────────────────────────── */
.container::-webkit-scrollbar { width: 8px; }
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
.container::-webkit-scrollbar-thumb:hover { background: #999; }
/* ── Host info section ──────────────────────────────────────────────────── */
.host-info-section {
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
font-size: 1.00em;
}
.info-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
margin-bottom: 10px;
}
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
.info-value { color: #222; }
.info-thresholds-title {
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.info-note { color: #888; font-style: italic; }
.info-loading { color: #bbb; font-style: italic; }
.threshold-covers { font-size: 1.00em; color: #777; font-style: italic; }
/* ── Dark mode ── */
html[data-theme="dark"] h1 { color: var(--text); }
html[data-theme="dark"] .subtitle { color: var(--text-sec); }
html[data-theme="dark"] .host-card { background: var(--surface); }
html[data-theme="dark"] .host-header:hover { background: var(--surface-2); }
html[data-theme="dark"] .host-name { color: var(--text); }
html[data-theme="dark"] .collapse-icon,
html[data-theme="dark"] .acc-icon { color: var(--text-muted); }
html[data-theme="dark"] .host-body { border-top-color: var(--border-3); }
html[data-theme="dark"] .plugin-accordion { border-color: var(--border); }
html[data-theme="dark"] .plugin-acc-header { background: var(--surface-2); }
html[data-theme="dark"] .plugin-acc-header:hover { background: var(--surface-3); }
html[data-theme="dark"] .plugin-label { color: var(--text-2); }
html[data-theme="dark"] .plugin-summary { color: var(--text-muted); }
html[data-theme="dark"] .data-table { background: var(--surface); }
html[data-theme="dark"] .data-table td { border-top-color: var(--border); color: var(--text); }
html[data-theme="dark"] .data-table td.key { color: var(--text-sec); }
html[data-theme="dark"] .data-table tbody tr:nth-child(even) { background: var(--surface-2); }
html[data-theme="dark"] .data-table tbody tr:hover { background: #1e3a5f; }
html[data-theme="dark"] .bar-track { background: var(--border); }
html[data-theme="dark"] .table-section-label { color: var(--text-muted); }
html[data-theme="dark"] .no-data,
html[data-theme="dark"] .loading { color: var(--text-dim); }
html[data-theme="dark"] .timestamp { color: var(--text-dim); border-top-color: var(--border-3); }
html[data-theme="dark"] .glance-chip.neutral { background: var(--surface-3); color: var(--text-sec); }
html[data-theme="dark"] .os-label { color: var(--text-muted); }
html[data-theme="dark"] .host-info-section { background: var(--surface-2); border-bottom-color: var(--border); }
html[data-theme="dark"] .info-label { color: var(--text-3); }
html[data-theme="dark"] .info-value { color: var(--text); }
html[data-theme="dark"] .info-thresholds-title { color: var(--text-3); }
html[data-theme="dark"] .info-note,
html[data-theme="dark"] .info-loading,
html[data-theme="dark"] .threshold-covers { color: var(--text-muted); }
html[data-theme="dark"] .check-ok { background: #0d2e17; }
html[data-theme="dark"] .check-warning { background: #2e1a00; }
html[data-theme="dark"] .check-critical { background: #2e0a0a; }
html[data-theme="dark"] .check-unknown { background: var(--surface-2); }
html[data-theme="dark"] .check-output { color: var(--text-sec); }
html[data-theme="dark"] .container::-webkit-scrollbar-track { background: var(--surface-2); }
html[data-theme="dark"] .container::-webkit-scrollbar-thumb { background: var(--border); }
</style>
<body>
{% include 'nav.html' %}
<div class="container">
<h1>{{ header }}</h1>
<p class="subtitle">Per-host system metrics — expand a host to see plugin details</p>
{% if not hosts %}
<div class="no-data">
<p>No hosts with plugin data available</p>
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending plugin metrics</p>
</div>
{% else %}
<div id="hosts-container">
{% for host in hosts %}
{% set plugins_csv = host.plugins | join(',') %}
<div class="host-card collapsed"
data-hostname="{{ host.name }}"
data-plugins="{{ plugins_csv }}">
<div class="host-header" onclick="toggleHost('{{ host.name }}')">
<div class="host-left">
<span class="collapse-icon"></span>
<span class="host-name">{{ host.name }}</span>
</div>
<div class="glance-strip" id="glance-{{ host.name }}" data-owner="{{ host.owner or '' }}">
{% if current_user and current_user.admin and host.owner %}<span class="glance-chip neutral">{{ host.owner }}</span>{% endif %}
<span class="glance-loading"></span>
</div>
<div class="host-right">
{% if 'nagios_runner' in host.plugins %}
<span class="nagios-badge" id="nagios-badge-{{ host.name }}"></span>
{% endif %}
<span class="os-label" id="os-label-{{ host.name }}"></span>
{% if host.is_owner %}
<button class="host-action-btn update-btn"
onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
<button class="host-action-btn delete-btn"
onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
{% endif %}
</div>
</div>
<div class="host-body">
<div class="host-info-section" id="info-{{ host.name }}">
<div class="info-loading">Loading…</div>
</div>
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
{% for plugin in plugin_order if plugin in host.plugins %}
<div class="plugin-accordion collapsed"
data-hostname="{{ host.name }}"
data-plugin="{{ plugin }}">
<div class="plugin-acc-header"
onclick="togglePlugin('{{ host.name }}', '{{ plugin }}')">
<span class="acc-icon"></span>
<span class="plugin-label">{{ plugin | replace('_',' ') | title }}</span>
<span class="plugin-summary" id="summary-{{ host.name }}-{{ plugin }}">Not loaded</span>
</div>
<div class="plugin-acc-body" id="body-{{ host.name }}-{{ plugin }}">
<div class="loading">Loading…</div>
</div>
</div>
{% endfor %}
{% for plugin in host.plugins | sort if plugin not in plugin_order %}
<div class="plugin-accordion collapsed"
data-hostname="{{ host.name }}"
data-plugin="{{ plugin }}">
<div class="plugin-acc-header"
onclick="togglePlugin('{{ host.name }}', '{{ plugin }}')">
<span class="acc-icon"></span>
<span class="plugin-label">{{ plugin | replace('_',' ') | title }}</span>
<span class="plugin-summary" id="summary-{{ host.name }}-{{ plugin }}">Not loaded</span>
</div>
<div class="plugin-acc-body" id="body-{{ host.name }}-{{ plugin }}">
<div class="loading">Loading…</div>
</div>
</div>
{% endfor %}
</div><!-- .host-body -->
</div><!-- .host-card -->
{% endfor %}
</div>
{% endif %}
</div>
<script>
// ── Constants ───────────────────────────────────────────────────────────
const GLANCE_PLUGINS = ['cpu_monitor','memory_monitor','disk_monitor',
'network_monitor','nagios_runner','os_info'];
const SKIP_FIELDS = new Set(['id','name']);
const CURRENT_USER_ADMIN = {{ 'true' if current_user and current_user.admin else 'false' }};
// ── Cache ───────────────────────────────────────────────────────────────
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
const pluginCache = {};
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
const infoCache = {};
function setCache(hostname, pluginName, sample) {
if (!pluginCache[hostname]) pluginCache[hostname] = {};
pluginCache[hostname][pluginName] = {
data: sample.data,
timestamp: sample.timestamp,
fetchedAt: Date.now(),
};
}
function getCache(hostname, pluginName) {
return pluginCache[hostname]?.[pluginName] ?? null;
}
// Return worst nagios exit code (0-3) found in a nagios_runner data object.
function nagiosWorstStatus(data) {
let worst = 0;
for (const [k, v] of Object.entries(data || {})) {
if (k.endsWith('_status_code') && typeof v === 'number' && v > worst) {
worst = v;
}
}
return worst;
}
// ── Fetch helpers ───────────────────────────────────────────────────────
async function fetchPlugin(hostname, pluginName) {
const r = await fetch(`/api/0/hosts/${hostname}/plugins/${pluginName}?limit=1`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const json = await r.json();
return json.samples?.[0] ?? null;
}
async function fetchHostInfo(hostname) {
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
}
function renderInfoSection(hostname, data) {
const el = document.getElementById(`info-${hostname}`);
if (!el) return;
const owner = data.owner ? escHtml(data.owner) : '—';
const managers = data.managers && data.managers.length
? data.managers.map(escHtml).join(', ') : '—';
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
const lastPkt = data.last_packet != null
? new Date(data.last_packet * 1000).toLocaleString() : '—';
let html = `<div class="info-meta">
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
</div>`;
if (data.thresholds === null) {
html += `<div class="info-note">Threshold alerting not configured.</div>`;
} else if (data.thresholds.length === 0) {
html += `<div class="info-note">No thresholds defined.</div>`;
} else {
html += `<div class="info-thresholds-title">Effective Thresholds</div>
<table class="data-table"><thead><tr>
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
</tr></thead><tbody>`;
for (const t of data.thresholds) {
const w = t.warning != null ? escHtml(String(t.warning)) : '—';
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
let metricCell = escHtml(t.metric);
if (t.covers && t.covers.length > 0) {
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
}
html += `<tr>
<td class="key">${metricCell}</td>
<td>${escHtml(t.operator)}</td>
<td>${w}</td>
<td>${c}</td>
</tr>`;
}
html += `</tbody></table>`;
}
el.innerHTML = html;
}
async function fetchHostGlance(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
const toFetch = GLANCE_PLUGINS.filter(p => availablePlugins.includes(p));
const results = await Promise.allSettled(
toFetch.map(p => fetchPlugin(hostname, p))
);
results.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) {
setCache(hostname, toFetch[i], r.value);
}
});
updateHostHeader(hostname);
// Update any open accordion bodies
if (card && !card.classList.contains('collapsed')) {
card.querySelectorAll('.plugin-accordion').forEach(acc => {
const pname = acc.dataset.plugin;
if (!acc.classList.contains('collapsed') && getCache(hostname, pname)) {
renderPluginBody(hostname, pname);
}
updateAccordionSummary(hostname, pname);
});
}
}
// ── Host header ─────────────────────────────────────────────────────────
function updateHostHeader(hostname) {
const strip = document.getElementById(`glance-${hostname}`);
const nagosBadge = document.getElementById(`nagios-badge-${hostname}`);
const osLabel = document.getElementById(`os-label-${hostname}`);
if (!strip) return;
const chips = [];
// Owner (admin only, static from server)
const owner = strip.dataset.owner;
if (CURRENT_USER_ADMIN && owner) {
chips.push(`<span class="glance-chip neutral">${owner}</span>`);
}
// CPU
const cpu = getCache(hostname, 'cpu_monitor');
if (cpu) {
const pct = cpu.data.cpu_percent ?? null;
if (pct !== null) {
const cls = pct > 90 ? 'crit' : pct > 70 ? 'warn' : '';
chips.push(`<span class="glance-chip ${cls}">CPU ${pct.toFixed(0)}%</span>`);
}
}
// MEM
const mem = getCache(hostname, 'memory_monitor');
if (mem) {
const pct = mem.data.memory_percent ?? null;
if (pct !== null) {
const cls = pct > 95 ? 'crit' : pct > 80 ? 'warn' : '';
chips.push(`<span class="glance-chip ${cls}">MEM ${pct.toFixed(0)}%</span>`);
}
}
// Top disk partition
const disk = getCache(hostname, 'disk_monitor');
if (disk && disk.data.partitions) {
let topMount = null, topPct = -1;
for (const [mp, pd] of Object.entries(disk.data.partitions)) {
if ((pd.percent ?? 0) > topPct) { topPct = pd.percent; topMount = mp; }
}
if (topMount !== null) {
const cls = topPct > 90 ? 'crit' : topPct > 75 ? 'warn' : '';
chips.push(`<span class="glance-chip ${cls}">${topMount} ${topPct.toFixed(0)}%</span>`);
}
}
// Net delta (most active interface by recv+sent delta)
const net = getCache(hostname, 'network_monitor');
if (net && net.data.interfaces) {
let bestIface = null, bestTotal = -1;
for (const [iface, idata] of Object.entries(net.data.interfaces)) {
const total = (idata.bytes_recv_delta ?? 0) + (idata.bytes_sent_delta ?? 0);
if (total > bestTotal) { bestTotal = total; bestIface = iface; }
}
if (bestIface) {
const idata = net.data.interfaces[bestIface];
const up = formatBytes(idata.bytes_sent_delta ?? 0) + '/s';
const dn = formatBytes(idata.bytes_recv_delta ?? 0) + '/s';
chips.push(`<span class="glance-chip neutral">${bestIface}${up}${dn}</span>`);
}
}
strip.innerHTML = chips.length
? chips.join('')
: '<span class="glance-loading">—</span>';
// Nagios badge — derive worst status from individual check codes
const nagios = getCache(hostname, 'nagios_runner');
if (nagosBadge && nagios) {
const worst = nagiosWorstStatus(nagios.data);
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
const status = names[worst] || '—';
const cls = worst === 0 ? 'ok' : worst === 1 ? 'warning' : worst >= 2 ? 'critical' : '';
nagosBadge.className = `nagios-badge ${cls}`;
nagosBadge.textContent = status;
}
// OS label
const osData = getCache(hostname, 'os_info');
if (osLabel && osData) {
const d = osData.data;
osLabel.textContent = d.distro_pretty_name || `${d.system || ''} ${d.machine || ''}`.trim();
}
}
// ── Toggle host ─────────────────────────────────────────────────────────
function toggleHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const wasCollapsed = card.classList.contains('collapsed');
card.classList.toggle('collapsed');
if (wasCollapsed) {
if (!pluginCache[hostname]) {
fetchHostGlance(hostname);
}
if (!infoCache[hostname]) {
const infoEl = document.getElementById(`info-${hostname}`);
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {
const el = document.getElementById(`info-${hostname}`);
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
});
}
}
}
// ── Toggle plugin accordion ─────────────────────────────────────────────
function togglePlugin(hostname, pluginName) {
const acc = document.querySelector(
`.plugin-accordion[data-hostname="${hostname}"][data-plugin="${pluginName}"]`
);
if (!acc) return;
const wasCollapsed = acc.classList.contains('collapsed');
acc.classList.toggle('collapsed');
if (wasCollapsed) {
const cached = getCache(hostname, pluginName);
if (cached) {
renderPluginBody(hostname, pluginName);
} else {
// Wave 3: fetch on-demand for non-glance plugins
const body = document.getElementById(`body-${hostname}-${pluginName}`);
if (body) body.innerHTML = '<div class="loading">Loading…</div>';
fetchPlugin(hostname, pluginName).then(sample => {
if (sample) {
setCache(hostname, pluginName, sample);
renderPluginBody(hostname, pluginName);
updateAccordionSummary(hostname, pluginName);
} else {
const b = document.getElementById(`body-${hostname}-${pluginName}`);
if (b) b.innerHTML = '<div class="no-data">No data available</div>';
}
}).catch(err => {
const b = document.getElementById(`body-${hostname}-${pluginName}`);
if (b) b.innerHTML = `<div class="error">Failed to load: ${err.message}</div>`;
});
}
}
}
// ── Accordion summaries ─────────────────────────────────────────────────
function updateAccordionSummary(hostname, pluginName) {
const el = document.getElementById(`summary-${hostname}-${pluginName}`);
if (!el) return;
const cached = getCache(hostname, pluginName);
if (!cached) return;
const d = cached.data;
let text = '';
switch (pluginName) {
case 'os_info':
text = d.distro_pretty_name || `${d.system || '?'} ${d.machine || ''}`.trim();
break;
case 'cpu_monitor': {
const pct = d.cpu_percent != null ? d.cpu_percent.toFixed(1) + '%' : '?';
const load = [d.load_1min, d.load_5min, d.load_15min]
.map(v => v != null ? v.toFixed(2) : '?').join(' / ');
const cores = d.cpu_core_count != null ? `${d.cpu_core_count} cores` : '';
text = `CPU: ${pct} · Load: ${load}${cores ? ' · ' + cores : ''}`;
break;
}
case 'memory_monitor': {
const pct = d.memory_percent != null ? d.memory_percent.toFixed(0) + '%' : '?';
const free = d.memory_available != null ? formatBytes(d.memory_available) : '?';
const total = d.memory_total != null ? formatBytes(d.memory_total) : '?';
const swap = d.swap_percent != null ? ` · Swap: ${d.swap_percent.toFixed(0)}%` : '';
text = `${pct} used · ${free} / ${total} free${swap}`;
break;
}
case 'disk_monitor': {
const parts = d.partitions || {};
const summary = Object.entries(parts)
.sort((a, b) => (b[1].percent || 0) - (a[1].percent || 0))
.slice(0, 3)
.map(([mp, pd]) => `${mp} ${(pd.percent || 0).toFixed(0)}%`)
.join(' · ');
text = summary || 'No partitions';
break;
}
case 'network_monitor': {
const stats = d.interface_stats || {};
const upCount = Object.values(stats).filter(s => s.isup).length;
const total = Object.keys(stats).length;
const established = d.connections?.ESTABLISHED ?? d.connections?.established ?? null;
text = total ? `${upCount}/${total} ifaces up` : '';
if (established !== null) text += ` · ${established} TCP ESTABLISHED`;
break;
}
case 'nagios_runner': {
const worst = nagiosWorstStatus(d);
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
const codes = Object.keys(d).filter(k => k.endsWith('_status_code'));
text = (names[worst] || '?') + (codes.length ? `${codes.length} checks` : '');
break;
}
case 'filesystem_info': {
const count = (d.filesystems || []).length;
text = `${count} filesystem${count !== 1 ? 's' : ''}`;
break;
}
case 'zfs_monitor': {
const pools = d.pools || {};
const names = Object.keys(pools);
if (names.length === 0) { text = 'No pools'; break; }
const degraded = names.filter(n => pools[n].health && pools[n].health !== 'ONLINE');
text = names.map(n => {
const p = pools[n];
const cap = p.capacity != null ? ` ${p.capacity.toFixed(0)}%` : '';
return `${n}${cap}`;
}).join(' · ');
if (degraded.length) text += `${degraded.map(n => pools[n].health).join(',')}`;
break;
}
default:
text = 'Loaded';
}
el.textContent = text;
}
// ── Render dispatcher ───────────────────────────────────────────────────
function renderPluginBody(hostname, pluginName) {
const body = document.getElementById(`body-${hostname}-${pluginName}`);
if (!body) return;
const cached = getCache(hostname, pluginName);
if (!cached) return;
let html = '';
switch (pluginName) {
case 'os_info': html = renderOsInfoTable(cached.data); break;
case 'cpu_monitor': html = renderCpuTable(cached.data); break;
case 'memory_monitor': html = renderMemoryTable(cached.data); break;
case 'disk_monitor': html = renderDiskTables(cached.data); break;
case 'network_monitor':html = renderNetworkTables(cached.data); break;
case 'zfs_monitor': html = renderZfsTables(cached.data); break;
case 'nagios_runner': html = renderNagiosTable(cached.data); break;
case 'filesystem_info':html = renderFilesystemTable(cached.data); break;
default: html = renderGenericTable(cached.data); break;
}
html += `<div class="timestamp">Last updated: ${new Date(cached.timestamp * 1000).toLocaleString()}</div>`;
body.innerHTML = html;
}
// ── Per-plugin renderers ────────────────────────────────────────────────
function renderOsInfoTable(d) {
const ORDER = ['distro_pretty_name','system','release','version','machine',
'processor','architecture','node','python_version',
'python_implementation',
'distro_name','distro_version','distro_id','distro_version_id'];
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
const shown = new Set(ORDER);
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
for (const k of keys) {
if (SKIP_FIELDS.has(k) || !(k in d)) continue;
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(d[k]))}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function renderCpuTable(d) {
const KEYS = [
['cpu_percent', 'CPU Usage', 'bar'],
['load_1min', 'Load (1 min)', 'num'],
['load_5min', 'Load (5 min)', 'num'],
['load_15min', 'Load (15 min)', 'num'],
['cpu_core_count', 'Core Count', 'int'],
['process_count', 'Process Count', 'int'],
['cpu_freq_current', 'CPU Freq (current)', 'num'],
['cpu_freq_min', 'CPU Freq (min)', 'num'],
['cpu_freq_max', 'CPU Freq (max)', 'num'],
['cpu_user', 'CPU User %', 'bar'],
['cpu_system', 'CPU System %', 'bar'],
['cpu_idle', 'CPU Idle %', 'pct'],
['cpu_iowait', 'CPU I/O Wait %', 'bar'],
];
const handled = new Set(KEYS.map(r => r[0]));
let html = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
for (const [k, label, fmt] of KEYS) {
if (!(k in d)) continue;
const v = d[k];
let cell;
if (fmt === 'bar' && typeof v === 'number') {
const cls = v > 90 ? 'crit' : v > 70 ? 'warn' : '';
cell = `<div class="bar-wrap">${v.toFixed(1)}%
<div class="bar-track"><div class="bar-fill ${cls}" style="width:${Math.min(v,100).toFixed(1)}%"></div></div>
</div>`;
} else if (fmt === 'pct') {
cell = typeof v === 'number' ? v.toFixed(1) + '%' : escHtml(String(v));
} else if (fmt === 'int') {
cell = typeof v === 'number' ? v.toLocaleString() : escHtml(String(v));
} else {
cell = typeof v === 'number' ? v.toFixed(2) : escHtml(String(v));
}
html += `<tr><td class="key">${label}</td><td>${cell}</td></tr>`;
}
// Any remaining scalar fields
for (const [k, v] of Object.entries(d)) {
if (handled.has(k) || SKIP_FIELDS.has(k)) continue;
if (typeof v === 'object' || Array.isArray(v)) continue;
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(v))}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function renderMemoryTable(d) {
const RAM_KEYS = [
['memory_percent', 'Usage', 'bar'],
['memory_total', 'Total', 'bytes'],
['memory_available','Available', 'bytes'],
['memory_used', 'Used', 'bytes'],
['memory_free', 'Free', 'bytes'],
['memory_active', 'Active', 'bytes'],
['memory_inactive', 'Inactive', 'bytes'],
['memory_buffers', 'Buffers', 'bytes'],
['memory_cached', 'Cached', 'bytes'],
['memory_shared', 'Shared', 'bytes'],
];
const SWAP_KEYS = [
['swap_percent','Usage', 'bar'],
['swap_total', 'Total', 'bytes'],
['swap_used', 'Used', 'bytes'],
['swap_free', 'Free', 'bytes'],
['swap_sin', 'Swapped In', 'bytes'],
['swap_sout', 'Swapped Out','bytes'],
];
function buildSection(keys, warnAt=80, critAt=95) {
let t = '<table class="data-table"><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>';
for (const [k, label, fmt] of keys) {
if (!(k in d)) continue;
const v = d[k];
let cell;
if (fmt === 'bar' && typeof v === 'number') {
const cls = v > critAt ? 'crit' : v > warnAt ? 'warn' : '';
cell = `<div class="bar-wrap">${v.toFixed(1)}%
<div class="bar-track"><div class="bar-fill ${cls}" style="width:${Math.min(v,100).toFixed(1)}%"></div></div>
</div>`;
} else if (fmt === 'bytes') {
cell = formatBytes(v);
} else {
cell = escHtml(String(v));
}
t += `<tr><td class="key">${label}</td><td>${cell}</td></tr>`;
}
t += '</tbody></table>';
return t;
}
const hasSwap = SWAP_KEYS.some(([k]) => k in d);
if (!hasSwap) return buildSection(RAM_KEYS);
return `<div class="flex-tables">
<div><div class="table-section-label">RAM</div>${buildSection(RAM_KEYS)}</div>
<div><div class="table-section-label">Swap</div>${buildSection(SWAP_KEYS, 50, 80)}</div>
</div>`;
}
function renderDiskTables(d) {
let html = '';
if (d.partitions && Object.keys(d.partitions).length) {
let pt = '<table class="data-table"><thead><tr>'
+ '<th>Mount</th><th>Device</th><th>Type</th>'
+ '<th class="num">Total</th><th class="num">Used</th>'
+ '<th class="num">Free</th><th class="num">Use %</th>'
+ '</tr></thead><tbody>';
for (const [mp, pd] of Object.entries(d.partitions)) {
const pct = pd.percent || 0;
const pctCls = pct > 90 ? 'pct-crit' : pct > 75 ? 'pct-warn' : 'pct-ok';
pt += `<tr>
<td class="iface-name">${escHtml(mp)}</td>
<td>${escHtml(pd.device || '—')}</td>
<td>${escHtml(pd.fstype || '—')}</td>
<td class="num">${formatBytes(pd.total || 0)}</td>
<td class="num">${formatBytes(pd.used || 0)}</td>
<td class="num">${formatBytes(pd.free || 0)}</td>
<td class="num ${pctCls}">${pct.toFixed(1)}%</td>
</tr>`;
}
pt += '</tbody></table>';
let iot = '';
if (d.io_counters && Object.keys(d.io_counters).length) {
const sample = Object.values(d.io_counters)[0];
const hasDelta = 'read_bytes_delta' in sample;
iot = '<table class="data-table"><thead><tr><th>Disk</th>'
+ '<th class="num">Read</th><th class="num">Write</th>';
if (hasDelta) iot += '<th class="num">Δ Read</th><th class="num">Δ Write</th>';
iot += '<th class="num">Reads</th><th class="num">Writes</th>'
+ '</tr></thead><tbody>';
for (const [disk, dd] of Object.entries(d.io_counters)) {
iot += `<tr>
<td class="iface-name">${escHtml(disk)}</td>
<td class="num">${formatBytes(dd.read_bytes || 0)}</td>
<td class="num">${formatBytes(dd.write_bytes || 0)}</td>`;
if (hasDelta) {
iot += `<td class="num">${formatBytes(dd.read_bytes_delta || 0)}</td>
<td class="num">${formatBytes(dd.write_bytes_delta || 0)}</td>`;
}
iot += `<td class="num">${(dd.read_count || 0).toLocaleString()}</td>
<td class="num">${(dd.write_count || 0).toLocaleString()}</td>
</tr>`;
}
iot += '</tbody></table>';
}
html += iot
? `<div class="flex-tables">
<div><div class="table-section-label">Partitions</div>${pt}</div>
<div><div class="table-section-label">I/O Counters</div>${iot}</div>
</div>`
: `<div class="table-section-label">Partitions</div>${pt}`;
}
return html || '<div class="no-data">No disk data available</div>';
}
function renderNetworkTables(d) {
let html = '';
if (d.interfaces && Object.keys(d.interfaces).length) {
const sample = Object.values(d.interfaces)[0];
const hasDelta = 'bytes_sent_delta' in sample;
let t = '<table class="data-table"><thead><tr><th>Interface</th>'
+ '<th class="num">Sent</th><th class="num">Recv</th>';
if (hasDelta) t += '<th class="num">Δ Sent/s</th><th class="num">Δ Recv/s</th>';
t += '<th class="num">Pkts Sent</th><th class="num">Pkts Recv</th>'
+ '</tr></thead><tbody>';
for (const [iface, idata] of Object.entries(d.interfaces)) {
t += `<tr>
<td class="iface-name">${escHtml(iface)}</td>
<td class="num">${formatBytes(idata.bytes_sent || 0)}</td>
<td class="num">${formatBytes(idata.bytes_recv || 0)}</td>`;
if (hasDelta) {
t += `<td class="num"><strong>${formatBytes(idata.bytes_sent_delta || 0)}/s</strong></td>
<td class="num"><strong>${formatBytes(idata.bytes_recv_delta || 0)}/s</strong></td>`;
}
t += `<td class="num">${(idata.packets_sent || 0).toLocaleString()}</td>
<td class="num">${(idata.packets_recv || 0).toLocaleString()}</td>
</tr>`;
}
t += '</tbody></table>';
html += `<div class="table-section-label">Traffic</div>${t}`;
}
if (d.interface_stats && Object.keys(d.interface_stats).length) {
let t = '<table class="data-table"><thead><tr>'
+ '<th>Interface</th><th>Status</th>'
+ '<th class="num">Speed</th><th>Duplex</th><th class="num">MTU</th>'
+ '</tr></thead><tbody>';
for (const [iface, idata] of Object.entries(d.interface_stats)) {
const isUp = idata.isup;
const statusCls = isUp ? 'status-up' : 'status-down';
const statusTxt = isUp ? '✓ UP' : '✗ DOWN';
const speed = idata.speed || 0;
const speedTxt = speed <= 0 ? '—'
: speed >= 1000 ? (speed/1000).toFixed(1) + ' Gbps'
: speed + ' Mbps';
let duplex = idata.duplex || '—';
duplex = duplex.replace('NicDuplex.', '');
if (duplex === '2') duplex = 'FULL';
else if (duplex === '1') duplex = 'HALF';
else if (duplex === '0') duplex = 'UNKNOWN';
let addrTitle = '';
if (d.addresses?.[iface]) {
addrTitle = d.addresses[iface]
.filter(a => a.family && a.family.includes('AF_INET'))
.map(a => a.address).join(', ');
}
t += `<tr>
<td class="iface-name" title="${escHtml(addrTitle)}">${escHtml(iface)}</td>
<td class="${statusCls}">${statusTxt}</td>
<td class="num">${speedTxt}</td>
<td>${escHtml(duplex)}</td>
<td class="num">${idata.mtu || '—'}</td>
</tr>`;
}
t += '</tbody></table>';
html += `<div class="table-section-label" style="margin-top:10px;">Interface Status</div>${t}`;
}
if (d.connections && Object.keys(d.connections).length) {
const states = Object.entries(d.connections).filter(([, v]) => v > 0);
if (states.length) {
let t = '<table class="data-table"><thead><tr>';
states.forEach(([s]) => t += `<th class="center">${escHtml(s)}</th>`);
t += '</tr></thead><tbody><tr>';
states.forEach(([, v]) => t += `<td class="center">${v}</td>`);
t += '</tr></tbody></table>';
html += `<div class="table-section-label" style="margin-top:10px;">TCP Connections</div>${t}`;
}
}
return html || '<div class="no-data">No network data available</div>';
}
function renderNagiosTable(d) {
const checkNames = new Set();
for (const k of Object.keys(d)) {
const m = k.match(/^(.+)_status_code$/);
if (m) checkNames.add(m[1]);
}
if (checkNames.size === 0) return renderGenericTable(d);
let html = '<table class="data-table"><thead><tr>'
+ '<th>Check</th><th class="center">Status</th><th>Output</th>'
+ '</tr></thead><tbody>';
for (const check of [...checkNames].sort()) {
const status = (d[`${check}_status`] || '?').toUpperCase();
const output = d[`${check}_output`] || '';
const rowCls = status === 'OK' ? 'check-ok'
: status === 'WARNING' ? 'check-warning'
: status === 'CRITICAL' ? 'check-critical' : 'check-unknown';
const statusCls = status === 'OK' ? 'check-status-ok'
: status === 'WARNING' ? 'check-status-warning'
: status === 'CRITICAL' ? 'check-status-critical' : 'check-status-unknown';
const label = check.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const truncated = output.length > 120 ? output.slice(0, 117) + '…' : output;
html += `<tr class="${rowCls}">
<td>${escHtml(label)}</td>
<td class="center ${statusCls}">${escHtml(status)}</td>
<td class="check-output" title="${escHtml(output)}">${escHtml(truncated)}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderFilesystemTable(d) {
const fs = d.filesystems;
if (!fs || !fs.length) return '<div class="no-data">No filesystem data</div>';
let html = '<table class="data-table"><thead><tr>'
+ '<th>Device</th><th>Mount</th><th>Type</th>'
+ '<th>Options</th><th class="num">Max File</th><th class="num">Max Path</th>'
+ '</tr></thead><tbody>';
for (const f of fs) {
const opts = f.opts || '—';
const display = opts.length > 50 ? opts.slice(0, 47) + '…' : opts;
html += `<tr>
<td class="iface-name">${escHtml(f.device || '—')}</td>
<td>${escHtml(f.mountpoint || '—')}</td>
<td>${escHtml(f.fstype || '—')}</td>
<td style="font-size:0.82em;" title="${escHtml(opts)}">${escHtml(display)}</td>
<td class="num">${f.maxfile || '—'}</td>
<td class="num">${f.maxpath || '—'}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderZfsTables(d) {
const pools = d.pools || {};
const names = Object.keys(pools);
if (names.length === 0) return '<div class="no-data">No ZFS pools found</div>';
const healthCls = h => {
if (!h || h === 'ONLINE') return 'pct-ok';
if (h === 'DEGRADED') return 'pct-warn';
return 'pct-crit';
};
let pt = '<table class="data-table"><thead><tr>'
+ '<th>Pool</th><th>Health</th>'
+ '<th class="num">Size</th><th class="num">Used</th>'
+ '<th class="num">Free</th><th class="num">Cap %</th>'
+ '<th class="num">Frag %</th><th class="num">Dedup</th>'
+ '</tr></thead><tbody>';
for (const name of names) {
const p = pools[name];
const cap = p.capacity != null ? p.capacity : 0;
const capCls = cap > 90 ? 'pct-crit' : cap > 75 ? 'pct-warn' : 'pct-ok';
pt += `<tr>
<td class="iface-name">${escHtml(name)}</td>
<td class="${healthCls(p.health)}">${escHtml(p.health || '—')}</td>
<td class="num">${formatBytes(p.size || 0)}</td>
<td class="num">${formatBytes(p.alloc || 0)}</td>
<td class="num">${formatBytes(p.free || 0)}</td>
<td class="num ${capCls}">${cap.toFixed(1)}%</td>
<td class="num">${p.frag != null ? p.frag.toFixed(1) + '%' : '—'}</td>
<td class="num">${p.dedup != null ? p.dedup.toFixed(2) + 'x' : '—'}</td>
</tr>`;
}
pt += '</tbody></table>';
const hasIo = names.some(n => pools[n].read_ops != null);
if (!hasIo) return pt;
let iot = '<table class="data-table"><thead><tr>'
+ '<th>Pool</th>'
+ '<th class="num">Read ops</th><th class="num">Write ops</th>'
+ '<th class="num">Read BW</th><th class="num">Write BW</th>'
+ '</tr></thead><tbody>';
for (const name of names) {
const p = pools[name];
iot += `<tr>
<td class="iface-name">${escHtml(name)}</td>
<td class="num">${p.read_ops != null ? p.read_ops.toLocaleString() : '—'}</td>
<td class="num">${p.write_ops != null ? p.write_ops.toLocaleString() : '—'}</td>
<td class="num">${p.read_bw != null ? formatBytes(p.read_bw) : '—'}</td>
<td class="num">${p.write_bw != null ? formatBytes(p.write_bw) : '—'}</td>
</tr>`;
}
iot += '</tbody></table>';
return `<div class="flex-tables">
<div><div class="table-section-label">Pools</div>${pt}</div>
<div><div class="table-section-label">I/O (cumulative)</div>${iot}</div>
</div>`;
}
function renderGenericTable(d) {
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
for (const [k, v] of Object.entries(d)) {
if (SKIP_FIELDS.has(k) || typeof v === 'object') continue;
html += `<tr><td class="key">${formatLabel(k)}</td><td>${escHtml(String(v))}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
// ── Utilities ───────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
function formatLabel(key) {
if (key === 'time') return 'Collected At';
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Auto-refresh (30 s) ─────────────────────────────────────────────────
setInterval(() => {
document.querySelectorAll('.host-card').forEach(card => {
fetchHostGlance(card.dataset.hostname);
});
document.querySelectorAll('.host-card:not(.collapsed)').forEach(card => {
const hostname = card.dataset.hostname;
card.querySelectorAll('.plugin-accordion:not(.collapsed)').forEach(acc => {
const pname = acc.dataset.plugin;
if (!GLANCE_PLUGINS.includes(pname)) {
fetchPlugin(hostname, pname).then(sample => {
if (sample) {
setCache(hostname, pname, sample);
renderPluginBody(hostname, pname);
updateAccordionSummary(hostname, pname);
}
}).catch(() => {});
}
});
});
}, 30000);
// ── Init ────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Fetch glance data for every host immediately so the strip is always populated.
document.querySelectorAll('.host-card').forEach(card => {
fetchHostGlance(card.dataset.hostname);
});
// Expand and load info for the target host (URL hash or first host).
function expandHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (!card) return false;
card.classList.remove('collapsed');
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {
const el = document.getElementById(`info-${hostname}`);
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
});
return true;
}
const hash = window.location.hash;
if (hash) {
const hostname = decodeURIComponent(hash.slice(1));
if (expandHost(hostname)) {
setTimeout(() => {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 150);
return;
}
}
const first = document.querySelector('.host-card');
if (first) expandHost(first.dataset.hostname);
});
// ── Host action helpers ──────────────────────────────────────
let _toastTimer = null;
function showToast(msg, isError) {
const t = document.getElementById('action-toast');
t.textContent = msg;
t.classList.toggle('error', !!isError);
t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
}
async function hostAction(btn, url) {
btn.disabled = true;
try {
const res = await fetch(url);
const text = await res.text();
showToast(text, !res.ok);
} catch (e) {
showToast('Request failed: ' + e.message, true);
} finally {
btn.disabled = false;
}
}
async function hostDelete(btn, hostname) {
if (!confirm('Delete host ' + hostname + '?')) return;
btn.disabled = true;
try {
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
const text = await res.text();
showToast(text, !res.ok);
if (res.ok) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.remove();
}
} catch (e) {
showToast('Request failed: ' + e.message, true);
btn.disabled = false;
}
}
</script>
<div id="action-toast"></div>
</body>
</html>