Add live log streaming via SSE

- Add /logs/stream SSE endpoint that spawns journalctl -f
- Stream new log lines to client in real-time
- Auto-scroll to bottom unless user has scrolled up
- Clean up process when client disconnects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Corey Johnson 2026-01-22 00:49:21 +00:00
parent 98c5d48846
commit 10cb94588d
2 changed files with 62 additions and 1 deletions

View File

@ -33,8 +33,38 @@ export const LogsPage = ({ service, logs }: LogsPageProps) => (
</a>
</div>
<pre style="margin-top: 1rem;">
<pre id="logs" style="margin-top: 1rem; max-height: 70vh; overflow-y: auto;">
<code>{logs.trim()}</code>
</pre>
<script dangerouslySetInnerHTML={{ __html: `
(function() {
const logsEl = document.getElementById('logs');
const codeEl = logsEl.querySelector('code');
let userScrolledUp = false;
logsEl.addEventListener('scroll', () => {
const atBottom = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 50;
userScrolledUp = !atBottom;
});
// Scroll to bottom initially
logsEl.scrollTop = logsEl.scrollHeight;
const service = new URLSearchParams(location.search).get('service') || 'phone-ap';
const es = new EventSource('/logs/stream?service=' + encodeURIComponent(service));
es.onmessage = (e) => {
codeEl.textContent += '\\n' + e.data;
if (!userScrolledUp) {
logsEl.scrollTop = logsEl.scrollHeight;
}
};
es.onerror = () => {
console.error('SSE connection lost, reconnecting...');
};
})();
`}} />
</Layout>
);

View File

@ -1,4 +1,5 @@
import { Hono } from "hono"
import { streamSSE } from "hono/streaming"
import { join } from "node:path"
import { $ } from "bun"
import { IndexPage } from "./components/IndexPage"
@ -55,6 +56,36 @@ app.get("/logs", async (c) => {
}
})
// SSE endpoint for live log streaming
app.get("/logs/stream", async (c) => {
const service = c.req.query("service") || "phone"
const validServices = ["phone-ap", "phone-web", "phone"]
const selectedService = validServices.includes(service) ? service : "phone"
return streamSSE(c, async (stream) => {
const proc = Bun.spawn(
["journalctl", "-u", `${selectedService}.service`, "-f", "-n", "0", "--no-pager", "--no-hostname"],
{ stdout: "pipe" }
)
const reader = proc.stdout.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
for (const line of text.split("\n").filter(Boolean)) {
await stream.writeSSE({ data: line })
}
}
} finally {
proc.kill()
}
})
})
// Handle WiFi configuration submission
app.post("/save", async (c) => {
const formData = await c.req.parseBody()