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