Files
NianAIGC/components/log-manager.tsx
2026-06-03 12:03:14 +08:00

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`;
}