Since RTT history keys make host.plugin_data non-empty after the very first heartbeat, the "no hosts" empty state can now only trigger when zero hosts have ever connected, not when hosts exist but haven't sent plugin metrics yet (that in-between state no longer exists). Update the copy to describe the actual current trigger condition. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEimzMv4Q5EjFg3hoiZ69T
1453 lines
64 KiB
HTML
1453 lines
64 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
{% include 'head.html' %}
|
|
|
|
<link rel="stylesheet" href="/static/hbd-ui.css">
|
|
<script src="/static/hbd-ui.js"></script>
|
|
|
|
<style>
|
|
body { overflow: hidden; background: var(--st-paper); }
|
|
|
|
/* This page scrolls inside .container, not the body — the toolbar is
|
|
always in view, and sticky misbehaves inside an overflow:hidden body. */
|
|
.st-toolbar { position: static; }
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
max-height: calc(100vh - var(--bars-h, 94px) - 30px);
|
|
overflow-y: auto;
|
|
padding: 0 10px 20px;
|
|
}
|
|
|
|
/* ── Host cards: one record list per host ───────────────────── */
|
|
.host-card {
|
|
background: var(--st-surface);
|
|
border: 1px solid var(--st-line);
|
|
border-radius: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.host-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
}
|
|
.host-header:hover { background: var(--st-surface-2); border-radius: 8px 8px 0 0; }
|
|
.host-card.collapsed .host-header:hover { border-radius: 8px; }
|
|
|
|
.host-left { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
|
|
.collapse-icon {
|
|
font-size: .85em;
|
|
color: var(--st-faint);
|
|
transition: transform 0.2s;
|
|
min-width: 14px;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) { .collapse-icon, .acc-icon { transition: none; } }
|
|
.host-card.collapsed .collapse-icon { transform: rotate(-90deg); }
|
|
|
|
.host-name {
|
|
font-family: var(--st-mono);
|
|
font-size: 13.5px;
|
|
font-weight: 600;
|
|
color: var(--st-ink);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Glance strip ───────────────────────────────────────────── */
|
|
.glance-strip { display: flex; align-items: center; gap: 5px; flex: 1; flex-wrap: wrap; padding: 0 8px; }
|
|
|
|
.glance-chip {
|
|
display: inline-flex; align-items: center;
|
|
font-family: var(--st-mono); font-size: 11px; line-height: 1.3;
|
|
padding: 3px 7px; border-radius: 4px; white-space: nowrap;
|
|
background: var(--st-ok-soft); color: var(--st-ok);
|
|
}
|
|
.glance-chip.warn { background: var(--st-warn-soft); color: var(--st-warn); }
|
|
.glance-chip.crit { background: var(--st-crit-soft); color: var(--st-crit); }
|
|
.glance-chip.neutral { background: var(--st-surface-2); color: var(--st-muted); }
|
|
.glance-loading { font-size: 11.5px; color: var(--st-faint); font-style: italic; }
|
|
|
|
/* ── Host right zone ────────────────────────────────────────── */
|
|
.host-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
|
|
.nagios-badge {
|
|
font-family: var(--st-mono); font-size: 10.5px; font-weight: 700;
|
|
padding: 3px 9px; border-radius: 4px;
|
|
background: var(--st-surface-2); color: var(--st-muted);
|
|
text-transform: uppercase; white-space: nowrap;
|
|
}
|
|
.nagios-badge.ok { background: var(--st-ok-soft); color: var(--st-ok); }
|
|
.nagios-badge.warning { background: var(--st-warn-soft); color: var(--st-warn); }
|
|
.nagios-badge.critical { background: var(--st-crit-soft); color: var(--st-crit); }
|
|
|
|
.os-label {
|
|
font-size: 11px; color: var(--st-faint); white-space: nowrap;
|
|
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
|
|
.host-action-btn {
|
|
font: 600 12px/1 inherit;
|
|
padding: 4px 10px; border-radius: 6px;
|
|
border: 1px solid var(--st-line);
|
|
background: var(--st-surface);
|
|
cursor: pointer; text-decoration: none; white-space: nowrap;
|
|
}
|
|
.host-action-btn.update-btn { color: var(--st-accent); }
|
|
.host-action-btn.update-btn:hover { background: var(--st-accent-soft); border-color: var(--st-accent); }
|
|
.host-action-btn.delete-btn { color: var(--st-crit); }
|
|
.host-action-btn.delete-btn:hover { background: var(--st-crit-soft); border-color: var(--st-crit); }
|
|
|
|
/* ── Action result toast ───────────────────────────────────── */
|
|
#action-toast {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
background: var(--st-ink);
|
|
color: var(--st-paper);
|
|
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: var(--st-crit); color: #fff; }
|
|
|
|
/* ── Host body ──────────────────────────────────────────────── */
|
|
.host-body { padding: 8px 14px 12px; border-top: 1px solid var(--st-line-soft); }
|
|
.host-card.collapsed .host-body { display: none; }
|
|
|
|
/* ── Plugin accordions ──────────────────────────────────────── */
|
|
.plugin-accordion {
|
|
border: 1px solid var(--st-line-soft);
|
|
border-radius: 6px;
|
|
margin-bottom: 5px;
|
|
overflow: hidden;
|
|
}
|
|
.plugin-acc-header {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 6px 12px; cursor: pointer;
|
|
background: var(--st-surface-2);
|
|
user-select: none;
|
|
}
|
|
.plugin-acc-header:hover { background: var(--st-accent-soft); }
|
|
|
|
.acc-icon { font-size: 0.7em; color: var(--st-faint); transition: transform 0.15s; min-width: 12px; }
|
|
.plugin-accordion:not(.collapsed) .acc-icon { transform: rotate(90deg); }
|
|
|
|
.plugin-label {
|
|
font-family: var(--st-mono);
|
|
font-weight: 600; font-size: 12.5px;
|
|
color: var(--st-ink);
|
|
min-width: 140px;
|
|
}
|
|
.plugin-summary { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); 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: 12.5px;
|
|
background: var(--st-surface);
|
|
border: 1px solid var(--st-line-soft);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
margin-bottom: 10px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.data-table thead { background: var(--st-surface-2); }
|
|
.data-table th {
|
|
font-family: var(--st-mono);
|
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
|
|
color: var(--st-faint); font-weight: 600;
|
|
text-align: left; padding: 5px 8px;
|
|
border-bottom: 1px solid var(--st-line);
|
|
}
|
|
.data-table th.num { text-align: right; }
|
|
.data-table th.center { text-align: center; }
|
|
.data-table td {
|
|
padding: 4px 8px;
|
|
border-top: 1px solid var(--st-line-soft);
|
|
color: var(--st-ink);
|
|
}
|
|
.data-table td.num { text-align: right; font-family: var(--st-mono); }
|
|
.data-table td.center { text-align: center; }
|
|
.data-table td.key { color: var(--st-muted); font-weight: 500; width: 38%; }
|
|
.data-table tbody tr:hover { background: var(--st-surface-2); }
|
|
|
|
.iface-name { font-family: var(--st-mono); font-weight: 600; color: var(--st-accent); }
|
|
|
|
/* ── Percent bars ───────────────────────────────────────────── */
|
|
.bar-wrap { display: flex; align-items: center; gap: 8px; min-width: 120px; }
|
|
.bar-track {
|
|
flex: 1; height: 7px; border-radius: 4px;
|
|
background: var(--st-line-soft); overflow: hidden; min-width: 60px;
|
|
}
|
|
.bar-fill { height: 100%; border-radius: 4px; background: var(--st-ok); }
|
|
.bar-fill.warn { background: var(--st-warn); }
|
|
.bar-fill.crit { background: var(--st-crit); }
|
|
|
|
/* ── Disk two-table layout ──────────────────────────────────── */
|
|
.flex-tables { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
.flex-tables > div { flex: 1 1 380px; }
|
|
.table-section-label {
|
|
font-family: var(--st-mono);
|
|
font-size: 11px; font-weight: 600; color: var(--st-faint);
|
|
text-transform: uppercase; letter-spacing: .05em;
|
|
margin: 4px 0 6px;
|
|
}
|
|
|
|
/* ── Status / misc ──────────────────────────────────────────── */
|
|
.status-up { color: var(--st-ok); font-weight: bold; }
|
|
.status-down { color: var(--st-crit); font-weight: bold; }
|
|
|
|
.pct-ok { color: var(--st-ok); font-weight: bold; }
|
|
.pct-warn { color: var(--st-warn); font-weight: bold; }
|
|
.pct-crit { color: var(--st-crit); font-weight: bold; }
|
|
|
|
.check-ok { background: var(--st-ok-soft); }
|
|
.check-warning { background: var(--st-warn-soft); }
|
|
.check-critical { background: var(--st-crit-soft); }
|
|
.check-unknown { background: var(--st-surface-2); }
|
|
|
|
.check-status-ok { color: var(--st-ok); font-weight: bold; }
|
|
.check-status-warning { color: var(--st-warn); font-weight: bold; }
|
|
.check-status-critical { color: var(--st-crit); font-weight: bold; }
|
|
.check-status-unknown { color: var(--st-faint); font-weight: bold; }
|
|
|
|
.check-output { font-size: 0.9em; color: var(--st-muted); }
|
|
|
|
.timestamp {
|
|
font-size: 11px; color: var(--st-faint);
|
|
margin-top: 6px; padding-top: 6px;
|
|
border-top: 1px solid var(--st-line-soft);
|
|
}
|
|
|
|
.no-data { text-align: center; padding: 40px; color: var(--st-faint); }
|
|
.loading { text-align: center; padding: 12px; color: var(--st-faint); font-style: italic; }
|
|
.error {
|
|
background: var(--st-crit-soft);
|
|
border-left: 3px solid var(--st-crit);
|
|
padding: 12px 16px; margin: 10px 0;
|
|
border-radius: 6px; color: var(--st-crit);
|
|
}
|
|
|
|
/* ── Scrollbar ──────────────────────────────────────────────── */
|
|
.container::-webkit-scrollbar { width: 8px; }
|
|
.container::-webkit-scrollbar-track { background: var(--st-surface-2); border-radius: 4px; }
|
|
.container::-webkit-scrollbar-thumb { background: var(--st-line); border-radius: 4px; }
|
|
.container::-webkit-scrollbar-thumb:hover { background: var(--st-faint); }
|
|
|
|
/* ── Host info section ──────────────────────────────────────── */
|
|
.host-info-section {
|
|
background: var(--st-surface-2);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
margin-bottom: 8px;
|
|
font-size: 12.5px;
|
|
}
|
|
.info-meta { display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
|
.info-label { font-family: var(--st-mono); font-size: 11px; font-weight: 600; color: var(--st-faint); white-space: nowrap; }
|
|
.info-value { color: var(--st-ink); }
|
|
.info-thresholds-title {
|
|
font-family: var(--st-mono);
|
|
font-size: 11px; font-weight: 600; color: var(--st-faint);
|
|
text-transform: uppercase; letter-spacing: .05em;
|
|
margin: 8px 0 4px;
|
|
}
|
|
.info-note { color: var(--st-faint); font-style: italic; }
|
|
.info-loading { color: var(--st-faint); font-style: italic; }
|
|
.threshold-covers { font-size: 11.5px; color: var(--st-faint); font-style: italic; }
|
|
</style>
|
|
|
|
<body>
|
|
{% include 'nav.html' %}
|
|
|
|
<div class="st-toolbar">
|
|
<span class="brand">hbd<span class="tld">·overview</span></span>
|
|
<span class="hintline">per-host metrics — expand a host for plugin details</span>
|
|
</div>
|
|
<svg class="pulse" viewBox="0 0 1200 14" preserveAspectRatio="none" aria-hidden="true">
|
|
<polyline points="0,10 340,10 352,10 358,3 364,13 370,1 378,12 384,10 560,10 572,10 578,3 584,13 590,1 598,12 604,10 1200,10"/>
|
|
</svg>
|
|
|
|
<div class="container">
|
|
|
|
{% if not hosts %}
|
|
<div class="no-data">
|
|
<p>No hosts have connected yet</p>
|
|
<p style="font-size:0.9em;margin-top:10px;">Hosts will appear here once they start sending heartbeats</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.connections && data.connections.length) {
|
|
html += `<div class="info-thresholds-title">Connectivity</div>`;
|
|
for (const c of data.connections) {
|
|
html += `<div class="info-note">${escHtml(c.family)} · ${escHtml(c.addr || '—')} RTT</div>
|
|
<div id="rtt-chart-${hostname}-${escHtml(c.family)}" style="margin-bottom:8px;"></div>`;
|
|
}
|
|
html += `<table class="data-table"><thead><tr>
|
|
<th>Family</th><th>Address</th><th>State</th>
|
|
<th class="num">RTT</th><th>Last Change</th><th>Last Packet</th>
|
|
</tr></thead><tbody>`;
|
|
for (const c of data.connections) {
|
|
const st = escHtml(c.state || '—');
|
|
const stCls = c.state === 'up' ? 'status-up' : (c.state ? 'status-down' : '');
|
|
const rtt = (c.state === 'up' && c.rtt) ? Math.round(c.rtt) + ' ms' : '—';
|
|
const chg = c.statetime ? new Date(c.statetime * 1000).toLocaleString() : '—';
|
|
const seen = c.lastbeat ? new Date(c.lastbeat * 1000).toLocaleString() : '—';
|
|
html += `<tr>
|
|
<td class="key">${escHtml(c.family)}</td>
|
|
<td>${escHtml(c.addr || '—')}</td>
|
|
<td><span class="${stCls}">${st}</span></td>
|
|
<td class="num">${rtt}</td>
|
|
<td>${chg}</td>
|
|
<td>${seen}</td>
|
|
</tr>`;
|
|
}
|
|
html += `</tbody></table>`;
|
|
}
|
|
|
|
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;
|
|
|
|
if (data.connections && data.connections.length) {
|
|
const rttThresholds = (data.thresholds || []).find(t => t.metric === 'rtt') || null;
|
|
for (const c of data.connections) {
|
|
fetchRttHistory(hostname, c.family)
|
|
.then(samples => renderRttChart(hostname, c.family, samples, rttThresholds))
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
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(hostname, 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;
|
|
|
|
if (pluginName === 'cpu_monitor') {
|
|
fetchCpuHistory(hostname).then(samples => renderCpuChart(hostname, samples)).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
async function fetchCpuHistory(hostname) {
|
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/cpu_monitor?limit=100`);
|
|
if (!r.ok) return [];
|
|
const json = await r.json();
|
|
return json.samples || [];
|
|
}
|
|
|
|
function renderTimeSeriesChart(elId, pts, opts) {
|
|
const el = document.getElementById(elId);
|
|
if (!el || pts.length < 2) { if (el) el.style.display = 'none'; return; }
|
|
|
|
const unitSuffix = opts.unitSuffix || '';
|
|
const W = 600, H = 80, PAD = { top: 6, right: 8, bottom: 18, left: 28 };
|
|
const cW = W - PAD.left - PAD.right;
|
|
const cH = H - PAD.top - PAD.bottom;
|
|
|
|
const tMin = pts[0].t, tMax = pts[pts.length - 1].t;
|
|
const tRange = tMax - tMin || 1;
|
|
const x = t => PAD.left + ((t - tMin) / tRange) * cW;
|
|
|
|
// Auto-scale Y axis with 10% padding, optionally clamped to opts.yDomain
|
|
const vMin = Math.min(...pts.map(p => p.v));
|
|
const vMax = Math.max(...pts.map(p => p.v));
|
|
const vRange = vMax - vMin || 1;
|
|
const vPad = Math.max(vRange * 0.1, 1);
|
|
const domainLow = Array.isArray(opts.yDomain) ? opts.yDomain[0] : 0;
|
|
const domainHigh = Array.isArray(opts.yDomain) ? opts.yDomain[1] : Infinity;
|
|
const yLow = Math.max(domainLow, vMin - vPad);
|
|
const yHigh = Math.min(domainHigh, vMax + vPad);
|
|
const yRange = yHigh - yLow || 1;
|
|
const y = v => PAD.top + cH - ((v - yLow) / yRange) * cH;
|
|
|
|
// Build polyline points and filled area path
|
|
const linePoints = pts.map(p => `${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
|
|
const areaPath = `M${x(pts[0].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} ` +
|
|
pts.map(p => `L${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') +
|
|
` L${x(pts[pts.length-1].t).toFixed(1)},${(PAD.top + cH).toFixed(1)} Z`;
|
|
|
|
// Color based on latest value
|
|
const latest = pts[pts.length - 1].v;
|
|
const { stroke: strokeColor, fill: fillColor } = opts.colorFor(latest);
|
|
|
|
// Compute nice tick step for ~3-5 grid lines
|
|
const rawStep = yRange / 4;
|
|
const mag = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
|
|
const niceStep = [1, 2, 5, 10].map(f => f * mag).find(s => yRange / s <= 5) || mag * 10;
|
|
const tickStart = Math.ceil(yLow / niceStep) * niceStep;
|
|
let gridLines = '';
|
|
for (let v = tickStart; v <= yHigh + 0.001; v += niceStep) {
|
|
const yy = y(v).toFixed(1);
|
|
const label = (Number.isInteger(v) ? v : v.toFixed(1)) + unitSuffix;
|
|
gridLines += `<line x1="${PAD.left}" y1="${yy}" x2="${PAD.left + cW}" y2="${yy}" stroke="#e0e0e0" stroke-width="1"/>`;
|
|
gridLines += `<text x="${(PAD.left - 3).toFixed(1)}" y="${yy}" text-anchor="end" dominant-baseline="middle" font-size="8" fill="#999">${label}</text>`;
|
|
}
|
|
|
|
// X-axis time labels
|
|
const fmt = ts => {
|
|
const d = new Date(ts * 1000);
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
const xLabels = `
|
|
<text x="${PAD.left}" y="${H - 2}" text-anchor="start" font-size="8" fill="#999">${fmt(pts[0].t)}</text>
|
|
<text x="${PAD.left + cW}" y="${H - 2}" text-anchor="end" font-size="8" fill="#999">${fmt(pts[pts.length-1].t)}</text>`;
|
|
|
|
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
|
|
style="width:100%;height:${H}px;display:block;">
|
|
<defs>
|
|
<clipPath id="${opts.clipId}">
|
|
<rect x="${PAD.left}" y="${PAD.top}" width="${cW}" height="${cH}"/>
|
|
</clipPath>
|
|
</defs>
|
|
${gridLines}
|
|
<line x1="${PAD.left}" y1="${PAD.top}" x2="${PAD.left}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
|
<line x1="${PAD.left}" y1="${PAD.top + cH}" x2="${PAD.left + cW}" y2="${PAD.top + cH}" stroke="#ccc" stroke-width="1"/>
|
|
<g clip-path="url(#${opts.clipId})">
|
|
<path d="${areaPath}" fill="${fillColor}" opacity="0.6"/>
|
|
<polyline points="${linePoints}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linejoin="round"/>
|
|
</g>
|
|
${xLabels}
|
|
</svg>`;
|
|
}
|
|
|
|
function renderCpuChart(hostname, samples) {
|
|
const pts = samples
|
|
.filter(s => s.data.cpu_percent != null)
|
|
.map(s => ({ t: s.timestamp, v: s.data.cpu_percent }));
|
|
|
|
renderTimeSeriesChart(`cpu-chart-${hostname}`, pts, {
|
|
yDomain: [0, 100],
|
|
clipId: `cpu-clip-${hostname}`,
|
|
colorFor: (latest) => ({
|
|
stroke: latest > 90 ? '#e53935' : latest > 70 ? '#fb8c00' : '#43a047',
|
|
fill: latest > 90 ? '#ffcdd2' : latest > 70 ? '#ffe0b2' : '#c8e6c9',
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function fetchRttHistory(hostname, family) {
|
|
const plugin = `rtt_${family.toLowerCase()}`;
|
|
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/plugins/${plugin}?limit=100`);
|
|
if (!r.ok) return [];
|
|
const json = await r.json();
|
|
return json.samples || [];
|
|
}
|
|
|
|
function renderRttChart(hostname, family, samples, rttThresholds) {
|
|
const pts = samples
|
|
.filter(s => s.data.rtt != null && s.data.rtt > 0)
|
|
.map(s => ({ t: s.timestamp, v: s.data.rtt }));
|
|
|
|
renderTimeSeriesChart(`rtt-chart-${hostname}-${family}`, pts, {
|
|
yDomain: 'auto',
|
|
clipId: `rtt-clip-${hostname}-${family}`,
|
|
unitSuffix: ' ms',
|
|
colorFor: (latest) => {
|
|
if (rttThresholds?.critical != null && latest > rttThresholds.critical) {
|
|
return { stroke: '#e53935', fill: '#ffcdd2' };
|
|
}
|
|
if (rttThresholds?.warning != null && latest > rttThresholds.warning) {
|
|
return { stroke: '#fb8c00', fill: '#ffe0b2' };
|
|
}
|
|
return { stroke: '#1976d2', fill: '#bbdefb' };
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderCpuTable(hostname, 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 = `<div id="cpu-chart-${hostname}" style="margin-bottom:8px;"></div>`;
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
fetchHostInfo(hostname).then(data => {
|
|
infoCache[hostname] = data;
|
|
renderInfoSection(hostname, data);
|
|
}).catch(() => {});
|
|
|
|
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>
|