Fix declared server config file reads
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
Reference in New Issue
Block a user