Files
andreasandClaude Fable 5 5d9f161706 feat: extend the record-row design system to Host Overview, Alerts, and About
Extract the settings redesign's tokens and components into shared
static/hbd-ui.css + hbd-ui.js and dedupe settings.html against them.
About becomes yaml-key sections with kv rows; Alerts gets stat tiles,
chip filters, and alert record rows (same fetch/ack logic); Host
Overview keeps its DOM and live-update JS but is re-skinned to the
token system with a page toolbar. Live Dashboard intentionally
untouched pending its own redesign.

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

273 lines
11 KiB
HTML
Raw Permalink 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; }
.alerts-main { max-width: 1100px; margin: 0 auto; padding: 0 20px 60px; }
/* summary tiles */
.stats { display: flex; gap: 10px; flex-wrap: wrap; padding-top: 18px; }
.stat {
flex: 1 1 140px; display: flex; align-items: baseline; gap: 10px;
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 8px;
padding: 10px 14px;
}
.stat .num { font-family: var(--st-mono); font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat .lbl { font-family: var(--st-mono); font-size: 11.5px; color: var(--st-faint); }
.stat.crit .num { color: var(--st-crit); }
.stat.warn .num { color: var(--st-warn); }
.stat.ok .num { color: var(--st-ok); }
/* filter bar */
.filterbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 14px 0 0; }
.filterbar .fbtn {
font-family: var(--st-mono); font-size: 12px; cursor: pointer;
color: var(--st-muted); background: var(--st-surface); border: 1px solid var(--st-line);
border-radius: 999px; padding: 5px 12px;
}
.filterbar .fbtn.active { background: var(--st-accent-soft); border-color: var(--st-accent); color: var(--st-accent); font-weight: 600; }
.filterbar 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: 170px;
}
.filterbar input.invalid { border-color: var(--st-crit); }
.filterbar .upd { margin-left: auto; font-size: 11.5px; color: var(--st-faint); }
/* alert row specifics */
.row.alert-crit { border-left: 3px solid var(--st-crit); padding-left: 11px; }
.row.alert-warn { border-left: 3px solid var(--st-warn); padding-left: 11px; }
.row.acked { opacity: .55; border-left-style: dashed; }
.row .id a { color: inherit; text-decoration: none; }
.row .id a:hover { color: var(--st-accent); }
.acked-chip { color: var(--st-ok); font-size: 12px; white-space: nowrap; }
.empty {
padding: 36px 14px; text-align: center; color: var(--st-faint); font-size: 13px;
}
.empty .big { font-size: 26px; color: var(--st-ok); display: block; margin-bottom: 6px; }
.errorbox {
background: var(--st-crit-soft); color: var(--st-crit);
border-radius: 8px; padding: 14px 16px; font-size: 13px;
}
</style>
<body>
{% include 'nav.html' %}
<div class="st-toolbar">
<span class="brand">hbd<span class="tld">·alerts</span></span>
<span class="hintline">threshold violations and reachability — refreshes every 15 s</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="alerts-main">
<div class="stats">
<div class="stat crit"><span class="num" id="critical-count"></span><span class="lbl">critical</span></div>
<div class="stat warn"><span class="num" id="warning-count"></span><span class="lbl">warning</span></div>
<div class="stat ok"><span class="num" id="host-count"></span><span class="lbl">hosts</span></div>
</div>
<div class="filterbar">
<button class="fbtn active" onclick="filterAlerts('all', this)">all</button>
<button class="fbtn" onclick="filterAlerts('critical', this)">critical</button>
<button class="fbtn" onclick="filterAlerts('warning', this)">warning</button>
<input id="host-filter" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
<span class="upd">updated <span id="last-update-time">never</span></span>
</div>
<section class="st" id="alerts">
<div class="sec-head">
<h2>alerts<span class="colon">:</span></h2>
<span class="count" id="alert-count"></span>
</div>
<div class="list" id="alerts-list">
<div class="empty">Loading alerts…</div>
</div>
</section>
</div>
<script>
let currentFilter = 'all';
let allAlerts = [];
let hostFilterRe = null;
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
async function loadAlerts() {
try {
const response = await fetch('/api/0/alerts');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
allAlerts = data.alerts;
document.getElementById('critical-count').textContent = data.summary.critical || 0;
document.getElementById('warning-count').textContent = data.summary.warning || 0;
document.getElementById('host-count').textContent = data.host_count || 0;
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
renderAlerts(allAlerts);
} catch (error) {
document.getElementById('alerts-list').innerHTML =
`<div class="errorbox">Failed to load alerts: ${escHtml(error.message)}. Retrying automatically.</div>`;
}
}
function renderAlerts(alerts) {
const container = document.getElementById('alerts-list');
let filtered = alerts;
if (currentFilter !== 'all') {
filtered = filtered.filter(a => a.level.toLowerCase() === currentFilter);
}
if (hostFilterRe) {
filtered = filtered.filter(a => hostFilterRe.test(a.hostname));
}
document.getElementById('alert-count').textContent =
filtered.length === alerts.length ? alerts.length : `${filtered.length} of ${alerts.length}`;
if (filtered.length === 0) {
container.innerHTML = (currentFilter === 'all' && !hostFilterRe && alerts.length === 0)
? `<div class="empty"><span class="big">✓</span>All systems normal — no active alerts.</div>`
: `<div class="empty">No matching alerts.</div>`;
return;
}
container.innerHTML = filtered.map(renderAlert).join('');
}
function renderAlert(alert) {
const level = alert.level.toLowerCase();
const cls = level === 'critical' ? 'crit' : (level === 'warning' ? 'warn' : 'level');
const duration = getDuration(alert.since);
const acked = alert.acknowledged || false;
const metric = (alert.metric_path.includes('.')
? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1)
: alert.metric_path).replace(/_status_code$/, '');
let chips = `<span class="chip ${cls}">${escHtml(alert.level)}</span>`;
chips += `<span class="chip"><span class="k">metric</span> ${escHtml(metric)}</span>`;
if (alert.formatted_message) {
chips += `<span class="chip">${escHtml(alert.formatted_message)}</span>`;
} else {
chips += `<span class="chip"><span class="k">value</span> ${escHtml(formatValue(alert.last_value))}</span>`;
if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
chips += `<span class="chip level">${escHtml(alert.operator)} ${escHtml(formatValue(alert.threshold_value))}</span>`;
}
}
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
chips += `<span class="chip level">recovers ${recOp} ${escHtml(formatValue(alert.recovery_threshold))}</span>`;
}
chips += `<span class="chip"><span class="k">for</span> ${duration}</span>`;
const act = acked
? `<span class="acked-chip">✓ acknowledged</span>`
: `<button class="btn small" onclick="acknowledgeAlert('${escHtml(alert.hostname)}', '${escHtml(alert.metric_path)}', event)">Acknowledge</button>`;
return `
<div class="row alert-${cls === 'level' ? 'warn' : cls}${acked ? ' acked' : ''}">
<span class="id">
<span class="dot ${cls === 'crit' ? 'crit' : 'warn'}"></span>
<a class="name" href="/plugins#${encodeURIComponent(alert.hostname)}">${escHtml(alert.hostname)}</a>
</span>
<span class="facts">${chips}</span>
<span class="act">${act}</span>
</div>`;
}
function formatValue(value) {
if (typeof value === 'number') {
if (value > 1000) return value.toLocaleString();
return value.toFixed(2);
}
return value;
}
function getDuration(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) {
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
function filterAlerts(filter, btn) {
currentFilter = filter;
document.querySelectorAll('.filterbar .fbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderAlerts(allAlerts);
}
async function acknowledgeAlert(hostname, metricPath, event) {
if (event) event.stopPropagation();
const button = event.target;
button.disabled = true;
button.textContent = 'Acknowledging…';
try {
const response = await fetch('/api/0/alerts/acknowledge', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hostname: hostname, metric_path: metricPath}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
const a = allAlerts.find(x => x.hostname === hostname && x.metric_path === metricPath);
if (a) {
a.acknowledged = true;
a.acknowledged_at = result.acknowledged_at;
}
renderAlerts(allAlerts);
} catch (error) {
alert(`Failed to acknowledge alert: ${error.message}`);
button.disabled = false;
button.textContent = 'Acknowledge';
}
}
function onHostFilterInput(input) {
const val = input.value.trim();
if (!val) {
hostFilterRe = null;
input.classList.remove('invalid');
} else {
try {
hostFilterRe = new RegExp(val, 'i');
input.classList.remove('invalid');
} catch (_) {
hostFilterRe = null;
input.classList.add('invalid');
}
}
renderAlerts(allAlerts);
}
setInterval(loadAlerts, 15000);
(function () {
const param = new URLSearchParams(window.location.search).get('filter');
if (param) {
const input = document.getElementById('host-filter');
input.value = param;
onHostFilterInput(input);
}
})();
loadAlerts();
</script>
</body>
</html>