Implement platform management features

This commit is contained in:
npc0-hue
2026-08-17 08:48:45 +08:00
parent 65353cf269
commit 302f1f64b7
38 changed files with 2185 additions and 110 deletions
@@ -0,0 +1,255 @@
/** @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
const apiMocks = vi.hoisted(() => ({
getGameClientBridgeCommand: vi.fn(),
listLogStreams: vi.fn(),
openServerLogEvents: vi.fn(),
queryLogStream: vi.fn(),
queueGameClientBridgeCommand: vi.fn()
}));
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
class FakeEventStream {
onerror: ((event: Event) => void) | null = null;
closed = false;
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
addEventListener(type: string, listener: (event: MessageEvent) => void) {
const listeners = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: MessageEvent) => void) {
this.listeners.get(type)?.delete(listener);
}
close() {
this.closed = true;
this.listeners.clear();
}
emit(type: string, payload: unknown) {
const event = new MessageEvent(type, { data: JSON.stringify(payload) });
this.listeners.get(type)?.forEach((listener) => listener(event));
}
}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let eventStream: FakeEventStream;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
eventStream = new FakeEventStream();
apiMocks.openServerLogEvents.mockReturnValue(eventStream);
apiMocks.listLogStreams.mockResolvedValue({ items: [], count: 0 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "", entries: [], nextSeq: 0, latestSeq: 0 });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { callback(0); return 1; });
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe("ServerManagementTerminalDrawer", () => {
it("shows current-session replay and keeps it on a repeated boundary for the same session", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay"));
await emitReady();
expect(container?.textContent).toContain("generation A current replay");
expect(container?.textContent).toContain("当前受管进程会话 · SSE 实时推送");
await emitSession("session-a");
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay"));
expect(container?.textContent).toContain("generation A current replay");
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
});
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
await renderDrawer();
await emitSession();
await emitLog("legacy-job", undefined, "job", logEntry(1, "legacy output must stay historical"));
await emitReady();
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
expect(container?.textContent).not.toContain("legacy output must stay historical");
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
});
it("clears generation A on a new session and rejects late generation A events", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A output"));
await emitSession("session-b");
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late generation A output"));
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "generation B output"));
expect(container?.textContent).not.toContain("generation A output");
expect(container?.textContent).not.toContain("late generation A output");
expect(container?.textContent).toContain("generation B output");
expect(container?.textContent).toContain("Run 已切换到新的受管进程输出会话");
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
});
it("clears a stopped session and restores the running session on the same SSE connection", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "running output before stop"));
await emitSession();
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late output after stop"));
expect(container?.textContent).not.toContain("running output before stop");
expect(container?.textContent).not.toContain("late output after stop");
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
await emitSession("session-b");
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "running output after recovery"));
expect(container?.textContent).not.toContain("running output before stop");
expect(container?.textContent).toContain("running output after recovery");
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
});
it("keeps selected historical output separate while live output continues in the background", async () => {
const oldStream = logStream("stdout-old", "session-old", "process.stdout", "2026-08-01T00:00:00Z");
oldStream.latestSeq = 900;
apiMocks.listLogStreams.mockResolvedValue({ items: [oldStream], count: 1 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: oldStream.id, entries: [logEntry(1, "selected historical output", "2026-08-01T00:00:01Z")], nextSeq: 1, latestSeq: 1 });
await renderDrawer();
await emitSession("session-current");
await emitStream(logStream("stdout-current", "session-current", "process.stdout"));
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "current live output"));
await clickButton("查看历史");
await flushPromises();
const select = container?.querySelector<HTMLSelectElement>('select[aria-label="选择历史日志流"]');
if (!select) throw new Error("history stream selector not found");
await act(async () => setSelectValue(select, oldStream.id));
await flushPromises();
expect(container?.textContent).toContain("selected historical output");
expect(container?.textContent).not.toContain("current live output");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(2, "new live output while viewing history"));
expect(container?.textContent).not.toContain("new live output while viewing history");
await clickButton("实时输出");
expect(container?.textContent).toContain("current live output");
expect(container?.textContent).toContain("new live output while viewing history");
expect(container?.textContent).not.toContain("selected historical output");
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
});
it("uses RCON only after an operator submits a command", async () => {
const pending = bridgeCommand("pending");
const succeeded = bridgeCommand("succeeded");
apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending);
apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded);
await renderDrawer();
await emitSession("session-current");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
if (!input || !form) throw new Error("command form not found");
await act(async () => setInputValue(input, "#ListPlayers"));
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
await flushPromises();
expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1);
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id);
expect(container?.textContent).toContain("ordinary live output");
});
});
async function renderDrawer() {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />);
});
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 500 });
}
async function emitSession(logSessionId?: string) {
await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime: "2026-08-14T00:00:00Z" }));
}
async function emitStream(stream: LogStreamResponse) {
await act(async () => eventStream.emit("stream", stream));
}
async function emitLog(streamId: string, logSessionId: string | undefined, streamKey: string, entry: LogEntryBody) {
await act(async () => eventStream.emit("log", { serverInstanceId: "server-1", streamId, source: "process", streamKey, logSessionId, latestSeq: entry.seq, entry }));
}
async function emitReady() {
await act(async () => eventStream.emit("ready", { serverInstanceId: "server-1", streamCount: 1, serverTime: "2026-08-14T00:00:00Z" }));
}
async function clickButton(label: string) {
const button = Array.from(container?.querySelectorAll<HTMLButtonElement>("button") ?? []).find((item) => item.textContent?.includes(label));
if (!button) throw new Error(`button not found: ${label}`);
await act(async () => button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })));
}
async function flushPromises() {
await act(async () => { await Promise.resolve(); });
}
function setInputValue(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function setSelectValue(select: HTMLSelectElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
}
function logStream(id: string, logSessionId: string, streamKey: string, updatedAt = "2026-08-14T00:00:00Z"): LogStreamResponse {
return { id, serverInstanceId: "server-1", source: "process", streamKey, logSessionId, sessionStartedAt: updatedAt, latestSeq: 1, storageBackend: "database", retentionPolicy: "default", createdAt: updatedAt, updatedAt };
}
function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${seq}Z`): LogEntryBody {
return { seq, timestamp, line, redacted: true };
}
function bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse {
return {
id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required",
result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined,
expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined
};
}
@@ -1,14 +1,15 @@
import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { History, 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 { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { EmptyState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
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 };
@@ -89,9 +90,16 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [followLatest, setFollowLatest] = useState(true);
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyStreams, setHistoryStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [historyLines, setHistoryLines] = useState<HistoryLineState>({ status: "idle" });
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
const outputRef = useRef<HTMLDivElement>(null);
const followLatestRef = useRef(true);
const initialHistoryPendingRef = useRef(false);
const liveSessionRef = useRef<string | null | undefined>(undefined);
const historyRequestRef = useRef(0);
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
const supportsCommands = quickCommands.length > 0;
@@ -121,10 +129,17 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setPending(false);
setResult(null);
setHistoryIndex(null);
setLiveSessionId(null);
setHistoryOpen(false);
setHistoryStreams({ status: "loading" });
setHistoryLines({ status: "idle" });
setSelectedHistoryStreamId("");
liveSessionRef.current = undefined;
historyRequestRef.current += 1;
initialHistoryPendingRef.current = true;
followLatestRef.current = true;
setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
}, [open, supportsCommands]);
useEffect(() => {
@@ -140,9 +155,24 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
if (!open) return undefined;
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow });
events.addEventListener("session", (event) => {
const session = parseLogSessionEvent(event);
if (!session) return;
ready = true;
const nextSessionId = normalizeLogSessionId(session.logSessionId);
const previousSessionId = liveSessionRef.current;
liveSessionRef.current = nextSessionId;
setLiveSessionId(nextSessionId);
if (previousSessionId === nextSessionId) return;
setStreams({ status: "ready", data: [] });
setLines(nextSessionId
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`)]
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]);
lockTerminalFollow();
});
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
});
@@ -153,7 +183,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return;
ready = true;
const stream = streamFromServerLogEvent(payload);
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
@@ -165,6 +195,35 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
return () => events.close();
}, [appendLines, lockTerminalFollow, open, serverId]);
useEffect(() => {
if (!open || !historyOpen) return;
let cancelled = false;
setHistoryStreams({ status: "loading" });
void platformApiClient.listLogStreams(serverId).then((response) => {
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
}).catch((error) => {
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
});
return () => { cancelled = true; };
}, [historyOpen, open, serverId]);
async function selectHistoryStream(streamId: string) {
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
if (!stream) return;
const requestId = historyRequestRef.current + 1;
historyRequestRef.current = requestId;
setSelectedHistoryStreamId(streamId);
setHistoryLines({ status: "loading" });
try {
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalInitialHistoryWindow), limit: terminalInitialHistoryWindow });
if (historyRequestRef.current !== requestId) return;
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
} catch (error) {
if (historyRequestRef.current !== requestId) return;
setHistoryLines({ status: "error", reason: error instanceof Error ? error.message : "历史日志加载失败" });
}
}
function selectQuickCommand(item: TerminalQuickCommand) {
setCommand(item.command);
setHistoryIndex(null);
@@ -193,9 +252,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
function clearTerminalBuffer() {
if (historyOpen) {
setHistoryLines({ status: "ready", data: [] });
return;
}
setLines([]);
}
function toggleHistory() {
historyRequestRef.current += 1;
setHistoryOpen((current) => !current);
setSelectedHistoryStreamId("");
setHistoryLines({ status: "idle" });
}
function handleTerminalScroll() {
const output = outputRef.current;
if (!output || initialHistoryPendingRef.current) return;
@@ -253,17 +323,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
<div className="terminal-output-topbar">
<div>
<strong>{serverName}</strong>
<span> Run · + SSE · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {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>
<button type="button" className="terminal-output-action" onClick={toggleHistory}><History size={14} /><span>{historyOpen ? "实时输出" : "查看历史"}</span></button>
<button type="button" className="terminal-output-action" aria-label="关闭打开终端" onClick={onClose}><X size={14} /><span></span></button>
</div>
</div>
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
{streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{streams.reason}</span></div>}
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">Run </span></div>}
{lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
{!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{streams.reason}</span></div>}
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} />}
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
</div>
</section>
<section className="terminal-command-dock" aria-label="terminal command controls">
@@ -279,7 +351,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
<span>SCUM </span>
<small>Enter / Run </small>
<small>Enter / </small>
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令" : "当前账号没有运行操作权限"} onKeyDown={handleCommandKeyDown} onChange={(event) => { setCommand(event.target.value); setHistoryIndex(null); }} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
@@ -290,6 +362,41 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
);
}
interface HistoryLogViewProps {
streams: LoadState<LogStreamResponse[]>;
lines: HistoryLineState;
selectedStreamId: string;
onSelect: (streamId: string) => Promise<void>;
}
function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) {
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" />;
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} />;
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" />;
return (
<>
<div className="terminal-line terminal-line-info terminal-source-system">
<time></time>
<span className="terminal-text">
<select aria-label="选择历史日志流" value={selectedStreamId} onChange={(event) => void onSelect(event.target.value)}>
<option value="" disabled></option>
{streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey} · {stream.updatedAt}</option>)}
</select>
</span>
</div>
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" />}
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" />}
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} />}
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" />}
{lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
</>
);
}
function TerminalStatusLine({ tone, label }: { tone: "info" | "warn" | "error"; label: string }) {
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{label}</span></div>;
}
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
return terminalQuickCommandCatalog[pluginId] ?? [];
}
@@ -331,6 +438,16 @@ function terminalSourceClass(value?: string): string {
return "log";
}
function normalizeLogSessionId(value?: string): string | null {
const normalized = value?.trim();
return normalized || null;
}
function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessionId: string | null | undefined): boolean {
const normalizedEventSessionId = normalizeLogSessionId(eventSessionId);
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
}
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
}