Fix declared server config file reads

This commit is contained in:
npc0-hue
2026-09-21 10:05:50 +08:00
parent b481b1b798
commit 20008b4043
16 changed files with 166 additions and 33 deletions
+2 -2
View File
@@ -403,7 +403,7 @@ export interface RuntimeLogSourceResponse {
}
export interface PluginLogicalDirectoryResponse { key: string; label: string; scope: "config" | "logs"; }
export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean; }
export interface PluginLogicalFileResponse { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; targetKey?: string; editable?: boolean; }
export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; }
export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; }
@@ -1373,7 +1373,7 @@ export interface DeclaredFileReadSnapshotResponse {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | string;
state: "ready" | "pending" | "not-read" | "failed" | "cancelled" | string;
content?: string;
version?: number;
checksum?: string;
@@ -80,4 +80,64 @@ describe("ServerConfigEditor", () => {
expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1);
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2);
});
it("surfaces a terminal Run read failure without polling for thirty seconds", async () => {
apiMocks.getServerFileReadSnapshot.mockResolvedValue({ serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "failed", reason: "Run 文件读取失败:file is missing" });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false };
await act(async () => root?.render(<ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />));
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(apiMocks.readServerFile).not.toHaveBeenCalled();
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("file is missing");
expect(container.textContent).toContain("重试");
});
it("does not dispatch a second read while an existing Run read is pending", async () => {
apiMocks.getServerFileReadSnapshot.mockResolvedValue({ serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "pending", reason: "等待运行端完成文件读取。" });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false };
await act(async () => root?.render(<ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />));
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(apiMocks.readServerFile).not.toHaveBeenCalled();
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(apiMocks.readServerFile).not.toHaveBeenCalled();
expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2);
});
it("retries a failed read with a new idempotency key and loads the file", async () => {
let retryRequested = false;
apiMocks.getServerFileReadSnapshot.mockImplementation(async () => retryRequested
? { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "[General]\nscum.MaxPlayers=63\n", version: 1, checksum: "sha256:test" }
: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "failed", reason: "Run 文件读取失败:file is missing" });
apiMocks.readServerFile.mockImplementation(async (_serverId: string, request: { idempotencyKey: string }) => {
retryRequested = true;
return { status: "queued", serverInstanceId: "server-1", pluginId: "game.scum", operation: "read", key: "scum-server-settings", job: { id: "job-read-retry", state: "queued" }, request };
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
const operations: OperationTracker = { operations: [], begin: () => "operation-read", update: () => undefined, succeed: () => undefined, fail: () => undefined, isPending: () => false };
await act(async () => root?.render(<ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />));
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
const retry = [...(container.querySelectorAll("button"))].find((button) => button.textContent?.includes("重试"));
expect(retry).toBeDefined();
await act(async () => retry?.click());
await act(async () => { await vi.advanceTimersByTimeAsync(500); await Promise.resolve(); });
expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1);
expect(apiMocks.readServerFile.mock.calls[0][1].idempotencyKey).toBe("web:server-config:read:server-1:scum-server-settings:1");
expect(container.textContent).toContain("已读取 ServerSettings.ini");
});
});
+15 -7
View File
@@ -28,6 +28,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const readInFlightRef = useRef("");
const readAttemptRef = useRef(new Map<string, number>());
const operationsRef = useRef(operations);
operationsRef.current = operations;
@@ -35,7 +36,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0];
const fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]);
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => {
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse, forceRetry = false) => {
const readKey = `${instance.id}:${file.key}`;
if (readInFlightRef.current === readKey) return;
readInFlightRef.current = readKey;
@@ -44,16 +45,23 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
setMessage("正在通过 Run 读取配置文件…");
try {
let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
if (next.state !== "ready") {
const failedState = next.state === "failed" || next.state === "cancelled";
let dispatched = false;
if (next.state === "not-read" || (forceRetry && failedState)) {
const readAttempt = (readAttemptRef.current.get(readKey) ?? 0) + 1;
readAttemptRef.current.set(readKey, readAttempt);
const operationId = operationsRef.current.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester });
const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key) });
const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key, readAttempt) });
operationsRef.current.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) {
dispatched = true;
}
if (dispatched || next.state === "pending") {
for (let pollAttempt = 0; pollAttempt < 60 && next.state !== "ready"; pollAttempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 500));
next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
}
}
if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? "Run 未返回配置文件内容。");
if (next.state !== "ready" || next.content === undefined) throw new Error(next.reason ?? (next.state === "pending" ? "Run 仍在读取配置文件,请稍后重试。" : "Run 未返回配置文件内容。"));
setSnapshot(next);
setRaw(next.content);
setValues(Object.fromEntries(fields.map((field) => [field.key, iniValue(next.content ?? "", field.configKey)])));
@@ -120,7 +128,7 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
<div className="panel-header"><div><h3>{selectedFile?.label}</h3><span className="page-status"> Run </span></div></div>
<div className="file-workbench-mode"><span>{mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件"}</span><button type="button" className={mode === "friendly" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("friendly")}><FileCog size={13} /> </button><button type="button" className={mode === "source" ? "file-workbench-mode-active" : undefined} onClick={() => setMode("source")}><Code2 size={13} /> </button></div>
{message && <ResultBadge status={error ? "failed" : "pending"} label={message} />}
{error && workspace && <ErrorState title="配置操作失败" reason={error} diagnosticId={`server-config:${instance.id}:${selectedFile?.key ?? "unknown"}`} compact />}
{error && workspace && <ErrorState title="配置操作失败" reason={error} diagnosticId={`server-config:${instance.id}:${selectedFile?.key ?? "unknown"}`} onRetry={selectedFile ? () => void readSelectedFile(selectedFile, true) : undefined} compact />}
{loading && <LoadingState label="正在读取配置文件…" compact />}
{!loading && mode === "friendly" && <div className="file-workbench-fields">{fields.length === 0 && <span className="provider-id"></span>}{configFieldGroups(fields).map((group) => <div key={group.label} className="file-workbench-group"><h4>{group.label}</h4>{group.fields.map((field) => <ConfigField key={field.key} field={field} value={values[field.key] ?? ""} onChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))} />)}</div>)}</div>}
{!loading && mode === "source" && <div className="file-workbench-raw"><textarea className="file-workbench-raw-editor" value={raw} spellCheck={false} onChange={(event) => setRaw(event.target.value)} aria-label={`${selectedFile?.label ?? "配置文件"}源码`} /><small> Run </small></div>}
@@ -147,5 +155,5 @@ function configFieldGroups(fields: PluginConfigFieldResponse[]): Array<{ label:
return [...groups.entries()].map(([label, groupFields]) => ({ label, fields: groupFields }));
}
function configOperationKey(operation: string, serverId: string, fileKey: string): string { return `web:server-config:${operation}:${serverId}:${fileKey}`; }
function configOperationKey(operation: string, serverId: string, fileKey: string, attempt = 1): string { return `web:server-config:${operation}:${serverId}:${fileKey}:${attempt}`; }
function formatReadTime(value: string): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN"); }