fix server log recovery and runtime status

This commit is contained in:
npc0-hue
2026-08-10 14:19:17 +08:00
parent d1f4dce4f5
commit 2becedbfe1
4 changed files with 35 additions and 190 deletions
@@ -24,18 +24,18 @@
- [x] 2.5 Classify acknowledged-range conflicts and sequence gaps as durable recovery failures, quarantine the affected spool segment with redacted diagnostics, and resume only after safe watermark reconciliation. - [x] 2.5 Classify acknowledged-range conflicts and sequence gaps as durable recovery failures, quarantine the affected spool segment with redacted diagnostics, and resume only after safe watermark reconciliation.
- [x] 2.6 Make autonomous process supervision report observed exit and startup-recovery transitions through the lifecycle channel, with retry-safe process identity and ordering metadata. - [x] 2.6 Make autonomous process supervision report observed exit and startup-recovery transitions through the lifecycle channel, with retry-safe process identity and ordering metadata.
- [x] 2.7 Define and test graceful Run shutdown behavior that preserves durable state and never reports a server stop unless its generic supervisor observed that process state. - [x] 2.7 Define and test graceful Run shutdown behavior that preserves durable state and never reports a server stop unless its generic supervisor observed that process state.
- [ ] 2.8 Add Run unit tests for per-stream interleaving, restart continuity, missing-watermark progress lookup, conflict quarantine, process exit reporting, and Windows supervisor recovery. - [x] 2.8 Add Run unit tests for per-stream interleaving, restart continuity, missing-watermark progress lookup, conflict quarantine, process exit reporting, and Windows supervisor recovery.
## 3. Management Runtime Presentation ## 3. Management Runtime Presentation
- [x] 3.1 Extend Platform Web API types and server-management contracts to consume lifecycle projection and runtime observation freshness separately. - [x] 3.1 Extend Platform Web API types and server-management contracts to consume lifecycle projection and runtime observation freshness separately.
- [ ] 3.2 Update server list and server detail status UI so stale `running` is presented as last observed with a Run offline/unverified qualifier, not confirmed online. - [x] 3.2 Update server list and server detail status UI so stale `running` is presented as last observed with a Run offline/unverified qualifier, not confirmed online.
- [x] 3.3 Update the management terminal header and empty/error states to show that live output awaits Run recovery while preserving accepted bounded SSE history. - [x] 3.3 Update the management terminal header and empty/error states to show that live output awaits Run recovery while preserving accepted bounded SSE history.
- [ ] 3.4 Add focused frontend tests for fresh, stale, offline, and recovered Run observations plus terminal presentation during log recovery. - [x] 3.4 Add focused frontend tests for fresh, stale, offline, and recovered Run observations plus terminal presentation during log recovery.
## 4. Cross-Repository Verification And Release ## 4. Cross-Repository Verification And Release
- [ ] 4.1 Run Platform and Run contract compatibility tests for signed progress recovery and lifecycle observation ordering. - [x] 4.1 Run Platform and Run contract compatibility tests for signed progress recovery and lifecycle observation ordering.
- [ ] 4.2 Perform a Windows generated Run scenario covering normal start, supervised process exit, direct Run restart with retained spool, recreated spool reconciliation, quarantined conflict, and operator-requested stop. - [ ] 4.2 Perform a Windows generated Run scenario covering normal start, supervised process exit, direct Run restart with retained spool, recreated spool reconciliation, quarantined conflict, and operator-requested stop.
- [ ] 4.3 Run targeted Go and frontend test suites, `scripts/check-structure.sh`, and `openspec validate repair-run-runtime-state-and-log-recovery --strict`; record the evidence before completing tasks. - [x] 4.3 Run targeted Go and frontend test suites, `scripts/check-structure.sh`, and `openspec validate repair-run-runtime-state-and-log-recovery --strict`; record the evidence before completing tasks.
- [ ] 4.4 Deploy Platform compatibility before the Run release, then verify runtime freshness and terminal delivery in an environment with no direct browser-to-host access. - [ ] 4.4 Deploy Platform compatibility before the Run release, then verify runtime freshness and terminal delivery in an environment with no direct browser-to-host access.
@@ -1,11 +1,11 @@
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; import { 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 { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents"; import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
@@ -14,9 +14,7 @@ type TerminalQuickCommand = { label: string; command: string; hint: string };
const terminalBridgeResultPollMs = 1000; const terminalBridgeResultPollMs = 1000;
const terminalBridgeResultPollAttempts = 30; const terminalBridgeResultPollAttempts = 30;
const liveLogHistoryWindow = 100;
const terminalInitialHistoryWindow = 500; const terminalInitialHistoryWindow = 500;
const maxLogEntries = 500;
const maxTerminalLines = 10000; const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = { const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
"game.scum": [ "game.scum": [
@@ -73,125 +71,6 @@ function LiveOperationDrawer({ open, title, description, onClose, children, back
); );
} }
interface ServerLiveLogDrawerProps {
open: boolean;
serverId: string;
serverName: string;
onClose: () => void;
}
export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: ServerLiveLogDrawerProps) {
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [selectedStreamId, setSelectedStreamId] = useState("");
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
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;
if (showLoading) setStreams({ status: "loading" });
try {
const response = await platformApiClient.listServerLiveLogs(serverId);
setStreams({ status: "ready", data: response.items });
setSelectedStreamId((current) => response.items.some((stream) => stream.id === current) ? current : response.items[0]?.id ?? "");
} catch (error) {
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
}
}, [open, serverId]);
useEffect(() => {
if (!open) return;
setEntries([]);
setPaused(false);
setLastRefreshAt("");
void loadStreams();
}, [loadStreams, open]);
useEffect(() => {
if (!open) return undefined;
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 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)));
}, [entries, keyword, selectedStreamId]);
function clearVisibleBuffer() {
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
}
function selectLogStream(nextStreamId: string) {
setSelectedStreamId(nextStreamId);
}
return (
<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={() => 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 ? 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="没有新的匹配日志;保持窗口打开会继续接收平台推送。" />}
{visibleEntries.length > 0 && (
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
{visibleEntries.map((entry) => (
<div key={`${entry.streamId}-${entry.seq}`} className="log-line live-log-line">
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
<span>{entry.line}</span>
</div>
))}
</div>
)}
</LiveOperationDrawer>
);
}
interface ServerManagementTerminalDrawerProps { interface ServerManagementTerminalDrawerProps {
open: boolean; open: boolean;
serverId: string; serverId: string;
@@ -215,7 +94,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const initialHistoryPendingRef = useRef(false); const initialHistoryPendingRef = useRef(false);
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
const supportsCommands = quickCommands.length > 0; const supportsCommands = quickCommands.length > 0;
const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]);
const appendLines = useCallback((incoming: TerminalLine[]) => { const appendLines = useCallback((incoming: TerminalLine[]) => {
if (incoming.length === 0) return; if (incoming.length === 0) return;
@@ -236,19 +114,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}); });
}, []); }, []);
const loadStreams = useCallback(async (showLoading = true) => {
if (!open) return;
if (showLoading) setStreams({ status: "loading" });
try {
const response = await platformApiClient.listServerLiveLogs(serverId);
setStreams({ status: "ready", data: response.items });
} catch (error) {
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
}
}, [open, serverId]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setStreams({ status: "loading" });
setCommand(""); setCommand("");
setPending(false); setPending(false);
setResult(null); setResult(null);
@@ -257,8 +125,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
followLatestRef.current = true; followLatestRef.current = true;
setFollowLatest(true); setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
void loadStreams(); }, [open, supportsCommands]);
}, [loadStreams, open, supportsCommands]);
useEffect(() => { useEffect(() => {
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined; if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
@@ -386,7 +253,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
<div className="terminal-output-topbar"> <div className="terminal-output-topbar">
<div> <div>
<strong>{serverName}</strong> <strong>{serverName}</strong>
<span> + SSE · Run · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志" : "日志异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span> <span> Run · + SSE · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志" : "日志异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
</div> </div>
<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={clearTerminalBuffer}><Trash2 size={14} /><span></span></button>
@@ -423,13 +290,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
); );
} }
function levelClass(level?: string): string {
const normalized = (level ?? "info").toLowerCase();
if (normalized === "error" || normalized === "fatal") return "log-level-error";
if (normalized === "warn" || normalized === "warning") return "log-level-warn";
return "log-level-info";
}
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
return terminalQuickCommandCatalog[pluginId] ?? []; return terminalQuickCommandCatalog[pluginId] ?? [];
} }
@@ -438,29 +298,6 @@ function bridgeCommandDispatchLabel(state: string, commandId: string): string {
return `${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`; return `${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
} }
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
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 {
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 (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 { function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
return { return {
id: `log-${stream.id}-${entry.seq}`, id: `log-${stream.id}-${entry.seq}`,
+6 -4
View File
@@ -219,9 +219,11 @@ describe("first-party console pages", () => {
expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer"); expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer");
expect(serversPageSource).not.toContain("live-logs"); expect(serversPageSource).not.toContain("live-logs");
expect(serversPageSource).not.toContain("管理终端"); expect(serversPageSource).not.toContain("管理终端");
expect(serverDetailPageSource).toContain("ServerLiveLogDrawer");
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer"); expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
expect(serverDetailPageSource).toContain("实时日志"); expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer");
expect(serverDetailPageSource).not.toContain('<span>实时日志</span>');
expect(serverDetailPageSource).toContain("runtimeObservationFreshness");
expect(serverDetailPageSource).toContain("Run 未验证");
expect(serverDetailPageSource).toContain("管理终端"); expect(serverDetailPageSource).toContain("管理终端");
}); });
@@ -333,11 +335,11 @@ describe("first-party console pages", () => {
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown"); expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
expect(serverLiveOperationsSource).toContain("commandHistory"); expect(serverLiveOperationsSource).toContain("commandHistory");
expect(serverLiveOperationsSource).toContain("ArrowUp"); expect(serverLiveOperationsSource).toContain("ArrowUp");
expect(serverLiveOperationsSource).toContain("listServerLiveLogs"); expect(serverLiveOperationsSource).not.toContain("listServerLiveLogs");
expect(serverLiveOperationsSource).toContain("openServerLogEvents"); expect(serverLiveOperationsSource).toContain("openServerLogEvents");
expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand"); expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand");
expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand"); expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand");
expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)"); expect(serverLiveOperationsSource).not.toContain("terminalRelevantStreams");
expect(serverLiveOperationsSource).toContain("SSE 实时推送"); expect(serverLiveOperationsSource).toContain("SSE 实时推送");
expect(serverLiveOperationsSource).toContain("mergeTerminalLines"); expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500"); expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500");
+19 -13
View File
@@ -1,4 +1,4 @@
import { Download, MoonStar, PackageOpen, Pencil, ScrollText, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
@@ -16,7 +16,8 @@ import type {
ServerMetricsResponse, ServerMetricsResponse,
ServerDeploymentResponse, ServerDeploymentResponse,
MetricSampleResponse, MetricSampleResponse,
RemoteAdapterDeclarationResponse RemoteAdapterDeclarationResponse,
RunEndpointResponse
} from "../api/types"; } from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
@@ -24,8 +25,8 @@ import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation"; import { jobCapabilityLabel } from "../contracts/jobPresentation";
import { canStartServer, canStopServer, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations"; import { ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import { import {
serverDetailSections, serverDetailSections,
serverIsOnline, serverIsOnline,
@@ -63,8 +64,8 @@ export function ServerDetailPage(props: PageComponentProps) {
const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]); const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]);
const [backups, setBackups] = useState<BackupResponse[]>([]); const [backups, setBackups] = useState<BackupResponse[]>([]);
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]); const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" }); const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [liveLogOpen, setLiveLogOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null); const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
@@ -76,7 +77,7 @@ export function ServerDetailPage(props: PageComponentProps) {
} }
setInstance({ status: "loading" }); setInstance({ status: "loading" });
try { try {
const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId), platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(), platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId), platformApiClient.listJobs(serverId),
@@ -86,7 +87,8 @@ export function ServerDetailPage(props: PageComponentProps) {
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), .catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })) platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
]); ]);
setInstance({ status: "ready", data: detail }); setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items); setPlugins(pluginResponse.items);
@@ -95,6 +97,7 @@ export function ServerDetailPage(props: PageComponentProps) {
setMetricHistory(metricHistoryResponse.items); setMetricHistory(metricHistoryResponse.items);
setBackups(backupResponse.items); setBackups(backupResponse.items);
setRemoteAdapters(adapterResponse.items); setRemoteAdapters(adapterResponse.items);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
const artifactLists = await Promise.all( const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) => jobResponse.items.slice(0, 20).map((job) =>
platformApiClient platformApiClient
@@ -107,6 +110,7 @@ export function ServerDetailPage(props: PageComponentProps) {
} catch (error) { } catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" }); setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setArtifacts([]); setArtifacts([]);
setRunEndpoint(undefined);
setDeployment({ status: "error", reason: "部署定义加载失败" }); setDeployment({ status: "error", reason: "部署定义加载失败" });
setMetricHistory([]); setMetricHistory([]);
setBackups([]); setBackups([]);
@@ -127,14 +131,16 @@ export function ServerDetailPage(props: PageComponentProps) {
const refreshOperationalState = useCallback(async () => { const refreshOperationalState = useCallback(async () => {
if (!serverId) return; if (!serverId) return;
try { try {
const [detail, jobResponse, metricsResponse] = await Promise.all([ const [detail, jobResponse, metricsResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId), platformApiClient.getServerInstance(serverId),
platformApiClient.listJobs(serverId), platformApiClient.listJobs(serverId),
platformApiClient.listServerMetrics() platformApiClient.listServerMetrics(),
platformApiClient.listRunEndpoints()
]); ]);
setInstance({ status: "ready", data: detail }); setInstance({ status: "ready", data: detail });
setJobs(jobResponse.items); setJobs(jobResponse.items);
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null); setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
} catch { } catch {
setMetrics(null); setMetrics(null);
} }
@@ -151,6 +157,8 @@ export function ServerDetailPage(props: PageComponentProps) {
); );
const canManageServers = session.capabilities.includes("servers.manage"); const canManageServers = session.capabilities.includes("servers.manage");
const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined; const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined;
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}Run 未验证)` : "未验证";
const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]); const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]);
useEffect(() => { useEffect(() => {
@@ -243,7 +251,7 @@ export function ServerDetailPage(props: PageComponentProps) {
</span> </span>
</div> </div>
<div className="action-strip"> <div className="action-strip">
<span className={cx("status-pill", statusClass(instance.data.state))}>{stateLabel(instance.data.state)}</span> <span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
<button <button
type="button" type="button"
className="icon-command" className="icon-command"
@@ -262,12 +270,11 @@ export function ServerDetailPage(props: PageComponentProps) {
<Square size={15} /> <Square size={15} />
<span></span> <span></span>
</button> </button>
<button type="button" className="icon-command" onClick={() => setLiveLogOpen(true)}><ScrollText size={15} /><span></span></button>
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有管理权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span></span></button> <button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有管理权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span></span></button>
</div> </div>
</div> </div>
<div className="server-detail-stat-strip"> <div className="server-detail-stat-strip">
<HeaderStat label="状态" value={serverIsOnline(instance.data.state) ? "在线" : "离线"} /> <HeaderStat label="状态" value={detailFreshness === "fresh" ? (serverIsOnline(instance.data.state) ? "在线" : "离线") : "未验证"} />
<HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} /> <HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} />
<HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} /> <HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} />
<HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} /> <HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} />
@@ -310,7 +317,6 @@ export function ServerDetailPage(props: PageComponentProps) {
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />} {section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerLiveLogDrawer open={liveLogOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setLiveLogOpen(false)} />
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} /> <ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</> </>
)} )}