diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index 2bb1f13..35136ab 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types"; +import { formatTerminalServerTime } from "../utils/logTime"; import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer"; const apiMocks = vi.hoisted(() => ({ @@ -88,6 +89,16 @@ describe("ServerManagementTerminalDrawer", () => { expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); }); + it("uses the server-provided clock for terminal system lines", async () => { + const serverTime = "2001-02-03T04:05:06Z"; + await renderDrawer(); + expect(container?.textContent).toContain("时间同步中"); + + await emitSession("session-server-clock", serverTime); + const systemLine = Array.from(container?.querySelectorAll(".terminal-line") ?? []).find((line) => line.textContent?.includes("已跟随当前受管进程输出会话")); + expect(systemLine?.querySelector("time")?.textContent).toBe(formatTerminalServerTime(serverTime)); + }); + it("renders an empty current session without accepting unrelated or sessionless logs", async () => { await renderDrawer(); @@ -203,8 +214,8 @@ async function renderDrawer() { 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 emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") { + await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime })); } async function emitStream(stream: LogStreamResponse) { diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index 88b6249..1b43481 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -6,7 +6,7 @@ import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types" import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon"; import { cx } from "../utils/classes"; import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; -import { formatTerminalLogTime } from "../utils/logTime"; +import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTime"; import { EmptyState, ResultBadge } from "./StateViews"; type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; @@ -98,6 +98,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState(""); const outputRef = useRef(null); const followLatestRef = useRef(true); + const serverTimeRef = useRef(undefined); const initialHistoryPendingRef = useRef(false); const liveSessionRef = useRef(undefined); const historyRequestRef = useRef(0); @@ -136,11 +137,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setHistoryLines({ status: "idle" }); setSelectedHistoryStreamId(""); liveSessionRef.current = undefined; + serverTimeRef.current = undefined; historyRequestRef.current += 1; initialHistoryPendingRef.current = true; followLatestRef.current = true; setFollowLatest(true); - setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]); + setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]); }, [open, supportsCommands]); useEffect(() => { @@ -160,6 +162,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu const session = parseLogSessionEvent(event); if (!session) return; ready = true; + serverTimeRef.current = session.serverTime; const nextSessionId = normalizeLogSessionId(session.logSessionId); const previousSessionId = liveSessionRef.current; liveSessionRef.current = nextSessionId; @@ -167,8 +170,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu if (previousSessionId === nextSessionId) return; setStreams({ status: "ready", data: [] }); setLines(nextSessionId - ? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`)] - : [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]); + ? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)] + : [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]); lockTerminalFollow(); }); events.addEventListener("stream", (event) => { @@ -284,26 +287,26 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50)); setPending(true); setResult({ status: "pending", label: "正在提交命令" }); - appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]); + appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND", undefined, serverTimeRef.current)]); try { const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted)); const label = rconJobDispatchLabel(response.status, response.jobId); setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); - appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]); + appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`, serverTimeRef.current)]); const finalJob = await waitForRCONJobTerminal(response.jobId); if (finalJob) { const outcome = terminalLineFromJob(finalJob); setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); appendLines([outcome]); } else { - const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`); + const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`, serverTimeRef.current); setResult({ status: "pending", label: "等待 Run 返回结果" }); appendLines([timeoutLine]); } } catch (error) { const label = error instanceof Error ? error.message : "命令提交失败"; setResult({ status: "failed", label }); - appendLines([terminalSystemLine("error", label, "ERROR")]); + appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]); } finally { setPending(false); } @@ -333,9 +336,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
- {!historyOpen && streams.status === "error" &&
{streams.reason}
} - {historyOpen && } - {!historyOpen && streams.status === "ready" && lines.length === 0 &&
{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}
} + {!historyOpen && streams.status === "error" &&
{streams.reason}
} + {historyOpen && } + {!historyOpen && streams.status === "ready" && lines.length === 0 &&
{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}
} {!historyOpen && lines.map((line) =>
{line.text}
)}
@@ -368,12 +371,13 @@ interface HistoryLogViewProps { lines: HistoryLineState; selectedStreamId: string; onSelect: (streamId: string) => Promise; + serverTime?: string; } -function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) { - if (streams.status === "loading") return ; - if (streams.status === "error") return ; - if (streams.data.length === 0) return ; +function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) { + if (streams.status === "loading") return ; + if (streams.status === "error") return ; + if (streams.data.length === 0) return ; return ( <>
@@ -385,17 +389,17 @@ function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryL
- {lines.status === "idle" && } - {lines.status === "loading" && } - {lines.status === "error" && } - {lines.status === "ready" && lines.data.length === 0 && } + {lines.status === "idle" && } + {lines.status === "loading" && } + {lines.status === "error" && } + {lines.status === "ready" && lines.data.length === 0 && } {lines.status === "ready" && lines.data.map((line) =>
{line.text}
)} ); } -function TerminalStatusLine({ tone, label }: { tone: "info" | "warn" | "error"; label: string }) { - return
{label}
; +function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) { + return
{label}
; } function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { @@ -419,9 +423,9 @@ function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): Te }; } -function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine { - const now = Date.now(); - return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey }; +function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, serverTime?: string): TerminalLine { + const sortKey = serverTime ? Date.parse(serverTime) : 0; + return { id, tone, text, at: formatTerminalServerTime(serverTime), sortKey, streamKey }; } function terminalLineClassName(line: TerminalLine): string { @@ -457,7 +461,7 @@ function terminalLineFromJob(job: JobResponse): TerminalLine { const completed = job.updatedAt; const sortKey = Date.parse(completed) || Date.now(); const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn"; - return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "PLATFORM" }; + return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: formatTerminalServerTime(job.updatedAt), sortKey, streamKey: "PLATFORM" }; } function jobStateLabel(state: JobResponse["state"]): string { diff --git a/platform_web/utils/logTime.test.ts b/platform_web/utils/logTime.test.ts index e902e2f..4712b2a 100644 --- a/platform_web/utils/logTime.test.ts +++ b/platform_web/utils/logTime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatTerminalLogTime } from "./logTime"; +import { formatTerminalLogTime, formatTerminalServerTime } from "./logTime"; describe("formatTerminalLogTime", () => { it("uses the SCUM log clock instead of the browser timezone", () => { @@ -11,4 +11,9 @@ describe("formatTerminalLogTime", () => { it("falls back to the event timestamp for generic logs", () => { expect(formatTerminalLogTime("not-a-timestamp")).toBe("时间未知"); }); + + it("does not use the browser clock before the server clock is synchronized", () => { + expect(formatTerminalServerTime()).toBe("时间同步中"); + expect(formatTerminalServerTime("2026-08-14T00:00:00Z")).toBe(formatTerminalLogTime("2026-08-14T00:00:00Z")); + }); }); diff --git a/platform_web/utils/logTime.ts b/platform_web/utils/logTime.ts index 6ae3f7b..9e7b9aa 100644 --- a/platform_web/utils/logTime.ts +++ b/platform_web/utils/logTime.ts @@ -9,3 +9,7 @@ export function formatTerminalLogTime(timestamp: string, line?: string): string const date = new Date(timestamp); return Number.isNaN(date.getTime()) ? "时间未知" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } + +export function formatTerminalServerTime(timestamp?: string): string { + return timestamp ? formatTerminalLogTime(timestamp) : "时间同步中"; +}