Stream live server logs over SSE
This commit is contained in:
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user