Stream live server logs over SSE
This commit is contained in:
@@ -55,6 +55,7 @@ import type {
|
||||
LlmConfigSuggestionRequest,
|
||||
LlmConfigSuggestionResponse,
|
||||
LogBackfillRequest,
|
||||
LogStreamEventOptions,
|
||||
LogStreamCursorRequest,
|
||||
LogStreamCursorResponse,
|
||||
LogStreamListResponse,
|
||||
@@ -446,6 +447,17 @@ export class PlatformApiClient {
|
||||
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
|
||||
}
|
||||
|
||||
openServerLogEvents(id: string, options: LogStreamEventOptions = {}): EventSource {
|
||||
return new EventSource(this.serverLogEventsUrl(id, options), { withCredentials: true });
|
||||
}
|
||||
|
||||
serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit));
|
||||
const query = params.toString();
|
||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
|
||||
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -60,7 +60,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||
|
||||
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
||||
- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters.
|
||||
- Live log and management-terminal output uses `GET /api/v1/server-instances/{id}/logs/events` as a single `EventSource`/SSE stream with bounded initial history. `POST /api/v1/log-streams/query` remains available for explicit historical cursor reads and reconnect repair, not periodic browser polling.
|
||||
# Client Manager API projection
|
||||
|
||||
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
|
||||
|
||||
@@ -13,10 +13,10 @@ export type DependencyState = "unknown" | "present" | "missing" | "installing" |
|
||||
export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed";
|
||||
export type ServerLifecycleAction = "create" | "start" | "stop" | "status";
|
||||
|
||||
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
|
||||
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired" | "unknown";
|
||||
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
|
||||
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
|
||||
export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled";
|
||||
export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled" | "unknown";
|
||||
export type GameClientBridgeJsonValue = string | number | boolean | null | GameClientBridgeJsonValue[] | GameClientBridgeJsonObject;
|
||||
|
||||
export interface GameClientBridgeJsonObject {
|
||||
@@ -1494,6 +1494,25 @@ export interface LogStreamCursorResponse {
|
||||
latestSeq: number;
|
||||
}
|
||||
|
||||
export interface LogStreamEventResponse {
|
||||
serverInstanceId: string;
|
||||
streamId: string;
|
||||
source: string;
|
||||
streamKey: string;
|
||||
latestSeq: number;
|
||||
entry: LogEntryBody;
|
||||
}
|
||||
|
||||
export interface LogStreamEventsReadyResponse {
|
||||
serverInstanceId: string;
|
||||
streamCount: number;
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
export interface LogStreamEventOptions {
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
export interface AuditEventResponse {
|
||||
id: string;
|
||||
actorId: string;
|
||||
|
||||
@@ -112,7 +112,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
|
||||
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
|
||||
const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
|
||||
|
||||
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;不要求选择 Run 节点或部署目标。" : "仅停止中的服务器可以修改部署设置。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
|
||||
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;不要求选择 Run 节点或部署目标。" : "任何运行状态都可以修改部署设置;这里只保存定义,不会直接重启进程。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
|
||||
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
||||
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
|
||||
{step === pluginStep && <div className="deployment-workflow-body">
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { LogEntryBody, LogStreamResponse } from "../api/types";
|
||||
import { sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
|
||||
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 { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
|
||||
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 };
|
||||
|
||||
const liveLogPollMs = 1000;
|
||||
const terminalLogPollMs = 1000;
|
||||
const logStreamPollMs = 5000;
|
||||
const terminalBridgeResultPollMs = 1000;
|
||||
const terminalBridgeResultPollAttempts = 30;
|
||||
const liveLogHistoryWindow = 100;
|
||||
const terminalLogQueryLimit = 150;
|
||||
const terminalHistoryWindow = 150;
|
||||
const maxLogEntries = 500;
|
||||
const maxTerminalLines = 600;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
"game.scum": [
|
||||
{ label: "查询玩家", command: "ListPlayers", hint: "SCUM RCON" },
|
||||
{ label: "服务器信息", command: "ServerInfo", hint: "SCUM RCON" },
|
||||
{ label: "设为中午", command: "SetTime 12", hint: "SCUM RCON" }
|
||||
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
|
||||
{ label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" },
|
||||
{ label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" },
|
||||
{ label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -85,10 +84,15 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [selectedStreamId, setSelectedStreamId] = useState("");
|
||||
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||||
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
|
||||
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;
|
||||
@@ -105,40 +109,45 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEntries([]);
|
||||
setCursorByStream({});
|
||||
setPaused(false);
|
||||
setLastRefreshAt("");
|
||||
void loadStreams();
|
||||
}, [loadStreams, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadStreams, open]);
|
||||
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 tailSelectedStream = useCallback(async () => {
|
||||
if (!open || !selectedStream) return;
|
||||
const afterSeq = cursorByStream[selectedStream.id] ?? initialLogCursor(selectedStream, liveLogHistoryWindow);
|
||||
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
|
||||
const nextSeq = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
|
||||
setCursorByStream((current) => updateCursor(current, selectedStream.id, nextSeq));
|
||||
setLastRefreshAt(new Date().toLocaleTimeString());
|
||||
if (cursor.entries.length === 0) return;
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
...cursor.entries.map((entry) => ({ ...entry, source: selectedStream.source || selectedStream.streamKey, streamId: selectedStream.id, streamKey: selectedStream.streamKey }))
|
||||
].slice(-maxLogEntries));
|
||||
}, [cursorByStream, open, selectedStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || paused || !selectedStream) return undefined;
|
||||
void tailSelectedStream().catch(() => undefined);
|
||||
const timer = window.setInterval(() => void tailSelectedStream().catch(() => undefined), liveLogPollMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [open, paused, selectedStream, tailSelectedStream]);
|
||||
|
||||
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)));
|
||||
@@ -146,35 +155,28 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
||||
|
||||
function clearVisibleBuffer() {
|
||||
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
|
||||
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
|
||||
}
|
||||
|
||||
function selectLogStream(nextStreamId: string) {
|
||||
setSelectedStreamId(nextStreamId);
|
||||
setEntries([]);
|
||||
setCursorByStream((current) => {
|
||||
const next = { ...current };
|
||||
delete next[nextStreamId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · 每 ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
|
||||
<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={() => void tailSelectedStream().catch(() => undefined)}><RotateCw size={14} /><span>刷新</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 ? cursorByStream[selectedStream.id] ?? 0 : "--"}</span>
|
||||
<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="没有新的匹配日志;保持窗口打开会继续按游标刷新。" />}
|
||||
{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) => (
|
||||
@@ -204,7 +206,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
const [pending, setPending] = useState(false);
|
||||
const [lines, setLines] = useState<TerminalLine[]>([]);
|
||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
@@ -228,57 +229,43 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
}
|
||||
}, [open, serverId]);
|
||||
|
||||
const tailTerminalLogs = useCallback(async (targetStreams = terminalStreams) => {
|
||||
if (!open || targetStreams.length === 0) return;
|
||||
const cursorUpdates: Record<string, number> = {};
|
||||
const batches = await Promise.all(targetStreams.map(async (stream) => {
|
||||
const afterSeq = cursorByStream[stream.id] ?? initialLogCursor(stream, terminalHistoryWindow);
|
||||
const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq, limit: terminalLogQueryLimit });
|
||||
cursorUpdates[stream.id] = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
|
||||
return cursor.entries.map((entry) => terminalLineFromLog(stream, entry));
|
||||
}));
|
||||
setCursorByStream((current) => {
|
||||
let changed = false;
|
||||
const next = { ...current };
|
||||
for (const [streamId, cursor] of Object.entries(cursorUpdates)) {
|
||||
const nextCursor = Math.max(next[streamId] ?? 0, cursor);
|
||||
if (nextCursor !== next[streamId]) {
|
||||
next[streamId] = nextCursor;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
appendLines(batches.flat().sort(compareTerminalLines));
|
||||
}, [appendLines, cursorByStream, open, terminalStreams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCommand("");
|
||||
setPending(false);
|
||||
setResult(null);
|
||||
setCursorByStream({});
|
||||
setHistoryIndex(null);
|
||||
setLines([terminalSystemLine("info", supportsCommands ? "读取平台历史日志,后续按游标实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||
void loadStreams();
|
||||
}, [loadStreams, open, supportsCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadStreams, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || streams.status !== "ready") return;
|
||||
void tailTerminalLogs(terminalRelevantStreams(streams.data)).catch((error) => appendLines([terminalSystemLine("error", error instanceof Error ? error.message : "历史日志读取失败", "LOGS")]));
|
||||
}, [appendLines, open, streams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || terminalStreams.length === 0) return undefined;
|
||||
const timer = window.setInterval(() => void tailTerminalLogs().catch(() => undefined), terminalLogPollMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [open, tailTerminalLogs, terminalStreams]);
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalHistoryWindow });
|
||||
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;
|
||||
const stream = streamFromServerLogEvent(payload);
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||
appendLines([terminalLineFromLog(stream, payload.entry)]);
|
||||
});
|
||||
events.onerror = () => {
|
||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||
};
|
||||
return () => events.close();
|
||||
}, [appendLines, open, serverId]);
|
||||
|
||||
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||
setCommand(item.command);
|
||||
@@ -322,11 +309,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setResult({ status: "pending", label: "正在提交命令" });
|
||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||
try {
|
||||
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
|
||||
const label = `已排队 · 任务 ${response.jobId}`;
|
||||
setResult({ status: "succeeded", label });
|
||||
appendLines([terminalSystemLine("success", `${label} · ${response.message || response.status}`, "PLATFORM", `ok-${response.jobId}`)]);
|
||||
void tailTerminalLogs().catch(() => undefined);
|
||||
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
|
||||
const label = bridgeCommandDispatchLabel(response.state, response.id);
|
||||
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
||||
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
|
||||
const finalCommand = await waitForBridgeCommandTerminal(response.id);
|
||||
if (finalCommand) {
|
||||
const outcome = terminalLineFromBridgeCommand(finalCommand);
|
||||
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
||||
appendLines([outcome]);
|
||||
} else {
|
||||
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
|
||||
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
||||
appendLines([timeoutLine]);
|
||||
}
|
||||
} catch (error) {
|
||||
const label = error instanceof Error ? error.message : "命令提交失败";
|
||||
setResult({ status: "failed", label });
|
||||
@@ -336,13 +332,22 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
|
||||
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
|
||||
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
|
||||
if (isTerminalBridgeCommandState(current.state)) return current;
|
||||
await delay(terminalBridgeResultPollMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
|
||||
<section className="terminal-output-panel" aria-label="terminal output">
|
||||
<div className="terminal-output-topbar">
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span>最近历史 + {terminalLogPollMs / 1000}s 实时刷新 · 日志源 {logStreamPollMs / 1000}s 探测 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
|
||||
<span>最近历史 + SSE 实时推送 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||
@@ -386,39 +391,35 @@ function levelClass(level?: string): string {
|
||||
return "log-level-info";
|
||||
}
|
||||
|
||||
function initialLogCursor(stream: LogStreamResponse, historyWindow: number): number {
|
||||
return Math.max(0, stream.latestSeq - historyWindow);
|
||||
}
|
||||
|
||||
function nextCursorSeq(afterSeq: number, entries: LogEntryBody[], nextSeq: number): number {
|
||||
if (entries.length === 0) return Math.max(afterSeq, nextSeq);
|
||||
return Math.max(afterSeq, nextSeq, entries[entries.length - 1]?.seq ?? afterSeq);
|
||||
}
|
||||
|
||||
function updateCursor(current: Record<string, number>, streamId: string, cursor: number): Record<string, number> {
|
||||
const nextCursor = Math.max(current[streamId] ?? 0, cursor);
|
||||
if (nextCursor === current[streamId]) return current;
|
||||
return { ...current, [streamId]: nextCursor };
|
||||
}
|
||||
|
||||
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||
}
|
||||
|
||||
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
|
||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||
}
|
||||
|
||||
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
|
||||
return [...streams].sort(compareTerminalStreams).slice(0, 8);
|
||||
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 {
|
||||
return terminalStreamRank(a) - terminalStreamRank(b) || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
|
||||
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 (key.includes("management-program")) return 0;
|
||||
if (key.includes("stderr")) return 1;
|
||||
if (key.includes("stdout")) return 2;
|
||||
return 3;
|
||||
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 {
|
||||
@@ -439,6 +440,34 @@ function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey:
|
||||
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
||||
}
|
||||
|
||||
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
||||
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
||||
}
|
||||
|
||||
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
|
||||
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
|
||||
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
|
||||
const sortKey = Date.parse(completed) || Date.now();
|
||||
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
|
||||
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
|
||||
}
|
||||
|
||||
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
|
||||
switch (state) {
|
||||
case "succeeded": return "已成功";
|
||||
case "failed": return "已失败";
|
||||
case "cancelled": return "已取消";
|
||||
case "expired": return "已过期";
|
||||
case "unknown": return "状态未知";
|
||||
case "claimed": return "Run 已领取";
|
||||
case "pending": return "已排队";
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
|
||||
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
|
||||
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";
|
||||
|
||||
@@ -3,10 +3,11 @@ import { describe, expect, it } from "vitest";
|
||||
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
|
||||
|
||||
describe("SourceRCONCommandPanel", () => {
|
||||
it("uses the typed dispatch API without confirmation, transcript, or connection fields", () => {
|
||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONChatRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONRawCommandRequest");
|
||||
it("uses protected bridge dispatch without confirmation, transcript, or connection fields", () => {
|
||||
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("scumAnnouncementCommand");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
|
||||
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||
import { ResultBadge } from "./StateViews";
|
||||
|
||||
interface SourceRCONCommandPanelProps {
|
||||
@@ -12,28 +12,25 @@ interface SourceRCONCommandPanelProps {
|
||||
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
|
||||
|
||||
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
|
||||
const [chatType, setChatType] = useState(4);
|
||||
const [chatMessage, setChatMessage] = useState("");
|
||||
const [targetSteamId, setTargetSteamId] = useState("");
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [rawCommand, setRawCommand] = useState("");
|
||||
const [pending, setPending] = useState<"chat" | "command" | null>(null);
|
||||
const [pending, setPending] = useState<"announcement" | "command" | null>(null);
|
||||
const [dispatch, setDispatch] = useState<DispatchState>(null);
|
||||
|
||||
if (pluginId !== "game.scum") {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendChat(event: FormEvent<HTMLFormElement>) {
|
||||
async function sendAnnouncement(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("chat");
|
||||
setDispatch({ status: "pending", label: "正在提交聊天消息" });
|
||||
setPending("announcement");
|
||||
setDispatch({ status: "pending", label: "正在提交服务器公告" });
|
||||
try {
|
||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONChatRequest(serverId, { chatType, message: chatMessage, targetSteamId }));
|
||||
setChatMessage("");
|
||||
setTargetSteamId("");
|
||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
||||
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, scumAnnouncementCommand(announcement)));
|
||||
setAnnouncement("");
|
||||
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "聊天消息提交失败" });
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "服务器公告提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
@@ -44,9 +41,9 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
||||
setPending("command");
|
||||
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
|
||||
try {
|
||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, rawCommand));
|
||||
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, rawCommand));
|
||||
setRawCommand("");
|
||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
||||
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
|
||||
} finally {
|
||||
@@ -55,35 +52,24 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="SCUM Source RCON controls">
|
||||
<article className="console-panel" aria-label="SCUM protected RCON controls">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>SCUM 聊天与管理员指令</h2>
|
||||
<p className="page-status">立即派发一次性任务;只显示安全状态,不保留聊天或指令记录。</p>
|
||||
<h2>SCUM 公告与管理员指令</h2>
|
||||
<p className="page-status">通过插件声明的 protected RCON 通道派发;只显示安全状态,不保留指令原文。</p>
|
||||
</div>
|
||||
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
|
||||
</div>
|
||||
<div className="operations-command-grid">
|
||||
<section className="console-module" aria-label="SCUM chat command">
|
||||
<div className="panel-header"><h2>发送聊天</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendChat(event)}>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
聊天类型
|
||||
<select value={chatType} onChange={(event) => setChatType(Number(event.target.value))} disabled={pending !== null}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((value) => <option key={value} value={value}>类型 {value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
目标 SteamID64(可选)
|
||||
<input value={targetSteamId} inputMode="numeric" maxLength={17} onChange={(event) => setTargetSteamId(event.target.value)} disabled={pending !== null} placeholder="留空为广播" />
|
||||
</label>
|
||||
</div>
|
||||
<section className="console-module" aria-label="SCUM announcement command">
|
||||
<div className="panel-header"><h2>发送公告</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendAnnouncement(event)}>
|
||||
<label>
|
||||
聊天内容
|
||||
<textarea value={chatMessage} maxLength={1024} rows={3} onChange={(event) => setChatMessage(event.target.value)} disabled={pending !== null} placeholder="输入单行聊天内容" />
|
||||
公告内容
|
||||
<textarea value={announcement} maxLength={1024} rows={3} onChange={(event) => setAnnouncement(event.target.value)} disabled={pending !== null} placeholder="输入单行公告内容" />
|
||||
</label>
|
||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !chatMessage.trim()}>{pending === "chat" ? "提交中…" : "发送聊天"}</button></div>
|
||||
<p className="page-status">会生成 SCUM 管理命令:#Announce 公告内容。</p>
|
||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !announcement.trim()}>{pending === "announcement" ? "提交中…" : "发送公告"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="console-module" aria-label="SCUM raw administrator command">
|
||||
@@ -91,9 +77,9 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
||||
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
|
||||
<label>
|
||||
指令
|
||||
<textarea value={rawCommand} maxLength={4000} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 SetTime 12" />
|
||||
<textarea value={rawCommand} maxLength={8192} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 #ListPlayers 或 #SetTime 12" />
|
||||
</label>
|
||||
<p className="page-status">指令会直接交给当前运行中的 SCUM,不会显示执行回包。</p>
|
||||
<p className="page-status">裸命令会交给当前运行中的 SCUM;执行结果以 Run 日志为准。</p>
|
||||
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -102,6 +88,6 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
||||
);
|
||||
}
|
||||
|
||||
function sourceRCONDispatchLabel(jobId: string, status: string): string {
|
||||
return `已${status === "queued" ? "排队" : "提交"} · 任务 ${jobId}`;
|
||||
function protectedRCONDispatchLabel(commandId: string, state: string): string {
|
||||
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||
}
|
||||
|
||||
+14
-1
@@ -5,6 +5,20 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location ~ ^/api/v1/server-instances/[^/]+/logs/events$ {
|
||||
proxy_pass http://platform:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
|
||||
location /api/v1/ {
|
||||
proxy_pass http://platform:8080/api/v1/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -27,4 +41,3 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ import type {
|
||||
GameClientBridgeStatusResponse
|
||||
} from "../api/types";
|
||||
|
||||
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired"]);
|
||||
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired", "unknown"]);
|
||||
const approvalStates = new Set<GameClientBridgeApprovalState>(["not_required", "pending", "approved", "rejected"]);
|
||||
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled"]);
|
||||
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled", "unknown"]);
|
||||
const forbiddenKeys = new Set([
|
||||
"apikey",
|
||||
"accesskey",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "./scumManagementRcon";
|
||||
|
||||
describe("SCUM management RCON bridge schema", () => {
|
||||
it("builds a protected bridge request without connection material", () => {
|
||||
const stamp = Date.UTC(2026, 7, 3, 8, 0, 0);
|
||||
|
||||
expect(scumManagementRCONCommandRequest("server/unsafe", " ListPlayers ", stamp)).toEqual({
|
||||
profileKey: "scum-client-manager",
|
||||
commandType: "management.rcon.request",
|
||||
payload: { requestText: "#ListPlayers" },
|
||||
idempotencyKey: `web:scum-rcon:server-unsafe:${stamp}`,
|
||||
priority: 20,
|
||||
expiresAt: "2026-08-03T08:02:00.000Z"
|
||||
});
|
||||
});
|
||||
|
||||
it("formats announcements and rejects framed command text", () => {
|
||||
expect(scumAnnouncementCommand("Restart in ten minutes")).toBe("#Announce Restart in ten minutes");
|
||||
expect(scumManagementRCONCommandRequest("server-1", "#SetTime 12").payload).toEqual({ requestText: "#SetTime 12" });
|
||||
expect(() => scumManagementRCONCommandRequest("server-1", "ListPlayers\nSetTime 12")).toThrow("管理指令必须是受限的单行文本");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { GameClientBridgeQueueRequest } from "../api/types";
|
||||
|
||||
export const scumManagementRCONProfileKey = "scum-client-manager";
|
||||
export const scumManagementRCONCommandType = "management.rcon.request";
|
||||
|
||||
const maxManagementCommandBytes = 8192;
|
||||
const managementCommandTtlMs = 120_000;
|
||||
|
||||
export function scumManagementRCONCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): GameClientBridgeQueueRequest {
|
||||
const stamp = Math.max(0, Math.floor(sequence));
|
||||
return {
|
||||
profileKey: scumManagementRCONProfileKey,
|
||||
commandType: scumManagementRCONCommandType,
|
||||
payload: { requestText: normalizeSCUMManagementCommand(command, "管理指令") },
|
||||
idempotencyKey: `web:scum-rcon:${safeBridgeIdentifierPart(serverInstanceId)}:${stamp}`,
|
||||
priority: 20,
|
||||
expiresAt: new Date(stamp + managementCommandTtlMs).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function scumAnnouncementCommand(message: string): string {
|
||||
return `#Announce ${validateSCUMManagementRCONText(message, "公告内容")}`;
|
||||
}
|
||||
|
||||
export function validateSCUMManagementRCONText(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxManagementCommandBytes || /[\u0000\r\n]/.test(normalized)) {
|
||||
throw new Error(`${label}必须是受限的单行文本。`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSCUMManagementCommand(value: string, label: string): string {
|
||||
const normalized = validateSCUMManagementRCONText(value, label);
|
||||
return normalized.startsWith("#") ? normalized : `#${normalized}`;
|
||||
}
|
||||
|
||||
function safeBridgeIdentifierPart(value: string): string {
|
||||
const normalized = value.trim().replace(/[^A-Za-z0-9._:-]+/g, "-").replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "").slice(0, 96);
|
||||
return normalized || "server";
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { LogEntryBody, LogStreamEventResponse, LogStreamResponse } from "../api/types";
|
||||
|
||||
export type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
|
||||
|
||||
export function parseLogStreamEvent(event: MessageEvent): LogStreamResponse | null {
|
||||
const value = parseEventData(event);
|
||||
if (!isRecord(value) || typeof value.id !== "string" || typeof value.serverInstanceId !== "string") return null;
|
||||
return value as unknown as LogStreamResponse;
|
||||
}
|
||||
|
||||
export function parseServerLogEvent(event: MessageEvent): LogStreamEventResponse | null {
|
||||
const value = parseEventData(event);
|
||||
if (!isRecord(value) || typeof value.streamId !== "string" || !isRecord(value.entry)) return null;
|
||||
return value as unknown as LogStreamEventResponse;
|
||||
}
|
||||
|
||||
export function entryFromServerLogEvent(event: LogStreamEventResponse): LiveLogEntry {
|
||||
return { ...event.entry, source: event.source || event.streamKey, streamId: event.streamId, streamKey: event.streamKey };
|
||||
}
|
||||
|
||||
export function streamFromServerLogEvent(event: LogStreamEventResponse): LogStreamResponse {
|
||||
return {
|
||||
id: event.streamId,
|
||||
serverInstanceId: event.serverInstanceId,
|
||||
source: event.source,
|
||||
streamKey: event.streamKey,
|
||||
latestSeq: event.latestSeq,
|
||||
storageBackend: "",
|
||||
retentionPolicy: "",
|
||||
createdAt: "",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeLogStreams(current: LogStreamResponse[], incoming: LogStreamResponse): LogStreamResponse[] {
|
||||
const index = current.findIndex((stream) => stream.id === incoming.id);
|
||||
if (index === -1) return [...current, incoming].sort(compareLogStreams);
|
||||
const next = [...current];
|
||||
next[index] = { ...next[index], ...incoming, latestSeq: Math.max(next[index].latestSeq, incoming.latestSeq) };
|
||||
return next.sort(compareLogStreams);
|
||||
}
|
||||
|
||||
export function appendLiveLogEntries(current: LiveLogEntry[], incoming: LiveLogEntry[], limit: number): LiveLogEntry[] {
|
||||
const seen = new Set(current.map(logEntryKey));
|
||||
const next = [...current];
|
||||
for (const entry of incoming) {
|
||||
const key = logEntryKey(entry);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
next.push(entry);
|
||||
}
|
||||
return next.slice(-limit);
|
||||
}
|
||||
|
||||
function compareLogStreams(a: LogStreamResponse, b: LogStreamResponse): number {
|
||||
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 logEntryKey(entry: LiveLogEntry): string {
|
||||
return `${entry.streamId}:${entry.seq}`;
|
||||
}
|
||||
|
||||
function parseEventData(event: MessageEvent): unknown {
|
||||
try {
|
||||
return JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
Reference in New Issue
Block a user