246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react";
|
|
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
|
|
|
type LogLevel = "info" | "warning" | "error";
|
|
|
|
type LogEntry = {
|
|
id: string;
|
|
createdAt: string;
|
|
level: LogLevel;
|
|
source: string;
|
|
message: string;
|
|
status?: number;
|
|
method?: string;
|
|
path?: string;
|
|
stack?: string;
|
|
details?: unknown;
|
|
};
|
|
|
|
type LogPayload = {
|
|
entries: LogEntry[];
|
|
logPath: string;
|
|
maxBytes: number;
|
|
};
|
|
|
|
const LEVEL_TABS: Array<{ id: LogLevel | "all"; label: string }> = [
|
|
{ id: "all", label: "全部" },
|
|
{ id: "error", label: "错误" },
|
|
{ id: "warning", label: "警告" },
|
|
{ id: "info", label: "信息" }
|
|
];
|
|
|
|
export function LogManager() {
|
|
const [payload, setPayload] = useState<LogPayload | null>(null);
|
|
const [level, setLevel] = useState<LogLevel | "all">("all");
|
|
const [query, setQuery] = useState("");
|
|
const [draftQuery, setDraftQuery] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [clearing, setClearing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const logsRef = useRef<HTMLDivElement | null>(null);
|
|
const contentRef = useRef<HTMLElement | null>(null);
|
|
const feedbackRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
void loadLogs();
|
|
}, [level, query]);
|
|
|
|
useEffect(() => {
|
|
return runScopedMotion(logsRef, (scope) => revealChildren(scope));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
crossfadeIn(contentRef.current);
|
|
}, [payload?.entries]);
|
|
|
|
useEffect(() => {
|
|
pulseFeedback(feedbackRef.current);
|
|
}, [error, message]);
|
|
|
|
const stats = useMemo(() => {
|
|
const entries = payload?.entries || [];
|
|
return {
|
|
total: entries.length,
|
|
error: entries.filter((entry) => entry.level === "error").length,
|
|
warning: entries.filter((entry) => entry.level === "warning").length,
|
|
info: entries.filter((entry) => entry.level === "info").length
|
|
};
|
|
}, [payload]);
|
|
|
|
async function loadLogs() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params = new URLSearchParams({ level, limit: "200" });
|
|
if (query.trim()) params.set("q", query.trim());
|
|
const response = await fetch(`/api/logs?${params.toString()}`, { cache: "no-store" });
|
|
const nextPayload = await response.json();
|
|
if (!response.ok) throw new Error(nextPayload.error || "读取日志失败");
|
|
setPayload(nextPayload);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function clearLogs() {
|
|
if (!window.confirm("确定清空后台日志?")) return;
|
|
setClearing(true);
|
|
setError(null);
|
|
setMessage(null);
|
|
try {
|
|
const response = await fetch("/api/logs", { method: "DELETE" });
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error || "清空日志失败");
|
|
setMessage("日志已清空。");
|
|
await loadLogs();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setClearing(false);
|
|
}
|
|
}
|
|
|
|
function submitSearch(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setQuery(draftQuery.trim());
|
|
}
|
|
|
|
return (
|
|
<div className="log-manager" ref={logsRef}>
|
|
<div className="workspace-head" data-animate>
|
|
<div>
|
|
<h1 className="workspace-title">日志管理</h1>
|
|
</div>
|
|
<div className="workspace-meta">
|
|
<span className="status">{stats.total} 条</span>
|
|
<span className="status failed">{stats.error} 错误</span>
|
|
<span className="status running">{stats.warning} 警告</span>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="panel log-actions" data-animate>
|
|
<div className="segmented log-tabs" aria-label="日志级别">
|
|
{LEVEL_TABS.map((tab) => (
|
|
<button
|
|
className={level === tab.id ? "active" : ""}
|
|
aria-pressed={level === tab.id}
|
|
type="button"
|
|
key={tab.id}
|
|
onClick={() => setLevel(tab.id)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<form className="log-search" onSubmit={submitSearch}>
|
|
<Search aria-hidden="true" />
|
|
<input
|
|
value={draftQuery}
|
|
placeholder="搜索消息、路径、状态"
|
|
onChange={(event) => setDraftQuery(event.target.value)}
|
|
/>
|
|
</form>
|
|
<div className="toolbar">
|
|
<button className="button" type="button" onClick={loadLogs} disabled={loading || clearing}>
|
|
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
|
|
刷新
|
|
</button>
|
|
<button className="button danger" type="button" onClick={clearLogs} disabled={loading || clearing}>
|
|
{clearing ? <Loader2 className="spin" size={18} /> : <Trash2 size={18} />}
|
|
清空
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{error || message ? (
|
|
<div ref={feedbackRef}>
|
|
{error ? <div className="callout" role="alert">{error}</div> : null}
|
|
{message ? <div className="callout success-callout" role="status" aria-live="polite">{message}</div> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<section className="panel log-storage" data-animate>
|
|
<div>
|
|
<span>日志文件</span>
|
|
<strong>{payload?.logPath || ".runtime/logs/server-events.jsonl"}</strong>
|
|
</div>
|
|
<div>
|
|
<span>单文件上限</span>
|
|
<strong>{formatBytes(payload?.maxBytes || 0)}</strong>
|
|
</div>
|
|
<div>
|
|
<span>当前筛选</span>
|
|
<strong>{levelLabel(level)} / {query || "全部"}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="panel log-list" data-animate ref={contentRef}>
|
|
{loading && !payload ? (
|
|
<div className="settings-loading">
|
|
<Loader2 className="spin" size={18} />
|
|
正在读取日志
|
|
</div>
|
|
) : payload?.entries.length ? (
|
|
payload.entries.map((entry) => <LogRow entry={entry} key={entry.id} />)
|
|
) : (
|
|
<div className="log-empty">暂无日志</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LogRow({ entry }: { entry: LogEntry }) {
|
|
const details = detailText(entry);
|
|
return (
|
|
<article className={`log-row log-row-${entry.level}`}>
|
|
<div className="log-row-main">
|
|
<span className={`status ${entry.level === "error" ? "failed" : entry.level === "warning" ? "running" : ""}`}>{levelLabel(entry.level)}</span>
|
|
<div className="log-message">
|
|
<strong>{entry.message}</strong>
|
|
<small>{formatTime(entry.createdAt)} · {entry.source}{entry.status ? ` · ${entry.status}` : ""}{entry.method || entry.path ? ` · ${[entry.method, entry.path].filter(Boolean).join(" ")}` : ""}</small>
|
|
</div>
|
|
</div>
|
|
{details ? (
|
|
<details className="log-detail">
|
|
<summary>详情</summary>
|
|
<pre>{details}</pre>
|
|
</details>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function detailText(entry: LogEntry): string {
|
|
const parts: string[] = [];
|
|
if (entry.stack) parts.push(entry.stack);
|
|
if (entry.details !== undefined) parts.push(JSON.stringify(entry.details, null, 2));
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
function levelLabel(level: LogLevel | "all"): string {
|
|
if (level === "error") return "错误";
|
|
if (level === "warning") return "警告";
|
|
if (level === "info") return "信息";
|
|
return "全部";
|
|
}
|
|
|
|
function formatTime(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString("zh-CN", { hour12: false });
|
|
}
|
|
|
|
function formatBytes(value: number): string {
|
|
if (!value) return "-";
|
|
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
|
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
return `${value} B`;
|
|
}
|