feat(web): dedicated full-page log viewer + run lyra-web as a service
The inline log panel is cramped, especially on mobile. Add a standalone mobile-first log page and serve the chat server under systemd like the dream loop (the nohup process didn't survive cleanly). - static/logs.html: full-page live log — level filter chips, text search, pause/resume with buffering, autoscroll toggle, color-coded levels, and the expandable "view full prompt" block (where the now-note is visible in context) - server: GET /logs serves the page (FileResponse) - index.html: "⛶ Full Log" button opens /logs in a new tab - deploy/lyra-web.service: user service so the chat server is reboot-resilient Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0d12" />
|
||||
<title>Lyra — Live Log</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--bg-elev: #141821;
|
||||
--bg-line: #11141b;
|
||||
--border: #232936;
|
||||
--text: #e6e9ef;
|
||||
--fade: #8b93a7;
|
||||
--accent: #7aa2ff;
|
||||
--info: #5ad1a0;
|
||||
--debug: #8b93a7;
|
||||
--error: #ff6b6b;
|
||||
--system: #c08bff;
|
||||
--warn: #ffcf6b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body { display: flex; flex-direction: column; }
|
||||
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: env(safe-area-inset-top) 12px 0;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 0 10px;
|
||||
}
|
||||
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: .2px; }
|
||||
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--fade); flex: none; }
|
||||
.dot.on { background: var(--info); box-shadow: 0 0 8px var(--info); }
|
||||
.dot.off { background: var(--error); }
|
||||
.count { margin-left: auto; color: var(--fade); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.controls {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
font-size: .8rem; padding: 6px 12px; border-radius: 999px;
|
||||
border: 1px solid var(--border); background: var(--bg-line); color: var(--fade);
|
||||
cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.chip.active { color: var(--text); border-color: var(--accent); background: #1b2333; }
|
||||
#search {
|
||||
flex: 1 1 140px; min-width: 120px;
|
||||
background: var(--bg-line); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; padding: 8px 10px; font-size: .9rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: .8rem; padding: 7px 11px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--bg-line); color: var(--text);
|
||||
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.btn.active { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
main { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 8px 8px 24px; }
|
||||
.empty { color: var(--fade); text-align: center; padding: 40px 16px; }
|
||||
|
||||
.line {
|
||||
border-bottom: 1px solid var(--bg-line);
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.line-head {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.t { color: var(--fade); font-size: .72rem; font-variant-numeric: tabular-nums; flex: none; }
|
||||
.lvl {
|
||||
font-size: .68rem; text-transform: uppercase; letter-spacing: .4px;
|
||||
padding: 1px 7px; border-radius: 5px; font-weight: 700; flex: none;
|
||||
}
|
||||
.lvl-info { color: var(--info); background: #0f2a20; }
|
||||
.lvl-debug { color: var(--debug); background: #1a1f29; }
|
||||
.lvl-error { color: var(--error); background: #2e1414; }
|
||||
.lvl-system { color: var(--system); background: #221536; }
|
||||
.lvl-warn { color: var(--warn); background: #2c2410; }
|
||||
.msg { font-size: .92rem; font-weight: 500; }
|
||||
.fields {
|
||||
width: 100%; color: var(--fade); font-size: .8rem; margin-top: 3px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
details.detail { margin-top: 6px; }
|
||||
details.detail > summary {
|
||||
cursor: pointer; color: var(--accent); font-size: .82rem;
|
||||
list-style: none; padding: 4px 0;
|
||||
}
|
||||
details.detail > summary::-webkit-details-marker { display: none; }
|
||||
details.detail > summary::before { content: "▸ "; }
|
||||
details.detail[open] > summary::before { content: "▾ "; }
|
||||
details.detail pre {
|
||||
background: var(--bg-line); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px; margin: 6px 0 2px; font-size: .78rem; line-height: 1.45;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
max-height: 60vh; overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<span class="dot" id="dot"></span>
|
||||
<h1>Lyra · Live Log</h1>
|
||||
<a class="back" href="/" title="Back to chat">← Chat</a>
|
||||
<span class="count" id="count">0</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="chips" id="chips">
|
||||
<span class="chip active" data-level="info">info</span>
|
||||
<span class="chip active" data-level="debug">debug</span>
|
||||
<span class="chip active" data-level="error">error</span>
|
||||
<span class="chip active" data-level="system">system</span>
|
||||
</div>
|
||||
<input id="search" type="search" placeholder="Filter text…" autocomplete="off" />
|
||||
<button class="btn active" id="autoscroll" title="Auto-scroll to newest">⤓ Auto</button>
|
||||
<button class="btn" id="pause" title="Pause incoming events">⏸ Pause</button>
|
||||
<button class="btn" id="clear" title="Clear the view">🗑 Clear</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="log">
|
||||
<div class="empty" id="empty">📡 Waiting for activity…</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const MAX_LINES = 2000;
|
||||
const logEl = document.getElementById('log');
|
||||
const emptyEl = document.getElementById('empty');
|
||||
const dot = document.getElementById('dot');
|
||||
const countEl = document.getElementById('count');
|
||||
const searchEl = document.getElementById('search');
|
||||
const autoBtn = document.getElementById('autoscroll');
|
||||
const pauseBtn = document.getElementById('pause');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
|
||||
const active = new Set(['info', 'debug', 'error', 'system', 'warn']);
|
||||
let autoscroll = true, paused = false, total = 0;
|
||||
const buffered = []; // events held while paused
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
|
||||
function fmtVal(v) { return (typeof v === 'object') ? JSON.stringify(v) : String(v); }
|
||||
|
||||
document.getElementById('chips').addEventListener('click', (e) => {
|
||||
const chip = e.target.closest('.chip'); if (!chip) return;
|
||||
const lvl = chip.dataset.level;
|
||||
if (active.has(lvl)) { active.delete(lvl); chip.classList.remove('active'); }
|
||||
else { active.add(lvl); chip.classList.add('active'); }
|
||||
applyFilters();
|
||||
});
|
||||
searchEl.addEventListener('input', applyFilters);
|
||||
autoBtn.addEventListener('click', () => { autoscroll = !autoscroll; autoBtn.classList.toggle('active', autoscroll); if (autoscroll) scrollDown(); });
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
paused = !paused; pauseBtn.classList.toggle('active', paused);
|
||||
pauseBtn.textContent = paused ? '▶ Resume' : '⏸ Pause';
|
||||
if (!paused) { buffered.splice(0).forEach(render); applyFilters(); }
|
||||
});
|
||||
clearBtn.addEventListener('click', () => {
|
||||
logEl.querySelectorAll('.line').forEach(n => n.remove());
|
||||
total = 0; countEl.textContent = '0'; emptyEl.classList.remove('hidden');
|
||||
});
|
||||
|
||||
function matches(node) {
|
||||
if (!active.has(node.dataset.level)) return false;
|
||||
const q = searchEl.value.trim().toLowerCase();
|
||||
if (q && !node.dataset.text.includes(q)) return false;
|
||||
return true;
|
||||
}
|
||||
function applyFilters() {
|
||||
let shown = 0;
|
||||
logEl.querySelectorAll('.line').forEach(n => {
|
||||
const ok = matches(n); n.classList.toggle('hidden', !ok); if (ok) shown++;
|
||||
});
|
||||
emptyEl.classList.toggle('hidden', shown > 0);
|
||||
if (autoscroll) scrollDown();
|
||||
}
|
||||
function scrollDown() { logEl.scrollTop = logEl.scrollHeight; }
|
||||
|
||||
function render(ev) {
|
||||
const level = ev.level || 'info';
|
||||
const time = new Date((ev.ts || 0) * 1000).toLocaleTimeString();
|
||||
const fields = Object.assign({}, ev.fields || {});
|
||||
const detail = fields.detail; delete fields.detail;
|
||||
const fieldStr = Object.entries(fields).map(([k, v]) => `${k}=${fmtVal(v)}`).join(' ');
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'line';
|
||||
line.dataset.level = level;
|
||||
line.dataset.text = `${ev.msg || ''} ${fieldStr} ${detail || ''}`.toLowerCase();
|
||||
line.innerHTML =
|
||||
`<div class="line-head">` +
|
||||
`<span class="t">${esc(time)}</span>` +
|
||||
`<span class="lvl lvl-${esc(level)}">${esc(level)}</span>` +
|
||||
`<span class="msg">${esc(ev.msg || '')}</span>` +
|
||||
`</div>` +
|
||||
(fieldStr ? `<div class="fields">${esc(fieldStr)}</div>` : '') +
|
||||
(detail ? `<details class="detail"><summary>view full prompt</summary><pre>${esc(detail)}</pre></details>` : '');
|
||||
|
||||
if (!matches(line)) line.classList.add('hidden');
|
||||
logEl.appendChild(line);
|
||||
emptyEl.classList.add('hidden');
|
||||
total++; countEl.textContent = total;
|
||||
|
||||
while (logEl.querySelectorAll('.line').length > MAX_LINES) {
|
||||
logEl.querySelector('.line').remove();
|
||||
}
|
||||
if (autoscroll && !line.classList.contains('hidden')) scrollDown();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const src = new EventSource('/stream/logs');
|
||||
src.onopen = () => { dot.className = 'dot on'; };
|
||||
src.onerror = () => { dot.className = 'dot off'; }; // EventSource auto-reconnects
|
||||
src.onmessage = (e) => {
|
||||
let ev; try { ev = JSON.parse(e.data); } catch (_) { return; }
|
||||
if (paused) { buffered.push(ev); if (buffered.length > MAX_LINES) buffered.shift(); return; }
|
||||
render(ev);
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user