Add backend log management
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Archive,
|
||||
LogIn,
|
||||
LogOut,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UserCircle
|
||||
@@ -19,6 +20,7 @@ import type { AuthUser } from "@/lib/auth/session";
|
||||
const nav = [
|
||||
{ href: "/create", label: "创作", icon: Sparkles },
|
||||
{ href: "/assets", label: "结果", icon: Archive },
|
||||
{ href: "/logs", label: "日志", icon: ScrollText },
|
||||
{ href: "/settings", label: "设置", icon: Settings }
|
||||
];
|
||||
|
||||
|
||||
245
components/log-manager.tsx
Normal file
245
components/log-manager.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"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`;
|
||||
}
|
||||
Reference in New Issue
Block a user