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:
2026-06-17 03:41:54 +00:00
parent 1e17d46c78
commit 9e4a731c27
4 changed files with 259 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Lyra web chat server (FastAPI + vendored UI)
[Service]
Type=simple
WorkingDirectory=/home/serversdown/project-lyra
UnsetEnvironment=VIRTUAL_ENV
ExecStart=/home/serversdown/.local/bin/uv run lyra-web
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
+6 -1
View File
@@ -15,7 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, summary from lyra import chat, logbus, memory, summary
@@ -110,6 +110,11 @@ def create_app() -> FastAPI:
], ],
} }
@app.get("/logs")
async def logs_page() -> FileResponse:
"""Full-page, mobile-friendly live log viewer (separate from the chat UI)."""
return FileResponse(str(_STATIC / "logs.html"))
@app.get("/stream/logs") @app.get("/stream/logs")
async def stream_logs(request: Request) -> StreamingResponse: async def stream_logs(request: Request) -> StreamingResponse:
"""Live activity feed: replay the recent buffer, then stream new events.""" """Live activity feed: replay the recent buffer, then stream new events."""
+1
View File
@@ -69,6 +69,7 @@
<button id="newSessionBtn"> New</button> <button id="newSessionBtn"> New</button>
<button id="renameSessionBtn">✏️ Rename</button> <button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
</div> </div>
<!-- Status --> <!-- Status -->
+239
View File
@@ -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>