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
273 lines
11 KiB
HTML
273 lines
11 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
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>
|