feat(webui): real-time streaming logs page
This commit is contained in:
parent
b49f0ec8a5
commit
652a837e88
@ -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 {
|
||||||
|
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 {
|
try {
|
||||||
const result = await api(`/api/logs?${params}`);
|
const result = await api("/api/logs?lines=200");
|
||||||
lines = result.lines;
|
lines = (result.lines || []).map(parseHistoryLine);
|
||||||
files = result.files;
|
requestAnimationFrame(() => {
|
||||||
updatedAt = new Date().toLocaleTimeString();
|
if (logView) logView.scrollTop = logView.scrollHeight;
|
||||||
error = "";
|
});
|
||||||
requestAnimationFrame(() => { if (logView) logView.scrollTop = logView.scrollHeight; });
|
} catch {}
|
||||||
} catch (caught) { error = caught.message; }
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user