Restore durable log ingest and typed plugin projections
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
@@ -10,11 +10,13 @@ import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTim
|
||||
import { EmptyState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
|
||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||
|
||||
const terminalJobResultPollMs = 1000;
|
||||
const terminalJobResultPollAttempts = 30;
|
||||
const terminalHistoryWindow = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
"game.scum": [
|
||||
@@ -90,11 +92,16 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
const [followLatest, setFollowLatest] = useState(true);
|
||||
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyStreams, setHistoryStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [historyLines, setHistoryLines] = useState<HistoryLineState>({ status: "idle" });
|
||||
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const followLatestRef = useRef(true);
|
||||
const serverTimeRef = useRef<string | undefined>(undefined);
|
||||
const initialHistoryPendingRef = useRef(false);
|
||||
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
||||
const historyRequestRef = useRef(0);
|
||||
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
||||
const supportsCommands = quickCommands.length > 0;
|
||||
|
||||
@@ -125,8 +132,13 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setResult(null);
|
||||
setHistoryIndex(null);
|
||||
setLiveSessionId(null);
|
||||
setHistoryOpen(false);
|
||||
setHistoryStreams({ status: "loading" });
|
||||
setHistoryLines({ status: "idle" });
|
||||
setSelectedHistoryStreamId("");
|
||||
liveSessionRef.current = undefined;
|
||||
serverTimeRef.current = undefined;
|
||||
historyRequestRef.current += 1;
|
||||
initialHistoryPendingRef.current = true;
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
@@ -157,10 +169,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setLiveSessionId(nextSessionId);
|
||||
if (previousSessionId === nextSessionId) return;
|
||||
setStreams({ status: "ready", data: [] });
|
||||
setLines((current) => mergeTerminalLines(current, [nextSessionId
|
||||
? terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)
|
||||
: terminalSystemLine("warn", "当前没有可跟随的受管进程输出。", "SYSTEM", "session-empty", serverTimeRef.current)
|
||||
]));
|
||||
setLines(nextSessionId
|
||||
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)]
|
||||
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]);
|
||||
lockTerminalFollow();
|
||||
});
|
||||
events.addEventListener("stream", (event) => {
|
||||
@@ -188,6 +199,35 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
return () => events.close();
|
||||
}, [appendLines, lockTerminalFollow, open, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !historyOpen) return;
|
||||
let cancelled = false;
|
||||
setHistoryStreams({ status: "loading" });
|
||||
void platformApiClient.listLogStreams(serverId).then((response) => {
|
||||
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
||||
}).catch((error) => {
|
||||
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [historyOpen, open, serverId]);
|
||||
|
||||
async function selectHistoryStream(streamId: string) {
|
||||
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
|
||||
if (!stream) return;
|
||||
const requestId = historyRequestRef.current + 1;
|
||||
historyRequestRef.current = requestId;
|
||||
setSelectedHistoryStreamId(streamId);
|
||||
setHistoryLines({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow });
|
||||
if (historyRequestRef.current !== requestId) return;
|
||||
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
|
||||
} catch (error) {
|
||||
if (historyRequestRef.current !== requestId) return;
|
||||
setHistoryLines({ status: "error", reason: error instanceof Error ? error.message : "历史日志加载失败" });
|
||||
}
|
||||
}
|
||||
|
||||
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||
setCommand(item.command);
|
||||
setHistoryIndex(null);
|
||||
@@ -216,9 +256,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
}
|
||||
|
||||
function clearTerminalBuffer() {
|
||||
if (historyOpen) {
|
||||
setHistoryLines({ status: "ready", data: [] });
|
||||
return;
|
||||
}
|
||||
setLines([]);
|
||||
}
|
||||
|
||||
function toggleHistory() {
|
||||
historyRequestRef.current += 1;
|
||||
setHistoryOpen((current) => !current);
|
||||
setSelectedHistoryStreamId("");
|
||||
setHistoryLines({ status: "idle" });
|
||||
}
|
||||
|
||||
function handleTerminalScroll() {
|
||||
const output = outputRef.current;
|
||||
if (!output || initialHistoryPendingRef.current) return;
|
||||
@@ -276,17 +327,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
<div className="terminal-output-topbar">
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span>{`当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||
<button type="button" className="terminal-output-action" onClick={toggleHistory}><History size={14} /><span>{historyOpen ? "实时输出" : "查看历史"}</span></button>
|
||||
<button type="button" className="terminal-output-action" aria-label="关闭打开终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
||||
{streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{streams.reason}</span></div>}
|
||||
{streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出。"}</span></div>}
|
||||
{lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||
{!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{streams.reason}</span></div>}
|
||||
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} serverTime={serverTimeRef.current} />}
|
||||
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
|
||||
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="terminal-command-dock" aria-label="terminal command controls">
|
||||
@@ -313,6 +366,42 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
);
|
||||
}
|
||||
|
||||
interface HistoryLogViewProps {
|
||||
streams: LoadState<LogStreamResponse[]>;
|
||||
lines: HistoryLineState;
|
||||
selectedStreamId: string;
|
||||
onSelect: (streamId: string) => Promise<void>;
|
||||
serverTime?: string;
|
||||
}
|
||||
|
||||
function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) {
|
||||
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" serverTime={serverTime} />;
|
||||
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} serverTime={serverTime} />;
|
||||
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" serverTime={serverTime} />;
|
||||
return (
|
||||
<>
|
||||
<div className="terminal-line terminal-line-info terminal-source-system">
|
||||
<time>历史</time>
|
||||
<span className="terminal-text">
|
||||
<select aria-label="选择历史日志流" value={selectedStreamId} onChange={(event) => void onSelect(event.target.value)}>
|
||||
<option value="" disabled>选择一个历史日志流</option>
|
||||
{streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey} · {stream.updatedAt}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" serverTime={serverTime} />}
|
||||
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" serverTime={serverTime} />}
|
||||
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} serverTime={serverTime} />}
|
||||
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" serverTime={serverTime} />}
|
||||
{lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) {
|
||||
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{formatTerminalServerTime(serverTime)}</time><span className="terminal-text">{label}</span></div>;
|
||||
}
|
||||
|
||||
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user