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
+18 -5
View File
@@ -308,21 +308,34 @@ describe("first-party console pages", () => {
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog");
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin");
expect(serverLiveOperationsSource).toContain("queueGameClientBridgeCommand");
expect(serverLiveOperationsSource).toContain("scumManagementRCONCommandRequest");
expect(serverLiveOperationsSource).toContain("#ListSquads");
expect(serverLiveOperationsSource).toContain("#ListSpawnedVehicles");
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
expect(serverLiveOperationsSource).toContain("hideHeader");
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
expect(serverLiveOperationsSource).toContain("commandHistory");
expect(serverLiveOperationsSource).toContain("ArrowUp");
expect(serverLiveOperationsSource).toContain("listServerLiveLogs");
expect(serverLiveOperationsSource).toContain("queryLogStream");
expect(serverLiveOperationsSource).toContain("terminalLogPollMs = 1000");
expect(serverLiveOperationsSource).toContain("logStreamPollMs = 5000");
expect(serverLiveOperationsSource).toContain("openServerLogEvents");
expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand");
expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand");
expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)");
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
expect(serverLiveOperationsSource).toContain("nextCursorSeq");
expect(serverLiveOperationsSource).toContain("initialLogCursor");
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
});
it("uses declared SCUM log source keys for log backfill defaults", () => {
expect(serversPageSource).toContain("scum-server-events");
expect(serverDetailPageSource).toContain("scum-server-events");
expect(serversPageSource).not.toContain("server-log");
expect(serverDetailPageSource).not.toContain("server-log");
});
it("renders plugin catalog bridge readiness", () => {
const html = renderToStaticMarkup(<PluginsPage />);
+6 -5
View File
@@ -86,7 +86,7 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).toContain("resetClientManagerKey");
expect(serverDetailPageSource).toContain("checkDependencies");
expect(serverDetailPageSource).toContain("installDependencies");
expect(serverDetailPageSource).toContain("listServerLiveLogs");
expect(serverDetailPageSource).toContain("openServerLogEvents");
expect(serverDetailPageSource).toContain("requestLogBackfill");
expect(serverDetailPageSource).toContain("ClientManagerLifecyclePanel");
expect(clientManagerLifecyclePanelSource).toContain("listClientManagerLifecycles");
@@ -119,11 +119,12 @@ describe("ServerDetailPage config write approval", () => {
}
});
it("adds direct SCUM chat and raw commands through the one-time typed RCON API", () => {
it("adds SCUM announcements and raw commands through protected RCON bridge jobs", () => {
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
expect(sourceRCONCommandPanelSource).toContain("不保留聊天或指令记录");
expect(sourceRCONCommandPanelSource).toContain("不会显示执行回包");
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
expect(sourceRCONCommandPanelSource).toContain("不保留指令原文");
expect(sourceRCONCommandPanelSource).toContain("执行结果以 Run 日志为准");
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
for (const forbidden of ["password", "host", "transcript", "history"]) {
+45 -38
View File
@@ -11,7 +11,6 @@ import type {
DependencyCatalogResponse,
GamePluginResponse,
JobResponse,
LogEntryBody,
LogStreamResponse,
RunDistributionResponse,
RunUpdateJobResponse,
@@ -74,6 +73,7 @@ import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePlugi
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
@@ -1359,7 +1359,7 @@ function runtimeDefaultsForPlugin(pluginId: string) {
sourceRevision: "main",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
logSourceKey: isScum ? "scum-server-events" : "latest-log"
};
}
@@ -1394,40 +1394,41 @@ interface LogFilterState {
function LogsSection({ serverId }: LogsSectionProps) {
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [entries, setEntries] = useState<Array<LogEntryBody & { source: string }>>([]);
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
const [selected, setSelected] = useState<(LogEntryBody & { source: string }) | null>(null);
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
const [eventSourceKey, setEventSourceKey] = useState(0);
const refresh = useCallback(async (showLoading = true) => {
if (showLoading) setStreams({ status: "loading" });
try {
const response = await platformApiClient.listServerLiveLogs(serverId);
const serverStreams = response.items;
setStreams({ status: "ready", data: serverStreams });
const collected: Array<LogEntryBody & { source: string }> = [];
for (const stream of serverStreams) {
try {
const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: 0, limit: 200 });
collected.push(...cursor.entries.map((entry) => ({ ...entry, source: stream.source || stream.streamKey })));
} catch {
// one unreadable stream should not blank the rest
}
}
collected.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
setEntries(collected);
} catch (error) {
setStreams({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, [serverId]);
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
const timer = window.setInterval(() => void refresh(false), 2000);
return () => window.clearInterval(timer);
}, [refresh]);
setStreams({ status: "loading" });
setEntries([]);
setSelected(null);
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
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;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
});
events.onerror = () => {
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
};
return () => events.close();
}, [eventSourceKey, serverId]);
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
@@ -1448,16 +1449,16 @@ function LogsSection({ serverId }: LogsSectionProps) {
return false;
}
return true;
});
}).sort(compareLogEntriesDesc);
}, [entries, filter]);
return (
<article className="console-panel" aria-label="server logs">
<div className="panel-header">
<h2></h2>
<button type="button" className="icon-command" onClick={() => void refresh()}>
<button type="button" className="icon-command" onClick={refresh}>
<Sparkles size={14} />
<span></span>
<span></span>
</button>
</div>
<div className="log-filter-bar">
@@ -1495,9 +1496,9 @@ function LogsSection({ serverId }: LogsSectionProps) {
/>
</div>
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={() => void refresh()} compact />}
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
{streams.status === "ready" && entries.length === 0 && (
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="刷新" onAction={() => void refresh()} />
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
)}
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
@@ -1505,7 +1506,7 @@ function LogsSection({ serverId }: LogsSectionProps) {
{visible.length > 0 && (
<div className="log-list" role="list">
{visible.map((entry) => (
<button key={`${entry.source}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
<span>{entry.line}</span>
@@ -1574,6 +1575,12 @@ function levelClass(level?: string): string {
return "log-level-info";
}
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
if (time !== 0) return time;
return b.seq - a.seq;
}
interface ConfigSectionProps {
serverId: string;
instance: ServerInstanceResponse;
+1 -1
View File
@@ -949,7 +949,7 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
logSourceKey: isScum ? "scum-server-events" : "latest-log"
};
}