Stream live server logs over SSE

This commit is contained in:
npc0-hue
2026-08-03 22:28:54 +08:00
parent 5d4fca14f9
commit 7eac1926dd
48 changed files with 1526 additions and 263 deletions
+145 -116
View File
@@ -1,30 +1,29 @@
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client";
import type { LogEntryBody, LogStreamResponse } from "../api/types";
import { sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
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 liveLogPollMs = 1000;
const terminalLogPollMs = 1000;
const logStreamPollMs = 5000;
const terminalBridgeResultPollMs = 1000;
const terminalBridgeResultPollAttempts = 30;
const liveLogHistoryWindow = 100;
const terminalLogQueryLimit = 150;
const terminalHistoryWindow = 150;
const maxLogEntries = 500;
const maxTerminalLines = 600;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
"game.scum": [
{ label: "查询玩家", command: "ListPlayers", hint: "SCUM RCON" },
{ label: "服务器信息", command: "ServerInfo", hint: "SCUM RCON" },
{ label: "设为中午", command: "SetTime 12", hint: "SCUM RCON" }
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
{ label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" },
{ label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" },
{ label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" }
]
};
@@ -85,10 +84,15 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [selectedStreamId, setSelectedStreamId] = useState("");
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
const [paused, setPaused] = useState(false);
const [keyword, setKeyword] = useState("");
const [lastRefreshAt, setLastRefreshAt] = useState("");
const [eventSourceKey, setEventSourceKey] = useState(0);
const pausedRef = useRef(paused);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
const loadStreams = useCallback(async (showLoading = true) => {
if (!open) return;
@@ -105,40 +109,45 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
useEffect(() => {
if (!open) return;
setEntries([]);
setCursorByStream({});
setPaused(false);
setLastRefreshAt("");
void loadStreams();
}, [loadStreams, open]);
useEffect(() => {
if (!open) return undefined;
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
return () => window.clearInterval(timer);
}, [loadStreams, open]);
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: liveLogHistoryWindow });
events.addEventListener("open", () => setLastRefreshAt(new Date().toLocaleTimeString()));
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
setSelectedStreamId((current) => current || stream.id);
});
events.addEventListener("ready", () => {
ready = true;
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
ready = true;
setLastRefreshAt(new Date().toLocaleTimeString());
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
setSelectedStreamId((current) => current || payload.streamId);
if (pausedRef.current) return;
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], maxLogEntries));
});
events.onerror = () => {
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
};
return () => events.close();
}, [eventSourceKey, open, serverId]);
const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined;
const tailSelectedStream = useCallback(async () => {
if (!open || !selectedStream) return;
const afterSeq = cursorByStream[selectedStream.id] ?? initialLogCursor(selectedStream, liveLogHistoryWindow);
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
const nextSeq = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
setCursorByStream((current) => updateCursor(current, selectedStream.id, nextSeq));
setLastRefreshAt(new Date().toLocaleTimeString());
if (cursor.entries.length === 0) return;
setEntries((current) => [
...current,
...cursor.entries.map((entry) => ({ ...entry, source: selectedStream.source || selectedStream.streamKey, streamId: selectedStream.id, streamKey: selectedStream.streamKey }))
].slice(-maxLogEntries));
}, [cursorByStream, open, selectedStream]);
useEffect(() => {
if (!open || paused || !selectedStream) return undefined;
void tailSelectedStream().catch(() => undefined);
const timer = window.setInterval(() => void tailSelectedStream().catch(() => undefined), liveLogPollMs);
return () => window.clearInterval(timer);
}, [open, paused, selectedStream, tailSelectedStream]);
const visibleEntries = useMemo(() => {
const query = keyword.trim().toLowerCase();
return entries.filter((entry) => entry.streamId === selectedStreamId && (!query || entry.line.toLowerCase().includes(query) || (entry.level ?? "info").toLowerCase().includes(query)));
@@ -146,35 +155,28 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
function clearVisibleBuffer() {
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
}
function selectLogStream(nextStreamId: string) {
setSelectedStreamId(nextStreamId);
setEntries([]);
setCursorByStream((current) => {
const next = { ...current };
delete next[nextStreamId];
return next;
});
}
return (
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · SSE 实时推送平台日志`} onClose={onClose}>
<div className="log-filter-bar live-operation-toolbar">
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => selectLogStream(event.target.value)}>
{streams.status === "ready" && streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey || stream.source} · seq {stream.latestSeq}</option>)}
</select>
<input type="search" value={keyword} placeholder="过滤可见日志" aria-label="过滤可见日志" onChange={(event) => setKeyword(event.target.value)} />
<button type="button" className="icon-command" onClick={() => setPaused((current) => !current)}>{paused ? <Play size={14} /> : <Pause size={14} />}<span>{paused ? "继续" : "暂停"}</span></button>
<button type="button" className="icon-command" onClick={() => void tailSelectedStream().catch(() => undefined)}><RotateCw size={14} /><span></span></button>
<button type="button" className="icon-command" onClick={() => setEventSourceKey((current) => current + 1)}><RotateCw size={14} /><span></span></button>
<button type="button" className="icon-command" onClick={clearVisibleBuffer}><Trash2 size={14} /><span></span></button>
</div>
<span className="page-status">{paused ? "已暂停" : "自动刷新"} · {lastRefreshAt || "等待"} · {selectedStream ? cursorByStream[selectedStream.id] ?? 0 : "--"}</span>
<span className="page-status">{paused ? "已暂停" : "实时推送"} · {lastRefreshAt || "等待"} · {selectedStream ? selectedStream.latestSeq : "--"}</span>
{streams.status === "loading" && <LoadingState label="正在加载日志源…" compact />}
{streams.status === "error" && <ErrorState title="实时日志不可用" reason={streams.reason} diagnosticId={`live-logs:${serverId}`} onRetry={() => void loadStreams()} compact />}
{streams.status === "ready" && streams.data.length === 0 && <EmptyState title="暂无日志源" description="运行端还没有向平台登记该服务器的日志流。" />}
{streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && <EmptyState title="等待日志" description="没有新的匹配日志;保持窗口打开会继续按游标刷新。" />}
{streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && <EmptyState title="等待日志" description="没有新的匹配日志;保持窗口打开会继续接收平台推送。" />}
{visibleEntries.length > 0 && (
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
{visibleEntries.map((entry) => (
@@ -204,7 +206,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const [pending, setPending] = useState(false);
const [lines, setLines] = useState<TerminalLine[]>([]);
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
@@ -228,57 +229,43 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
}, [open, serverId]);
const tailTerminalLogs = useCallback(async (targetStreams = terminalStreams) => {
if (!open || targetStreams.length === 0) return;
const cursorUpdates: Record<string, number> = {};
const batches = await Promise.all(targetStreams.map(async (stream) => {
const afterSeq = cursorByStream[stream.id] ?? initialLogCursor(stream, terminalHistoryWindow);
const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq, limit: terminalLogQueryLimit });
cursorUpdates[stream.id] = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
return cursor.entries.map((entry) => terminalLineFromLog(stream, entry));
}));
setCursorByStream((current) => {
let changed = false;
const next = { ...current };
for (const [streamId, cursor] of Object.entries(cursorUpdates)) {
const nextCursor = Math.max(next[streamId] ?? 0, cursor);
if (nextCursor !== next[streamId]) {
next[streamId] = nextCursor;
changed = true;
}
}
return changed ? next : current;
});
appendLines(batches.flat().sort(compareTerminalLines));
}, [appendLines, cursorByStream, open, terminalStreams]);
useEffect(() => {
if (!open) return;
setCommand("");
setPending(false);
setResult(null);
setCursorByStream({});
setHistoryIndex(null);
setLines([terminalSystemLine("info", supportsCommands ? "读取平台历史日志,后续按游标实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
void loadStreams();
}, [loadStreams, open, supportsCommands]);
useEffect(() => {
if (!open) return undefined;
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
return () => window.clearInterval(timer);
}, [loadStreams, open]);
useEffect(() => {
if (!open || streams.status !== "ready") return;
void tailTerminalLogs(terminalRelevantStreams(streams.data)).catch((error) => appendLines([terminalSystemLine("error", error instanceof Error ? error.message : "历史日志读取失败", "LOGS")]));
}, [appendLines, open, streams]);
useEffect(() => {
if (!open || terminalStreams.length === 0) return undefined;
const timer = window.setInterval(() => void tailTerminalLogs().catch(() => undefined), terminalLogPollMs);
return () => window.clearInterval(timer);
}, [open, tailTerminalLogs, terminalStreams]);
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalHistoryWindow });
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
});
events.addEventListener("ready", () => {
ready = true;
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
ready = true;
const stream = streamFromServerLogEvent(payload);
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
appendLines([terminalLineFromLog(stream, payload.entry)]);
});
events.onerror = () => {
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
};
return () => events.close();
}, [appendLines, open, serverId]);
function selectQuickCommand(item: TerminalQuickCommand) {
setCommand(item.command);
@@ -322,11 +309,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setResult({ status: "pending", label: "正在提交命令" });
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
try {
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
const label = `已排队 · 任务 ${response.jobId}`;
setResult({ status: "succeeded", label });
appendLines([terminalSystemLine("success", `${label} · ${response.message || response.status}`, "PLATFORM", `ok-${response.jobId}`)]);
void tailTerminalLogs().catch(() => undefined);
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
const label = bridgeCommandDispatchLabel(response.state, response.id);
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
const finalCommand = await waitForBridgeCommandTerminal(response.id);
if (finalCommand) {
const outcome = terminalLineFromBridgeCommand(finalCommand);
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
appendLines([outcome]);
} else {
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
setResult({ status: "pending", label: "等待 Run 返回结果" });
appendLines([timeoutLine]);
}
} catch (error) {
const label = error instanceof Error ? error.message : "命令提交失败";
setResult({ status: "failed", label });
@@ -336,13 +332,22 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
}
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
if (isTerminalBridgeCommandState(current.state)) return current;
await delay(terminalBridgeResultPollMs);
}
return null;
}
return (
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
<section className="terminal-output-panel" aria-label="terminal output">
<div className="terminal-output-topbar">
<div>
<strong>{serverName}</strong>
<span> + {terminalLogPollMs / 1000}s · {logStreamPollMs / 1000}s · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
<span> + SSE · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
</div>
<div>
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span></span></button>
@@ -386,39 +391,35 @@ function levelClass(level?: string): string {
return "log-level-info";
}
function initialLogCursor(stream: LogStreamResponse, historyWindow: number): number {
return Math.max(0, stream.latestSeq - historyWindow);
}
function nextCursorSeq(afterSeq: number, entries: LogEntryBody[], nextSeq: number): number {
if (entries.length === 0) return Math.max(afterSeq, nextSeq);
return Math.max(afterSeq, nextSeq, entries[entries.length - 1]?.seq ?? afterSeq);
}
function updateCursor(current: Record<string, number>, streamId: string, cursor: number): Record<string, number> {
const nextCursor = Math.max(current[streamId] ?? 0, cursor);
if (nextCursor === current[streamId]) return current;
return { ...current, [streamId]: nextCursor };
}
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
return terminalQuickCommandCatalog[pluginId] ?? [];
}
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
return `${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
}
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
return [...streams].sort(compareTerminalStreams).slice(0, 8);
const active = streams.filter((stream) => stream.latestSeq > 0);
const candidates = active.length > 0 ? active : streams;
return [...candidates].sort(compareTerminalStreams).slice(0, 12);
}
function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number {
return terminalStreamRank(a) - terminalStreamRank(b) || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
const rank = terminalStreamRank(a) - terminalStreamRank(b);
if (rank !== 0) return rank;
const updated = (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0);
if (updated !== 0) return updated;
return b.latestSeq - a.latestSeq || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
}
function terminalStreamRank(stream: LogStreamResponse): number {
const key = `${stream.source}:${stream.streamKey}`.toLowerCase();
if (key.includes("management-program")) return 0;
if (key.includes("stderr")) return 1;
if (key.includes("stdout")) return 2;
return 3;
if (stream.source === "file" || key.includes("scum.")) return 0;
if (key.includes("management-program")) return 1;
if (key.includes("stderr")) return 2;
if (key.includes("stdout")) return 3;
return 4;
}
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
@@ -439,6 +440,34 @@ function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey:
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
}
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
}
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
const sortKey = Date.parse(completed) || Date.now();
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
}
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
switch (state) {
case "succeeded": return "已成功";
case "failed": return "已失败";
case "cancelled": return "已取消";
case "expired": return "已过期";
case "unknown": return "状态未知";
case "claimed": return "Run 已领取";
case "pending": return "已排队";
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";