225 lines
9.4 KiB
HTML
225 lines
9.4 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; }
|
|
|
|
.log-main { max-width: 1100px; margin: 0 auto; padding: 0 20px 60px; }
|
|
|
|
.st-toolbar input, .st-toolbar select {
|
|
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;
|
|
}
|
|
.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); }
|
|
|
|
.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; }
|
|
|
|
.empty { padding: 24px 14px; text-align: center; color: var(--st-faint); font-size: 12.5px; }
|
|
.loadmore {
|
|
display: block; margin: 14px auto 0; cursor: pointer;
|
|
font-family: var(--st-mono); font-size: 12px; color: var(--st-muted);
|
|
background: var(--st-surface); border: 1px solid var(--st-line); border-radius: 999px;
|
|
padding: 6px 16px;
|
|
}
|
|
.loadmore:hover { color: var(--st-accent); border-color: var(--st-accent); }
|
|
.loadmore:disabled { opacity: .5; cursor: default; }
|
|
|
|
@media (max-width: 760px) {
|
|
.logrow { grid-template-columns: 66px minmax(0,1fr); }
|
|
.logrow .lvlc, .logrow .h { display: none; }
|
|
}
|
|
</style>
|
|
|
|
<body>
|
|
{% include 'nav.html' %}
|
|
|
|
<div class="st-toolbar">
|
|
<span class="brand">hbd<span class="tld">·log</span></span>
|
|
<span class="hintline">connectivity and alert events — full journal history</span>
|
|
<span class="spacer"></span>
|
|
<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 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="log-main">
|
|
<section class="st">
|
|
<div class="sec-head">
|
|
<h2>log<span class="colon">:</span></h2>
|
|
<span class="count" id="log-count"></span>
|
|
</div>
|
|
<div class="list" id="messages">
|
|
<div class="empty">Loading events…</div>
|
|
</div>
|
|
<button class="loadmore" id="load-older" style="display:none">load older</button>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
var PAGE_SIZE = 200;
|
|
var oldestTs = null; // ts of the oldest loaded row (pagination cursor)
|
|
var seq = 0; // guards against out-of-order fetch responses
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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">'
|
|
+ '<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 currentFilters() {
|
|
return {
|
|
host: document.getElementById('filter-host').value.trim(),
|
|
level: document.getElementById('filter-level').value,
|
|
q: document.getElementById('filter-msg').value.trim()
|
|
};
|
|
}
|
|
|
|
function matchesFilters(msg) {
|
|
var f = currentFilters();
|
|
if (f.host && !(msg.host || '').toLowerCase().includes(f.host.toLowerCase())) return false;
|
|
if (f.level && (msg.level || '').toLowerCase() !== f.level) return false;
|
|
if (f.q && !(msg.message || '').toLowerCase().includes(f.q.toLowerCase())) return false;
|
|
return true;
|
|
}
|
|
|
|
function updateCount() {
|
|
var n = document.querySelectorAll('#messages .logrow').length;
|
|
document.getElementById('log-count').textContent = n ? n + ' events loaded' : '';
|
|
}
|
|
|
|
async function fetchLog(reset) {
|
|
var mySeq = ++seq;
|
|
var box = document.getElementById('messages');
|
|
var btn = document.getElementById('load-older');
|
|
btn.disabled = true;
|
|
var f = currentFilters();
|
|
var params = new URLSearchParams();
|
|
params.set('limit', String(PAGE_SIZE));
|
|
if (f.host) params.set('host', f.host);
|
|
if (f.level) params.set('level', f.level);
|
|
if (f.q) params.set('q', f.q);
|
|
if (!reset && oldestTs != null) params.set('before', String(oldestTs));
|
|
var data;
|
|
try {
|
|
var r = await fetch('/api/0/log?' + params.toString());
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
data = await r.json();
|
|
} catch (e) {
|
|
if (mySeq === seq && reset) {
|
|
box.innerHTML = '<div class="empty">Failed to load events: ' + escHtml(e.message) + '</div>';
|
|
}
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
if (mySeq !== seq) return; // a newer request superseded this one
|
|
if (reset) { box.innerHTML = ''; oldestTs = null; }
|
|
var html = '';
|
|
data.events.forEach(function (ev) {
|
|
html += logRowHtml(ev);
|
|
if (ev.ts != null && (oldestTs == null || ev.ts < oldestTs)) oldestTs = ev.ts;
|
|
});
|
|
box.insertAdjacentHTML('beforeend', html);
|
|
if (!box.children.length) box.innerHTML = '<div class="empty">No events found.</div>';
|
|
btn.style.display = data.more ? '' : 'none';
|
|
btn.disabled = false;
|
|
updateCount();
|
|
}
|
|
|
|
function onFilterChange() { fetchLog(true); }
|
|
|
|
function debounce(fn, ms) {
|
|
var t;
|
|
return function () { clearTimeout(t); t = setTimeout(fn, ms); };
|
|
}
|
|
document.getElementById('filter-host').addEventListener('input', debounce(onFilterChange, 300));
|
|
document.getElementById('filter-msg').addEventListener('input', debounce(onFilterChange, 300));
|
|
document.getElementById('filter-level').addEventListener('change', onFilterChange);
|
|
document.getElementById('load-older').addEventListener('click', function () { fetchLog(false); });
|
|
|
|
// ---- live tail over the shared 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)) return;
|
|
var ws_hbd = new WebSocket("{{ heartbeat_ws_url }}");
|
|
ws_hbd.onopen = function () {
|
|
setWsStatus(true);
|
|
ws_hbd.send("heartbeat_web");
|
|
};
|
|
ws_hbd.onmessage = function (event) {
|
|
var state = JSON.parse(event.data);
|
|
// history replays are covered by the API seed; only tail new events
|
|
if (state.type !== "message" || state.history) return;
|
|
var msg = state.data;
|
|
if (!matchesFilters(msg)) return;
|
|
var box = document.getElementById('messages');
|
|
var placeholder = box.querySelector('.empty');
|
|
if (placeholder) placeholder.remove();
|
|
box.insertAdjacentHTML('afterbegin', logRowHtml(msg));
|
|
updateCount();
|
|
};
|
|
ws_hbd.onclose = function () {
|
|
setWsStatus(false);
|
|
setTimeout(WS_Connect, 3000);
|
|
};
|
|
}
|
|
|
|
// ---- boot ----------------------------------------------------------------
|
|
fetchLog(true);
|
|
WS_Connect();
|
|
</script>
|
|
</body>
|
|
</html>
|