feat(scum): add file config workbench
This commit is contained in:
@@ -282,6 +282,11 @@ export interface RuntimeLogSourceResponse {
|
||||
retentionDays?: number;
|
||||
}
|
||||
|
||||
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 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[]; }
|
||||
|
||||
export interface RuntimeLogEventResponse {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -364,6 +369,7 @@ export interface GamePluginResponse {
|
||||
lifecycleActions: Record<string, string>;
|
||||
bridgeActions: string[];
|
||||
pages: GamePluginPageResponse[];
|
||||
fileWorkspace?: PluginFileWorkspaceResponse;
|
||||
tags: string[];
|
||||
aiPurposes: string[];
|
||||
productionLifecycle: PluginProductionLifecycleDeclaration;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FileCode2, FileText, RefreshCw, Save, ScrollText } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { LogEntryBody, PluginFileWorkspaceResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse } from "../api/types";
|
||||
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
|
||||
import { DiffView } from "./OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; config: ServerConfigResponse; values: Record<string, string>; unknown: Array<[string, string]> };
|
||||
|
||||
export function ScumFileConfigWorkbench({ contract, workspace }: { contract: ScumOperationsPageContract; workspace: PluginFileWorkspaceResponse }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [directoryKey, setDirectoryKey] = useState(workspace.defaultDirectoryKey);
|
||||
const [selectedFileKey, setSelectedFileKey] = useState(workspace.files.find((file) => file.directoryKey === workspace.defaultDirectoryKey)?.key ?? "");
|
||||
const [preview, setPreview] = useState<ServerConfigDiffPreviewResponse | null>(null);
|
||||
const [result, setResult] = useState<string>("");
|
||||
const [logEntries, setLogEntries] = useState<LogEntryBody[] | null>(null);
|
||||
|
||||
const files = useMemo(() => workspace.files.filter((file) => file.directoryKey === directoryKey), [directoryKey, workspace.files]);
|
||||
const selectedFile = workspace.files.find((file) => file.key === selectedFileKey);
|
||||
const fields = workspace.configFields.filter((field) => field.fileKey === selectedFileKey);
|
||||
const load = useCallback(async () => {
|
||||
setState({ status: "loading" }); setPreview(null); setResult("");
|
||||
try { const config = await platformApiClient.getServerConfig(contract.serverInstanceId); const parsed = parseIni(config.content); const values = Object.fromEntries(workspace.configFields.map((field) => [field.key, parsed[field.configKey] ?? field.defaultValue ?? ""])); const known = new Set(workspace.configFields.map((field) => field.configKey)); setState({ status: "ready", config, values, unknown: Object.entries(parsed).filter(([key]) => !known.has(key)) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "无法读取平台配置投影。" }); }
|
||||
}, [contract.serverInstanceId, workspace.configFields]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
useEffect(() => { setSelectedFileKey(workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? ""); setLogEntries(null); setPreview(null); }, [directoryKey, workspace.files]);
|
||||
|
||||
async function openLog() { if (!selectedFile?.streamKey) return; setLogEntries(null); try { const streams = await platformApiClient.listLogStreams(); const stream = streams.items.find((item) => item.serverInstanceId === contract.serverInstanceId && item.streamKey === selectedFile.streamKey); if (!stream) { setLogEntries([]); return; } const data = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 200), limit: 200 }); setLogEntries(data.entries); } catch (error) { setResult(error instanceof Error ? error.message : "日志文件内容读取失败。" ); setLogEntries([]); } }
|
||||
async function previewChanges() { if (state.status !== "ready") return; const proposed = patchIni(state.config.content, fields, state.values); try { setPreview(await platformApiClient.previewServerConfigDiff(contract.serverInstanceId, { expectedConfigVersion: state.config.configVersion, expectedChecksum: state.config.checksum, key: state.config.key ?? selectedFileKey, proposedContent: proposed })); } catch (error) { setResult(error instanceof Error ? error.message : "配置差异预览失败。" ); } }
|
||||
async function approve() { if (!preview) return; try { const response = await platformApiClient.approveServerConfigWrite(contract.serverInstanceId, { expectedConfigVersion: preview.configVersion, expectedChecksum: preview.checksum, key: preview.key, proposedContent: preview.proposedContent, idempotencyKey: `scum-files-config-${Date.now()}` }); setResult(`已派发配置写入任务 ${response.job.id}。`); setPreview(null); await load(); } catch (error) { setResult(error instanceof Error ? error.message : "配置写入审批失败。" ); } }
|
||||
if (state.status === "loading") return <LoadingState label="正在加载 SCUM 文件与配置工作台…" />;
|
||||
if (state.status === "error") return <ErrorState title="SCUM 文件与配置不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
|
||||
return <div className="console-page" aria-label="SCUM 文件与配置工作台">
|
||||
<section className="console-panel"><div className="panel-header"><div><h2><FileCode2 size={16} /> 文件与配置</h2><p className="provider-id">默认展开插件声明的配置目录;仅模型字段可编辑,保存前必须预览差异。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="action-strip">{workspace.directories.map((directory) => <button key={directory.key} type="button" className="icon-command" aria-pressed={directoryKey === directory.key} onClick={() => setDirectoryKey(directory.key)}><span>{directory.label}</span></button>)}</div><div className="console-record-list">{files.map((file) => <button key={file.key} type="button" className="console-record" aria-pressed={selectedFileKey === file.key} onClick={() => { setSelectedFileKey(file.key); setPreview(null); setLogEntries(null); }}><span>{file.kind === "log" ? <ScrollText size={15} /> : <FileText size={15} />}</span><strong>{file.label}</strong><small>{file.kind === "log" ? "日志文件" : file.editable ? "已建模配置" : "只读配置"}</small></button>)}</div></section>
|
||||
{selectedFile?.kind === "config" && <section className="console-panel"><div className="panel-header"><h2>配置字段 · {selectedFile.label}</h2><span className="page-status">{fields.length} 项已建模</span></div><div className="provider-form">{fields.map((field) => <label key={field.key}>{field.label}<small>{field.description} · 默认值 {field.defaultValue || "--"} · {field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"} · 所属 {selectedFile.label}</small>{field.control === "boolean" ? <select value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)}><option value="true">启用</option><option value="false">关闭</option></select> : <input type={field.control === "text" ? "text" : "number"} min={field.minimum} max={field.maximum} value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)} />}</label>)}</div><div className="console-module"><strong>未建模字段(只读)</strong>{state.unknown.length ? <div className="console-row-list">{state.unknown.map(([key, value]) => <div className="console-row" key={key}><span>{key}</span><code>{value}</code></div>)}</div> : <p className="page-status">没有未建模字段。</p>}</div><div className="action-strip"><button type="button" className="icon-command" onClick={() => void previewChanges()}><Save size={14} /><span>预览配置差异</span></button>{preview && <button type="button" className="icon-command" onClick={() => void approve()}><span>批准并派发写入</span></button>}</div>{preview && <DiffView lines={preview.diff.map((line) => ({ kind: line.kind === "context" ? "same" : line.kind, text: line.content }))} />}</section>}
|
||||
{selectedFile?.kind === "log" && <section className="console-panel"><div className="panel-header"><h2>日志文件 · {selectedFile.label}</h2><button type="button" className="icon-command" onClick={() => void openLog()}><ScrollText size={14} /><span>读取文件内容</span></button></div>{logEntries === null ? <EmptyState title="尚未读取日志文件" description="只读取该插件声明的日志流,不显示主机路径。" /> : logEntries.length === 0 ? <EmptyState title="暂无日志内容" description="当前声明的日志流没有可读取记录。" /> : <pre className="log-view">{logEntries.map((entry) => `${entry.timestamp} ${entry.line}`).join("\n")}</pre>}</section>}
|
||||
{result && <ResultBadge status={result.startsWith("已派发") ? "succeeded" : "failed"} label={result} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function parseIni(content: string): Record<string, string> { return Object.fromEntries(content.split(/\r?\n/).flatMap((line) => { const match = line.match(/^\s*([^=;#\s]+)\s*=\s*(.*?)\s*$/); return match ? [[match[1], match[2]]] : []; })); }
|
||||
function patchIni(content: string, fields: PluginFileWorkspaceResponse["configFields"], values: Record<string, string>): string { let next = content; for (const field of fields) { const pattern = new RegExp(`(^\\s*${escapeRegExp(field.configKey)}\\s*=\\s*).*?$`, "m"); next = pattern.test(next) ? next.replace(pattern, `$1${values[field.key]}`) : `${next.replace(/\s*$/, "")}\n${field.configKey}=${values[field.key]}`; } return next; }
|
||||
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
@@ -10,7 +10,7 @@ const now = "2026-07-20T08:00:00Z";
|
||||
|
||||
const contract: ScumOperationsPageContract = {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "operations",
|
||||
routeKey: "files-config",
|
||||
serverInstanceId: "server-1",
|
||||
title: "SCUM 运维",
|
||||
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"],
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { resolveScumOperationsPageContract } from "./scumOperations";
|
||||
import { normalizeScumRouteKey, resolveScumOperationsPageContract } from "./scumOperations";
|
||||
|
||||
const plugin = {
|
||||
id: "game.scum",
|
||||
pages: [{
|
||||
key: "operations",
|
||||
title: "SCUM 运维",
|
||||
path: "/operations",
|
||||
key: "files-config",
|
||||
title: "文件与配置",
|
||||
path: "/files-config",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access", "unknown.permission"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request", "unknown.action"]
|
||||
}],
|
||||
@@ -26,7 +26,7 @@ const plugin = {
|
||||
],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
},
|
||||
runtimeProfiles: {
|
||||
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
|
||||
@@ -42,7 +42,7 @@ describe("SCUM operations page contract", () => {
|
||||
available: true,
|
||||
contract: {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "operations",
|
||||
routeKey: "files-config",
|
||||
serverInstanceId: "server-1",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
|
||||
@@ -61,4 +61,10 @@ describe("SCUM operations page contract", () => {
|
||||
expect(resolveScumOperationsPageContract(plugin, "")).toMatchObject({ available: false, reason: "缺少服务器实例上下文。" });
|
||||
expect(resolveScumOperationsPageContract({ ...plugin, gameClientBridge: undefined }, "server-1")).toMatchObject({ available: false });
|
||||
});
|
||||
|
||||
it("migrates only legacy SCUM page keys to the files-and-config workbench", () => {
|
||||
expect(normalizeScumRouteKey("game.scum", "overview")).toBe("files-config");
|
||||
expect(normalizeScumRouteKey("game.scum", "logs")).toBe("files-config");
|
||||
expect(normalizeScumRouteKey("game.other", "logs")).toBe("logs");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./pluginBridge";
|
||||
|
||||
export const scumOperationsPluginId = "game.scum";
|
||||
export const scumOperationsRouteKey = "operations";
|
||||
export const scumOperationsRouteKey = "files-config";
|
||||
|
||||
export interface ScumOperationsPageContract {
|
||||
pluginId: typeof scumOperationsPluginId;
|
||||
@@ -102,7 +102,9 @@ export type ScumOperationsPageResolution =
|
||||
| { available: true; contract: ScumOperationsPageContract }
|
||||
| { available: false; reason: string };
|
||||
|
||||
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
|
||||
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle" | "fileWorkspace">;
|
||||
|
||||
export function normalizeScumRouteKey(pluginId: string, routeKey: string): string { return pluginId === scumOperationsPluginId && ["overview", "operations", "config", "logs"].includes(routeKey) ? scumOperationsRouteKey : routeKey; }
|
||||
|
||||
export function resolveScumOperationsPageContract(plugin: ScumPluginProjection, serverInstanceId: string): ScumOperationsPageResolution {
|
||||
if (plugin.id !== scumOperationsPluginId) {
|
||||
|
||||
@@ -30,9 +30,9 @@ const plugin: GamePluginResponse = {
|
||||
lifecycleActions: {},
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
|
||||
pages: [{
|
||||
key: "operations",
|
||||
title: "SCUM 运维",
|
||||
path: "/operations",
|
||||
key: "files-config",
|
||||
title: "文件与配置",
|
||||
path: "/files-config",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
|
||||
}],
|
||||
@@ -45,7 +45,7 @@ const plugin: GamePluginResponse = {
|
||||
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
|
||||
},
|
||||
status: "installed"
|
||||
};
|
||||
@@ -62,7 +62,7 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
};
|
||||
return {
|
||||
session,
|
||||
params: { pluginId: "game.scum", routeKey: "operations", serverId },
|
||||
params: { pluginId: "game.scum", routeKey: "files-config", serverId },
|
||||
operations,
|
||||
onNavigate: () => undefined,
|
||||
onLogout: async () => undefined,
|
||||
@@ -74,7 +74,7 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
describe("PluginPageHostPage", () => {
|
||||
it("renders SCUM operations from manifest-owned declarations", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
|
||||
expect(html).toContain("SCUM 运维");
|
||||
expect(html).toContain("文件与配置");
|
||||
expect(html).toContain("平台托管上下文");
|
||||
expect(html).toContain("命令目录");
|
||||
expect(html).toContain("快照目录");
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { PageFrame } from "../components/PageFrame";
|
||||
import { ScumOperationsPanel } from "../components/ScumOperationsPanel";
|
||||
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
import { resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
|
||||
import { normalizeScumRouteKey, resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
|
||||
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
|
||||
type PluginPageState =
|
||||
@@ -22,7 +22,7 @@ interface PluginPageHostPageProps extends PageComponentProps {
|
||||
|
||||
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
|
||||
const pluginId = params.pluginId ?? "";
|
||||
const routeKey = params.routeKey ?? "";
|
||||
const routeKey = normalizeScumRouteKey(pluginId, params.routeKey ?? "");
|
||||
const serverId = params.serverId ?? "";
|
||||
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
||||
|
||||
@@ -105,7 +105,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{scumResolution?.available && <ScumOperationsPanel contract={scumResolution.contract} />}
|
||||
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user