feat(webui): real-time streaming logs page
This commit is contained in:
parent
b49f0ec8a5
commit
652a837e88
@ -1,44 +1,289 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Switch } from "bits-ui";
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
let search = $state("");
|
||||
let lineCount = $state("500");
|
||||
let autoRefresh = $state(true);
|
||||
let lines = $state([]);
|
||||
let files = $state([]);
|
||||
let updatedAt = $state("");
|
||||
let error = $state("");
|
||||
let logView;
|
||||
const MAX_LINES = 2000;
|
||||
const LEVELS = ["全部", "INF", "WRN", "ERR"];
|
||||
|
||||
async function load() {
|
||||
const params = new URLSearchParams({ lines: lineCount });
|
||||
if (search.trim()) params.set("search", search.trim());
|
||||
let lines = $state([]);
|
||||
let level = $state("全部");
|
||||
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 {
|
||||
const data = JSON.parse(event.data);
|
||||
appendLine({
|
||||
ts: data.ts || "",
|
||||
level: normalizeLevel(data.level),
|
||||
target: data.target || "",
|
||||
message: data.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?${params}`);
|
||||
lines = result.lines;
|
||||
files = result.files;
|
||||
updatedAt = new Date().toLocaleTimeString();
|
||||
error = "";
|
||||
requestAnimationFrame(() => { if (logView) logView.scrollTop = logView.scrollHeight; });
|
||||
} catch (caught) { error = caught.message; }
|
||||
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(() => {
|
||||
load();
|
||||
const timer = setInterval(() => { if (autoRefresh) load(); }, 5000);
|
||||
return () => clearInterval(timer);
|
||||
loadHistory().then(() => connect());
|
||||
|
||||
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>
|
||||
|
||||
<section class="page active content-page">
|
||||
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); load(); }}>
|
||||
<label class="search grow"><span>⌕</span><input bind:value={search} placeholder="过滤日志" /></label>
|
||||
<select bind:value={lineCount}><option>200</option><option>500</option><option>1000</option><option>5000</option></select>
|
||||
<label class="switch-label"><Switch.Root class="switch" bind:checked={autoRefresh}><Switch.Thumb class="switch-thumb" /></Switch.Root><span>自动刷新</span></label>
|
||||
<button class="secondary" type="submit">↻ 刷新</button>
|
||||
</form>
|
||||
<div class="log-meta">{files.length} 个日志文件 · 显示 {lines.length} 行{#if updatedAt} · {updatedAt}{/if}</div>
|
||||
<pre bind:this={logView} class="log-view">{error || lines.join("\n") || "没有匹配的日志"}</pre>
|
||||
<section class="page active content-page logs-page">
|
||||
<div class="toolbar filters">
|
||||
<div class="chips">
|
||||
{#each LEVELS as lv}
|
||||
<button class="chip" class:active={level === lv} onclick={() => (level = lv)}>{lv}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="search grow"><span>⌕</span><input bind:value={search} placeholder="搜索日志" /></label>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user