feat(webui): real-time streaming logs page

This commit is contained in:
xiaoxixi 2026-07-26 22:01:25 +08:00
parent b49f0ec8a5
commit 652a837e88

View File

@ -1,44 +1,289 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { Switch } from "bits-ui";
import { api } from "../lib/api.js"; import { api } from "../lib/api.js";
let search = $state(""); const MAX_LINES = 2000;
let lineCount = $state("500"); const LEVELS = ["全部", "INF", "WRN", "ERR"];
let autoRefresh = $state(true);
let lines = $state([]);
let files = $state([]);
let updatedAt = $state("");
let error = $state("");
let logView;
async function load() { let lines = $state([]);
const params = new URLSearchParams({ lines: lineCount }); let level = $state("全部");
if (search.trim()) params.set("search", search.trim()); let search = $state("");
let paused = $state(false);
let connected = $state(false);
let rate = $state(0);
let logView;
let nextId = 0;
let ws = null;
let reconnectTimer = null;
let rateTimer = null;
let timestamps = [];
let destroyed = false;
const filtered = $derived.by(() => {
const needle = search.trim().toLowerCase();
return lines.filter((line) => {
if (level !== "全部" && line.level !== level) return false;
if (needle && !line.message.toLowerCase().includes(needle) && !line.target.toLowerCase().includes(needle)) return false;
return true;
});
});
function normalizeLevel(raw) {
const upper = (raw || "").toUpperCase();
if (upper === "INFO") return "INF";
if (upper === "WARN") return "WRN";
if (upper === "ERROR") return "ERR";
return "DBG";
}
function parseHistoryLine(raw) {
const match = raw.match(/^(\S+)\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+(?:ThreadId\(\d+\)\s+)?([^:]+):\s*(.*)$/);
if (match) {
return { id: nextId++, ts: match[1], level: normalizeLevel(match[2]), target: match[3].trim(), message: match[4] };
}
return { id: nextId++, ts: "", level: "DBG", target: "", message: raw };
}
function appendLine(entry) {
entry.id = nextId++;
lines = [...lines, entry];
if (lines.length > MAX_LINES) {
lines = lines.slice(lines.length - MAX_LINES);
}
timestamps.push(Date.now());
if (!paused) {
requestAnimationFrame(() => {
if (logView) logView.scrollTop = logView.scrollHeight;
});
}
}
function connect() {
if (destroyed) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/ws/logs`);
ws.onopen = () => {
connected = true;
};
ws.onmessage = (event) => {
try { try {
const result = await api(`/api/logs?${params}`); const data = JSON.parse(event.data);
lines = result.lines; appendLine({
files = result.files; ts: data.ts || "",
updatedAt = new Date().toLocaleTimeString(); level: normalizeLevel(data.level),
error = ""; target: data.target || "",
requestAnimationFrame(() => { if (logView) logView.scrollTop = logView.scrollHeight; }); message: data.message || ""
} catch (caught) { error = caught.message; } });
} catch {}
};
ws.onclose = () => {
connected = false;
ws = null;
if (!destroyed) {
reconnectTimer = setTimeout(async () => {
await loadHistory();
connect();
}, 3000);
}
};
ws.onerror = () => {
ws?.close();
};
}
async function loadHistory() {
try {
const result = await api("/api/logs?lines=200");
lines = (result.lines || []).map(parseHistoryLine);
requestAnimationFrame(() => {
if (logView) logView.scrollTop = logView.scrollHeight;
});
} catch {}
}
function download() {
const text = filtered.map((l) => `${l.ts} ${l.level} ${l.target}: ${l.message}`).join("\n");
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `picobot-${new Date().toISOString().slice(0, 19).replaceAll(":", "")}.log`;
a.click();
URL.revokeObjectURL(url);
} }
onMount(() => { onMount(() => {
load(); loadHistory().then(() => connect());
const timer = setInterval(() => { if (autoRefresh) load(); }, 5000);
return () => clearInterval(timer); rateTimer = setInterval(() => {
const cutoff = Date.now() - 60000;
timestamps = timestamps.filter((t) => t > cutoff);
rate = timestamps.length;
}, 5000);
return () => {
destroyed = true;
clearTimeout(reconnectTimer);
clearInterval(rateTimer);
ws?.close();
};
}); });
</script> </script>
<section class="page active content-page"> <section class="page active content-page logs-page">
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); load(); }}> <div class="toolbar filters">
<label class="search grow"><span></span><input bind:value={search} placeholder="过滤日志" /></label> <div class="chips">
<select bind:value={lineCount}><option>200</option><option>500</option><option>1000</option><option>5000</option></select> {#each LEVELS as lv}
<label class="switch-label"><Switch.Root class="switch" bind:checked={autoRefresh}><Switch.Thumb class="switch-thumb" /></Switch.Root><span>自动刷新</span></label> <button class="chip" class:active={level === lv} onclick={() => (level = lv)}>{lv}</button>
<button class="secondary" type="submit">↻ 刷新</button> {/each}
</form> </div>
<div class="log-meta">{files.length} 个日志文件 · 显示 {lines.length}{#if updatedAt} · {updatedAt}{/if}</div> <label class="search grow"><span></span><input bind:value={search} placeholder="搜索日志" /></label>
<pre bind:this={logView} class="log-view">{error || lines.join("\n") || "没有匹配的日志"}</pre> <button class="secondary" class:active={paused} onclick={() => (paused = !paused)}>
{paused ? "▶ 继续" : "⏸ 暂停"}
</button>
<button class="secondary" onclick={download}> 下载</button>
</div>
<div class="status-bar" class:ok={connected} class:fail={!connected}>
{#if connected}
<span class="dot"></span> 实时推送中 · {rate} 行/分
{:else}
<span class="dot"></span> 已断开 — 重连中
{/if}
</div>
<div bind:this={logView} class="log-view">
{#each filtered as line (line.id)}
<div class="log-line">
<span class="ts">{line.ts}</span>
<span class="lvl lvl-{line.level}">{line.level}</span>
<span class="target">{line.target}</span>
<span class="msg">{line.message}</span>
</div>
{/each}
{#if filtered.length === 0}
<div class="empty">没有匹配的日志</div>
{/if}
</div>
</section> </section>
<style>
.chips {
display: flex;
gap: 4px;
}
.chip {
border: 1px solid var(--line);
background: var(--panel);
color: var(--muted);
border-radius: 99px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.chip.active {
background: var(--accent-soft);
border-color: var(--accent-border);
color: var(--accent);
}
.status-bar {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
}
.status-bar.ok {
color: var(--signal);
}
.status-bar.fail {
color: var(--danger);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.logs-page {
display: flex;
flex-direction: column;
}
.log-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.log-line {
display: flex;
gap: 8px;
padding: 1px 0;
line-height: 1.65;
}
.ts {
color: var(--faint);
flex-shrink: 0;
}
.lvl {
flex-shrink: 0;
width: 3ch;
font-weight: 700;
}
.lvl-INF { color: var(--signal); }
.lvl-WRN { color: var(--accent); }
.lvl-ERR { color: var(--danger); }
.lvl-DBG { color: var(--muted); }
.target {
color: var(--text-soft);
flex-shrink: 0;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg {
color: var(--text);
word-break: break-all;
}
.empty {
color: var(--muted);
padding: 24px 0;
text-align: center;
}
.secondary.active {
background: var(--accent-soft);
border-color: var(--accent-border);
color: var(--accent);
}
</style>