130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const LEVEL_CLASS: Record<string, string> = {
|
||
trace: "text-zinc-600",
|
||
debug: "text-zinc-500",
|
||
info: "text-sky-400",
|
||
warn: "text-amber-400",
|
||
warning: "text-amber-400",
|
||
error: "text-red-400",
|
||
fatal: "text-red-500",
|
||
};
|
||
|
||
function parseLine(line: string): { text: string; className: string } {
|
||
const t = line.trim();
|
||
if (t.startsWith("{")) {
|
||
try {
|
||
const o = JSON.parse(t) as Record<string, unknown>;
|
||
if (o && typeof o === "object" && typeof o.lvl === "string") {
|
||
return {
|
||
text: typeof o.msg === "string" ? o.msg : t,
|
||
className: LEVEL_CLASS[o.lvl.toLowerCase()] ?? "text-zinc-300",
|
||
};
|
||
}
|
||
} catch {
|
||
// 不是合法 JSON,原样显示
|
||
}
|
||
}
|
||
return { text: line, className: "text-zinc-300" };
|
||
}
|
||
|
||
interface LogViewerProps {
|
||
/** SSE 模式:订阅 /api/runs/[runId]/events。runId 变化时请通过 key 重挂载 */
|
||
runId?: number;
|
||
/** 静态模式:直接展示完整日志 */
|
||
log?: string;
|
||
live?: boolean;
|
||
className?: string;
|
||
emptyText?: string;
|
||
/** SSE 收到 done 事件时回调 */
|
||
onDone?: () => void;
|
||
}
|
||
|
||
export function LogViewer({
|
||
runId,
|
||
log,
|
||
live,
|
||
className,
|
||
emptyText = "暂无日志",
|
||
onDone,
|
||
}: LogViewerProps) {
|
||
const isLive = live && runId != null;
|
||
const [sseLines, setSseLines] = useState<string[]>([]);
|
||
const boxRef = useRef<HTMLDivElement>(null);
|
||
const stickRef = useRef(true);
|
||
const onDoneRef = useRef(onDone);
|
||
|
||
useEffect(() => {
|
||
onDoneRef.current = onDone;
|
||
}, [onDone]);
|
||
|
||
useEffect(() => {
|
||
if (!isLive) return;
|
||
const es = new EventSource(`/api/runs/${runId}/events`);
|
||
es.onmessage = (e) => {
|
||
try {
|
||
const d = JSON.parse(e.data) as { line?: unknown };
|
||
setSseLines((prev) => [
|
||
...prev,
|
||
typeof d.line === "string" ? d.line : String(e.data),
|
||
]);
|
||
} catch {
|
||
setSseLines((prev) => [...prev, String(e.data)]);
|
||
}
|
||
};
|
||
es.addEventListener("done", () => {
|
||
es.close();
|
||
onDoneRef.current?.();
|
||
});
|
||
es.onerror = () => es.close();
|
||
return () => es.close();
|
||
}, [isLive, runId]);
|
||
|
||
const lines = useMemo(() => {
|
||
if (isLive) return sseLines;
|
||
if (log == null || log === "") return [];
|
||
return log.replace(/\n+$/, "").split("\n");
|
||
}, [isLive, sseLines, log]);
|
||
|
||
useEffect(() => {
|
||
const el = boxRef.current;
|
||
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
|
||
}, [lines]);
|
||
|
||
return (
|
||
<div
|
||
ref={boxRef}
|
||
onScroll={() => {
|
||
const el = boxRef.current;
|
||
if (!el) return;
|
||
// 用户上翻时暂停自动滚动,回到底部附近时恢复
|
||
stickRef.current =
|
||
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||
}}
|
||
className={cn(
|
||
"h-80 overflow-auto rounded-md border border-zinc-800 bg-zinc-950 p-3 font-mono text-xs leading-5",
|
||
className
|
||
)}
|
||
>
|
||
{lines.length === 0 ? (
|
||
<div className="text-zinc-500">{emptyText}</div>
|
||
) : (
|
||
lines.map((line, i) => {
|
||
const { text, className: lineCls } = parseLine(line);
|
||
return (
|
||
<div
|
||
key={i}
|
||
className={cn("whitespace-pre-wrap break-all", lineCls)}
|
||
>
|
||
{text || " "}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
);
|
||
}
|