fix server log recovery and runtime status
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { 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";
|
||||
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 { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
@@ -14,9 +14,7 @@ type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||
|
||||
const terminalBridgeResultPollMs = 1000;
|
||||
const terminalBridgeResultPollAttempts = 30;
|
||||
const liveLogHistoryWindow = 100;
|
||||
const terminalInitialHistoryWindow = 500;
|
||||
const maxLogEntries = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
"game.scum": [
|
||||
@@ -73,125 +71,6 @@ function LiveOperationDrawer({ open, title, description, onClose, children, back
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerLiveLogDrawerProps {
|
||||
open: boolean;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: ServerLiveLogDrawerProps) {
|
||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [selectedStreamId, setSelectedStreamId] = useState("");
|
||||
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||||
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;
|
||||
if (showLoading) setStreams({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listServerLiveLogs(serverId);
|
||||
setStreams({ status: "ready", data: response.items });
|
||||
setSelectedStreamId((current) => response.items.some((stream) => stream.id === current) ? current : response.items[0]?.id ?? "");
|
||||
} catch (error) {
|
||||
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
|
||||
}
|
||||
}, [open, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEntries([]);
|
||||
setPaused(false);
|
||||
setLastRefreshAt("");
|
||||
void loadStreams();
|
||||
}, [loadStreams, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
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 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)));
|
||||
}, [entries, keyword, selectedStreamId]);
|
||||
|
||||
function clearVisibleBuffer() {
|
||||
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
|
||||
}
|
||||
|
||||
function selectLogStream(nextStreamId: string) {
|
||||
setSelectedStreamId(nextStreamId);
|
||||
}
|
||||
|
||||
return (
|
||||
<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={() => 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 ? 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="没有新的匹配日志;保持窗口打开会继续接收平台推送。" />}
|
||||
{visibleEntries.length > 0 && (
|
||||
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
|
||||
{visibleEntries.map((entry) => (
|
||||
<div key={`${entry.streamId}-${entry.seq}`} className="log-line live-log-line">
|
||||
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
||||
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
|
||||
<span>{entry.line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</LiveOperationDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerManagementTerminalDrawerProps {
|
||||
open: boolean;
|
||||
serverId: string;
|
||||
@@ -215,7 +94,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
const initialHistoryPendingRef = useRef(false);
|
||||
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
||||
const supportsCommands = quickCommands.length > 0;
|
||||
const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]);
|
||||
|
||||
const appendLines = useCallback((incoming: TerminalLine[]) => {
|
||||
if (incoming.length === 0) return;
|
||||
@@ -236,19 +114,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadStreams = useCallback(async (showLoading = true) => {
|
||||
if (!open) return;
|
||||
if (showLoading) setStreams({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listServerLiveLogs(serverId);
|
||||
setStreams({ status: "ready", data: response.items });
|
||||
} catch (error) {
|
||||
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
|
||||
}
|
||||
}, [open, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStreams({ status: "loading" });
|
||||
setCommand("");
|
||||
setPending(false);
|
||||
setResult(null);
|
||||
@@ -257,8 +125,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||
void loadStreams();
|
||||
}, [loadStreams, open, supportsCommands]);
|
||||
}, [open, supportsCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
||||
@@ -386,7 +253,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
<div className="terminal-output-topbar">
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span>已接受历史 + SSE 实时推送 · 实时输出等待 Run 连通与水位恢复 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
<span>当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {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>
|
||||
@@ -423,13 +290,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
);
|
||||
}
|
||||
|
||||
function levelClass(level?: string): string {
|
||||
const normalized = (level ?? "info").toLowerCase();
|
||||
if (normalized === "error" || normalized === "fatal") return "log-level-error";
|
||||
if (normalized === "warn" || normalized === "warning") return "log-level-warn";
|
||||
return "log-level-info";
|
||||
}
|
||||
|
||||
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||
}
|
||||
@@ -438,29 +298,6 @@ function bridgeCommandDispatchLabel(state: string, commandId: string): string {
|
||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||
}
|
||||
|
||||
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
|
||||
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 {
|
||||
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 (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 {
|
||||
return {
|
||||
id: `log-${stream.id}-${entry.seq}`,
|
||||
|
||||
Reference in New Issue
Block a user