Files
heartbeat/hbd/server/templates/live.html
T
andreasandClaude Fable 5 8d268adf70 feat: Live Dashboard redesign — 6 columns, hover details, last-alert column
Replaces the 11-column table with severity-sorted record rows: host,
combined status chip, latency, alert chips, time-in-state, and the most
recent alert message. Per-family address/state/latency/last-change move
to a hover card on the status chip and permanently to a new Connectivity
table in the Host Overview info section (host info API now includes
connections). Last-alert is seeded from /api/0/alerts and kept fresh
from the event stream; the event log below uses the same row idiom with
its filters intact. WS reconnect state shows in the toolbar instead of
a modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-10 13:45:03 -04:00

489 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<link rel="stylesheet" href="/static/hbd-ui.css">
<script src="/static/hbd-ui.js"></script>
<style>
html, body { height: auto; overflow: visible; }
body { background: var(--st-paper); padding: 60px 0 0; }
.live-main { max-width: 1180px; margin: 0 auto; padding: 0 20px 70px; }
.st-toolbar input {
font: 12px var(--st-mono); color: var(--st-ink);
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
padding: 5px 12px; min-width: 160px;
}
.live-dot { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--st-ok); }
.live-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--st-ok); }
.live-dot.reconnecting { color: var(--st-crit); }
.live-dot.reconnecting::before { background: var(--st-crit); }
/* ── host rows: 6 columns ── */
.hrow, .hhead {
display: grid;
grid-template-columns: 175px 105px 70px 125px 85px minmax(0,1fr);
gap: 4px 14px; align-items: center;
padding: 6px 14px; border-bottom: 1px solid var(--st-line-soft);
}
.hrow:last-child { border-bottom: none; }
.hrow:hover { background: var(--st-surface-2); }
.hhead {
font-family: var(--st-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
color: var(--st-faint); background: var(--st-surface-2); border-radius: 8px 8px 0 0;
}
.hhead .num, .hrow .num { text-align: right; }
.hrow.sev-warn { border-left: 3px solid var(--st-warn); padding-left: 11px; }
.hrow.sev-crit { border-left: 3px solid var(--st-crit); padding-left: 11px; }
.host { font-family: var(--st-mono); font-size: 13px; font-weight: 400; display: flex; align-items: center; gap: 7px; min-width: 0; }
.host.watched { font-weight: 700; }
.host a { color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host a:hover { color: var(--st-accent); }
.dot.down { background: var(--st-crit); box-shadow: 0 0 0 3px var(--st-crit-soft); }
.dot.overdue { background: transparent; border: 2px solid var(--st-warn); }
.stale { color: var(--st-faint); font-size: 10px; border: 1px solid var(--st-line); border-radius: 4px; padding: 1px 4px; font-family: var(--st-mono); white-space: nowrap; }
.chip.outline { background: transparent; border: 1px solid var(--st-line); }
.lat { font-family: var(--st-mono); font-size: 12px; color: var(--st-muted); font-variant-numeric: tabular-nums; }
.since { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); font-variant-numeric: tabular-nums; }
.lastalert { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lastalert .lvl { font-weight: 700; }
.lastalert .lvl.crit { color: var(--st-crit); }
.lastalert .lvl.warn { color: var(--st-warn); }
.lastalert .ago { color: var(--st-faint); }
.lastalert.quiet { color: var(--st-faint); }
/* ── hover card on the status chip ── */
.tip { position: relative; display: inline-flex; }
.tipbox {
display: none; position: absolute; z-index: 40; top: calc(100% + 6px); left: 0;
width: 340px; background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px;
box-shadow: 0 8px 28px var(--shadow); padding: 10px 12px;
}
.tip:hover .tipbox, .tip:focus-within .tipbox { display: block; }
.tipbox table { border-collapse: collapse; width: 100%; font-family: var(--st-mono); font-size: 11.5px; }
.tipbox th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--st-faint); padding: 2px 6px 4px; border-bottom: 1px solid var(--st-line-soft); }
.tipbox td { padding: 3px 6px; color: var(--st-muted); }
.tipbox td:first-child { color: var(--st-faint); }
.tipbox .up { color: var(--st-ok); font-weight: 700; }
.tipbox .dn { color: var(--st-crit); font-weight: 700; }
.tipbox .ov { color: var(--st-warn); font-weight: 700; }
.tipbox .meta { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--st-line-soft); font-size: 11px; color: var(--st-faint); }
/* ── event log ── */
.logrow {
display: grid; grid-template-columns: 130px 74px 150px minmax(0,1fr);
gap: 4px 14px; align-items: baseline;
padding: 4px 14px; border-bottom: 1px solid var(--st-line-soft);
font-family: var(--st-mono); font-size: 11.5px;
}
.logrow:last-child { border-bottom: none; }
.logrow .t { color: var(--st-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
.logrow .h { color: var(--st-ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.logrow .m { color: var(--st-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filters { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
.filters input, .filters select {
font: 11px var(--st-mono); color: var(--st-ink); background: var(--st-surface);
border: 1px solid var(--st-line); border-radius: 999px; padding: 3px 10px;
}
.empty { padding: 24px 14px; text-align: center; color: var(--st-faint); font-size: 12.5px; }
@media (max-width: 760px) {
.hhead { display: none; }
.hrow { grid-template-columns: minmax(0,1fr) auto auto; padding: 8px 12px; }
.hrow .num { text-align: left; }
.hrow .since { display: none; }
.lastalert { grid-column: 1 / -1; }
.logrow { grid-template-columns: 66px minmax(0,1fr); }
.logrow .lvlc, .logrow .h { display: none; }
.tipbox { left: auto; right: 0; }
}
</style>
<body>
{% include 'nav.html' %}
<div class="st-toolbar">
<span class="brand">hbd<span class="tld">·live</span></span>
<span class="hintline">every host, one line — details on hover, history in the overview</span>
<span class="spacer"></span>
<input type="text" id="host-filter" placeholder="host filter (regex)">
<span class="live-dot reconnecting" id="ws-status">connecting…</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="live-main">
<section class="st">
<div class="sec-head">
<h2>hosts<span class="colon">:</span></h2>
<span class="count" id="host-counts"></span>
<span class="sub">sorted by severity, then name</span>
</div>
<div class="list">
<div class="hhead">
<span>host</span><span>status</span><span class="num">latency</span>
<span>alerts</span><span>since</span><span>last alert</span>
</div>
<div id="host-rows"></div>
</div>
</section>
<section class="st">
<div class="sec-head">
<h2>log<span class="colon">:</span></h2>
<span class="sub">connectivity and alert events</span>
<span class="filters">
<input type="text" id="filter-host" placeholder="host">
<select id="filter-level">
<option value="">level</option>
<option value="info">INFO</option>
<option value="warning">WARNING</option>
<option value="critical">CRITICAL</option>
<option value="recover">RECOVER</option>
<option value="unknown">UNKNOWN</option>
</select>
<input type="text" id="filter-msg" placeholder="message">
</span>
</div>
<div class="list" id="messages">
<div class="empty">Waiting for events…</div>
</div>
</section>
</div>
<script>
var HBD_VERSION = "{{ hbd_version }}";
var INITIAL_HOSTS = {{ hosts | tojson }};
var MAX_LOG_ROWS = 400;
var hostData = {}; // name -> latest stateinfo payload
var lastAlert = {}; // name -> {level, message, ts}
var hostFilterRe = null;
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ---- derived per-host facts ----------------------------------------
var STATE_RANK = { down: 3, overdue: 2, up: 1 };
function worstConnection(data) {
var worst = null;
(data.connections || []).forEach(function (c) {
if (!c.state || c.state === 'unknown') return;
if (!worst || (STATE_RANK[c.state] || 0) > (STATE_RANK[worst.state] || 0)) worst = c;
});
return worst;
}
function severity(data) {
var w = worstConnection(data);
if ((data.alert_critical_unacked || 0) > 0 || (data.alert_critical_acked || 0) > 0) return 'crit';
if (w && w.state === 'down') return 'crit';
if ((data.alert_warning_unacked || 0) > 0 || (data.alert_warning_acked || 0) > 0) return 'warn';
if (w && w.state === 'overdue') return 'warn';
return 'ok';
}
var SEV_ORDER = { crit: 0, warn: 1, ok: 2 };
function relTime(ts) {
if (!ts) return '—';
var s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
if (s < 86400 * 30) return Math.floor(s / 86400) + 'd ' + Math.floor((s % 86400) / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
function fmtClock(ts) {
var d = new Date(ts * 1000);
function p(n) { return n < 10 ? '0' + n : '' + n; }
var t = p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
return (d.toDateString() === new Date().toDateString())
? t
: d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + t;
}
// ---- host row rendering ---------------------------------------------
function statusChip(data) {
var w = worstConnection(data);
if (!w) return '<span class="chip outline">—</span>';
var cls = w.state === 'up' ? 'ok' : (w.state === 'down' ? 'crit' : 'warn');
return '<span class="chip ' + cls + '">' + escHtml(w.state) + '</span>';
}
function tipCard(data) {
var rows = '';
(data.connections || []).forEach(function (c) {
if (!c.state || c.state === 'unknown') return;
var cls = c.state === 'up' ? 'up' : (c.state === 'down' ? 'dn' : 'ov');
var fam = (c.afam || '').replace('IPv', 'v');
var rtt = (c.state === 'up' && c.rtts && c.rtts[0]) ? Math.round(c.rtts[0]) + ' ms' : '—';
rows += '<tr><td>' + escHtml(fam) + '</td><td>' + escHtml(c.addr || '—') + '</td>'
+ '<td class="' + cls + '">' + escHtml(c.state) + '</td>'
+ '<td>' + rtt + '</td><td>' + (c.statetime ? fmtClock(c.statetime) : '—') + '</td></tr>';
});
if (!rows) rows = '<tr><td colspan="5">no connections yet</td></tr>';
var meta = [];
meta.push('agent ' + (data.hbc_version ? 'v' + escHtml(String(data.hbc_version)) : 'unknown'));
if (data.dyn) meta.push('dyndns');
if (data.watched) meta.push('watched');
if (data.interval) meta.push('interval ' + escHtml(String(data.interval)) + ' s');
return '<span class="tipbox" role="tooltip">'
+ '<table><thead><tr><th></th><th>address</th><th>state</th><th>rtt</th><th>since</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table>'
+ '<div class="meta">' + meta.join(' · ') + '</div></span>';
}
function alertChips(data) {
var cu = data.alert_critical_unacked || 0, ca = data.alert_critical_acked || 0;
var wu = data.alert_warning_unacked || 0, wa = data.alert_warning_acked || 0;
var out = '';
if (cu) out += '<span class="chip crit"><b>' + cu + '</b> crit</span> ';
if (wu) out += '<span class="chip warn"><b>' + wu + '</b> warn</span> ';
var acked = ca + wa;
if (acked) out += '<span class="chip outline">' + acked + ' ackd</span>';
return out;
}
function lastAlertHtml(name) {
var a = lastAlert[name];
if (!a) return '<span class="lastalert quiet">—</span>';
var lvl = (a.level || '').toUpperCase();
var quiet = (lvl !== 'WARNING' && lvl !== 'CRITICAL');
var lvlCls = lvl === 'CRITICAL' ? 'crit' : (lvl === 'WARNING' ? 'warn' : '');
var short = lvl === 'CRITICAL' ? 'CRIT' : (lvl === 'WARNING' ? 'WARN' : escHtml(lvl));
return '<span class="lastalert' + (quiet ? ' quiet' : '') + '" title="' + escHtml(a.message) + '">'
+ '<span class="lvl ' + lvlCls + '">' + short + '</span> '
+ escHtml(a.message)
+ (a.ts ? ' <span class="ago">· ' + relTime(a.ts) + ' ago</span>' : '')
+ '</span>';
}
function renderRow(data) {
var name = data.name;
var sev = severity(data);
var row = document.querySelector('.hrow[data-host="' + CSS.escape(name) + '"]');
var isNew = false;
if (!row) {
row = document.createElement('div');
row.dataset.host = name;
isNew = true;
}
row.className = 'hrow' + (sev !== 'ok' ? ' sev-' + sev : '');
row.dataset.sev = sev;
var w = worstConnection(data);
var up = (data.connections || []).find(function (c) { return c.state === 'up' && c.rtts && c.rtts.length; });
var lat = up ? Math.round(up.rtts[0]) + ' ms' : '—';
var since = w ? relTime(w.statetime) : '—';
var staleTag = '';
if (!data.hbc_version) staleTag = '<span class="stale" title="agent version unknown">agent?</span>';
else if (data.hbc_version !== HBD_VERSION) {
staleTag = '<span class="stale" title="agent v' + escHtml(String(data.hbc_version)) + ' ≠ server v' + HBD_VERSION + '">v' + escHtml(String(data.hbc_version)) + '</span>';
}
var dotCls = w ? (w.state === 'up' ? sev : (w.state === 'down' ? 'down' : 'overdue')) : 'off';
row.innerHTML =
'<span class="host' + (data.watched ? ' watched' : '') + '">'
+ '<span class="dot ' + dotCls + '"></span>'
+ '<a href="/plugins#' + encodeURIComponent(name) + '">' + escHtml(name) + '</a>' + staleTag + '</span>'
+ '<span class="tip" tabindex="0">' + statusChip(data) + tipCard(data) + '</span>'
+ '<span class="lat num">' + lat + '</span>'
+ '<span>' + alertChips(data) + '</span>'
+ '<span class="since">' + since + '</span>'
+ lastAlertHtml(name);
if (isNew) {
document.getElementById('host-rows').appendChild(row);
sortRows();
}
applyHostFilter(row);
}
function sortRows() {
var container = document.getElementById('host-rows');
var rows = [...container.children];
rows.sort(function (a, b) {
var d = (SEV_ORDER[a.dataset.sev] ?? 2) - (SEV_ORDER[b.dataset.sev] ?? 2);
return d !== 0 ? d : a.dataset.host.localeCompare(b.dataset.host);
});
rows.forEach(function (r) { container.appendChild(r); });
}
function updateCounts() {
var total = 0, up = 0, overdue = 0, down = 0;
Object.values(hostData).forEach(function (d) {
total++;
var w = worstConnection(d);
if (!w) return;
if (w.state === 'up') up++;
else if (w.state === 'overdue') overdue++;
else if (w.state === 'down') down++;
});
var parts = [total + ' hosts', up + ' up'];
if (overdue) parts.push(overdue + ' overdue');
if (down) parts.push(down + ' down');
document.getElementById('host-counts').textContent = parts.join(' · ');
}
function updateHost(data) {
var prev = hostData[data.name];
var prevSev = prev ? severity(prev) : null;
hostData[data.name] = data;
renderRow(data);
if (prevSev !== severity(data)) sortRows();
updateCounts();
}
// ---- host filter -----------------------------------------------------
function applyHostFilter(row) {
var rows = row ? [row] : [...document.querySelectorAll('.hrow')];
rows.forEach(function (r) {
r.style.display = (!hostFilterRe || hostFilterRe.test(r.dataset.host)) ? '' : 'none';
});
}
document.getElementById('host-filter').addEventListener('input', function () {
var v = this.value.trim();
try {
hostFilterRe = v ? new RegExp(v, 'i') : null;
this.style.borderColor = '';
} catch (_) {
hostFilterRe = null;
this.style.borderColor = 'var(--st-crit)';
}
applyHostFilter();
});
// ---- last alert tracking ---------------------------------------------
var ALERT_LEVELS = { WARNING: 1, CRITICAL: 1, RECOVER: 1 };
function noteAlert(host, level, message, ts) {
if (!host || !ALERT_LEVELS[(level || '').toUpperCase()]) return;
var cur = lastAlert[host];
if (cur && cur.ts && ts && ts < cur.ts) return; // keep the newest
lastAlert[host] = { level: level, message: message, ts: ts };
if (hostData[host]) renderRow(hostData[host]);
}
async function seedActiveAlerts() {
try {
var r = await fetch('/api/0/alerts');
if (!r.ok) return;
var data = await r.json();
(data.alerts || []).forEach(function (a) {
var metric = (a.metric_path.includes('.')
? a.metric_path.slice(a.metric_path.indexOf('.') + 1)
: a.metric_path).replace(/_status_code$/, '');
var msg = metric + ' = ' + a.last_value
+ (a.formatted_message ? ' ' + a.formatted_message
: (a.operator && a.threshold_value != null ? ' (' + a.operator + ' ' + a.threshold_value + ')' : ''));
noteAlert(a.hostname, a.level, msg, a.since);
});
} catch (_) { /* dashboard still works without the seed */ }
}
// ---- event log ---------------------------------------------------------
function logRowHtml(msg) {
var lvl = (msg.level || 'INFO');
var lc = lvl.toLowerCase();
var chipCls = lc === 'critical' ? 'crit' : (lc === 'warning' ? 'warn' : (lc === 'recover' ? 'ok' : ''));
var short = lc === 'critical' ? 'CRIT' : (lc === 'warning' ? 'WARN' : escHtml(lvl));
return '<div class="logrow" data-level="' + escHtml(lc) + '" data-host="' + escHtml(msg.host || '') + '">'
+ '<span class="t">' + fmtClock(msg.ts) + '</span>'
+ '<span class="lvlc"><span class="chip ' + chipCls + '">' + short + '</span></span>'
+ '<span class="h">' + escHtml(msg.host || '') + (msg.service ? ' · ' + escHtml(msg.service) : '') + '</span>'
+ '<span class="m">' + escHtml(msg.message || '') + '</span>'
+ '</div>';
}
function addLogRow(msg, isHistory) {
var box = document.getElementById('messages');
var placeholder = box.querySelector('.empty');
if (placeholder) placeholder.remove();
box.insertAdjacentHTML(isHistory ? 'beforeend' : 'afterbegin', logRowHtml(msg));
while (box.children.length > MAX_LOG_ROWS) box.lastElementChild.remove();
applyLogFilters();
}
function applyLogFilters() {
var hostF = document.getElementById('filter-host').value.toLowerCase().trim();
var levelF = document.getElementById('filter-level').value;
var msgF = document.getElementById('filter-msg').value.toLowerCase().trim();
document.querySelectorAll('#messages .logrow').forEach(function (entry) {
var show = true;
if (hostF && !(entry.dataset.host || '').toLowerCase().includes(hostF)) show = false;
if (levelF && entry.dataset.level !== levelF) show = false;
if (msgF) {
var m = entry.querySelector('.m');
if (!m || !m.textContent.toLowerCase().includes(msgF)) show = false;
}
entry.style.display = show ? '' : 'none';
});
}
document.getElementById('filter-host').addEventListener('input', applyLogFilters);
document.getElementById('filter-level').addEventListener('change', applyLogFilters);
document.getElementById('filter-msg').addEventListener('input', applyLogFilters);
// ---- websocket -----------------------------------------------------------
function setWsStatus(ok) {
var el = document.getElementById('ws-status');
el.classList.toggle('reconnecting', !ok);
el.textContent = ok ? 'live' : 'reconnecting…';
}
function WS_Connect() {
if (!("WebSocket" in window)) {
console.log("WebSocket NOT supported by your Browser!");
return;
}
var ws_hbd = new WebSocket("{{ heartbeat_ws_url }}");
ws_hbd.onopen = function () {
setWsStatus(true);
ws_hbd.send("heartbeat_web");
};
ws_hbd.onerror = function (event) { console.log(event); };
ws_hbd.onmessage = function (event) {
var state = JSON.parse(event.data);
if (state.type === "host") {
updateHost(state.data);
} else if (state.type === "message") {
var msg = state.data;
addLogRow(msg, !!state.history);
noteAlert(msg.host, msg.level, msg.message, msg.ts);
}
};
ws_hbd.onclose = function () {
setWsStatus(false);
setTimeout(WS_Connect, 3000);
};
}
// ---- boot -----------------------------------------------------------------
INITIAL_HOSTS.forEach(function (h) { hostData[h.name] = h; });
Object.values(hostData).forEach(renderRow);
sortRows();
updateCounts();
seedActiveAlerts();
WS_Connect();
// refresh relative times once a minute
setInterval(function () {
Object.values(hostData).forEach(renderRow);
}, 60000);
</script>
</body>
</html>