feat: dedicated /log page with journal-backed history and nav item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user