Fix server file workspace null handling
This commit is contained in:
@@ -1164,7 +1164,7 @@ func fileWorkspaceToDomain(body PluginFileWorkspaceBody) domain.PluginFileWorksp
|
|||||||
}
|
}
|
||||||
func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWorkspaceBody {
|
func fileWorkspaceFromDomain(workspace domain.PluginFileWorkspace) PluginFileWorkspaceBody {
|
||||||
workspace = domain.CopyPluginFileWorkspace(workspace)
|
workspace = domain.CopyPluginFileWorkspace(workspace)
|
||||||
body := PluginFileWorkspaceBody{DefaultDirectoryKey: workspace.DefaultDirectoryKey}
|
body := PluginFileWorkspaceBody{DefaultDirectoryKey: workspace.DefaultDirectoryKey, Directories: []PluginLogicalDirectoryBody{}, Files: []PluginLogicalFileBody{}, ConfigFields: []PluginConfigFieldBody{}}
|
||||||
for _, item := range workspace.Directories {
|
for _, item := range workspace.Directories {
|
||||||
body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
body.Directories = append(body.Directories, PluginLogicalDirectoryBody{Key: item.Key, Label: item.Label, Scope: item.Scope})
|
||||||
}
|
}
|
||||||
@@ -1956,6 +1956,9 @@ func ServerFileWorkspaceFromDomain(view domain.ServerFileWorkspaceView) ServerFi
|
|||||||
|
|
||||||
func ServerFileTransferPolicyFromDomain(policy domain.ServerFileTransferPolicy) ServerFileTransferPolicyResponse {
|
func ServerFileTransferPolicyFromDomain(policy domain.ServerFileTransferPolicy) ServerFileTransferPolicyResponse {
|
||||||
policy = domain.CopyServerFileTransferPolicy(policy)
|
policy = domain.CopyServerFileTransferPolicy(policy)
|
||||||
|
if policy.Notes == nil {
|
||||||
|
policy.Notes = []string{}
|
||||||
|
}
|
||||||
return ServerFileTransferPolicyResponse{Channel: policy.Channel, UploadChunkSizeBytes: policy.UploadChunkSizeBytes, DownloadChunkSizeBytes: policy.DownloadChunkSizeBytes, MaxInlineEditBytes: policy.MaxInlineEditBytes, MaxBrowserUploadBytes: policy.MaxBrowserUploadBytes, Notes: policy.Notes}
|
return ServerFileTransferPolicyResponse{Channel: policy.Channel, UploadChunkSizeBytes: policy.UploadChunkSizeBytes, DownloadChunkSizeBytes: policy.DownloadChunkSizeBytes, MaxInlineEditBytes: policy.MaxInlineEditBytes, MaxBrowserUploadBytes: policy.MaxBrowserUploadBytes, Notes: policy.Notes}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -657,6 +657,25 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(44);
|
expect(fetchMock).toHaveBeenCalledTimes(44);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes server file workspace null arrays from older platform responses", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/files/workspace")) {
|
||||||
|
return jsonResponse({ ...serverFileWorkspace, defaultDirectoryKey: "", directories: null, files: null, configFields: null, transfer: { ...serverFileWorkspace.transfer, notes: null } });
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=scum-config")) {
|
||||||
|
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "scum-config", state: "declared", entries: null });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected request: ${url}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const client = new PlatformApiClient();
|
||||||
|
|
||||||
|
await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ directories: [], files: [], configFields: [], transfer: { notes: [] } });
|
||||||
|
await expect(client.listServerFiles(server.id, { directoryKey: "scum-config" })).resolves.toMatchObject({ entries: [] });
|
||||||
|
});
|
||||||
|
|
||||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
|||||||
@@ -585,16 +585,16 @@ export class PlatformApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getServerFileWorkspace(serverInstanceId: string): Promise<ServerFileWorkspaceResponse> {
|
async getServerFileWorkspace(serverInstanceId: string): Promise<ServerFileWorkspaceResponse> {
|
||||||
return this.request<ServerFileWorkspaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`);
|
return normalizeServerFileWorkspace(await this.request<ServerFileWorkspaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listServerFiles(serverInstanceId: string, request: Partial<ServerFileListRequest> = {}): Promise<ServerFileListResponse> {
|
async listServerFiles(serverInstanceId: string, request: Partial<ServerFileListRequest> = {}): Promise<ServerFileListResponse> {
|
||||||
const params = serverFileListQuery(request);
|
const params = serverFileListQuery(request);
|
||||||
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`);
|
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
|
async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
|
||||||
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request });
|
return normalizeServerFileList(await this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise<FileOperationDispatchResponse> {
|
async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise<FileOperationDispatchResponse> {
|
||||||
@@ -964,4 +964,12 @@ function serverFileListQuery(request: Partial<ServerFileListRequest>): string {
|
|||||||
return query ? `?${query}` : "";
|
return query ? `?${query}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeServerFileWorkspace(response: ServerFileWorkspaceResponse): ServerFileWorkspaceResponse {
|
||||||
|
return { ...response, directories: response.directories ?? [], files: response.files ?? [], configFields: response.configFields ?? [], transfer: { ...response.transfer, notes: response.transfer.notes ?? [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerFileList(response: ServerFileListResponse): ServerFileListResponse {
|
||||||
|
return { ...response, entries: response.entries ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);
|
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||||||
|
|
||||||
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
||||||
const canUpload = workspace.status === "ready" && activeDirectory?.scope !== "logs" && !uploadBusy;
|
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && activeDirectory?.scope !== "logs" && !uploadBusy;
|
||||||
const entries = list.status === "ready" ? list.data.entries : [];
|
const entries = list.status === "ready" ? list.data.entries : [];
|
||||||
|
|
||||||
const loadWorkspace = useCallback(async () => {
|
const loadWorkspace = useCallback(async () => {
|
||||||
@@ -604,7 +604,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
try {
|
try {
|
||||||
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
||||||
setWorkspace({ status: "ready", data: response });
|
setWorkspace({ status: "ready", data: response });
|
||||||
setDirectoryKey((current) => current || response.defaultDirectoryKey || response.directories[0]?.key || "");
|
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
|
||||||
|
setDirectoryKey((current) => current || nextDirectoryKey);
|
||||||
|
if (!nextDirectoryKey) {
|
||||||
|
setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "插件尚未声明文件工作区;需要在插件 manifest 中添加 fileWorkspace。" } });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
||||||
setList({ status: "error", reason: "文件工作区不可用" });
|
setList({ status: "error", reason: "文件工作区不可用" });
|
||||||
@@ -773,6 +777,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
</div>
|
</div>
|
||||||
<div className="server-file-toolbar">
|
<div className="server-file-toolbar">
|
||||||
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
||||||
|
{workspace.data.directories.length === 0 && <span className="provider-id">插件尚未声明可浏览目录</span>}
|
||||||
{workspace.data.directories.map((directory) => (
|
{workspace.data.directories.map((directory) => (
|
||||||
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
|
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
|
||||||
{directory.label}
|
{directory.label}
|
||||||
|
|||||||
@@ -628,6 +628,151 @@
|
|||||||
"server.game-client.command",
|
"server.game-client.command",
|
||||||
"server.game-client.maintenance"
|
"server.game-client.maintenance"
|
||||||
],
|
],
|
||||||
|
"fileWorkspace": {
|
||||||
|
"defaultDirectoryKey": "scum-config",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"key": "scum-config",
|
||||||
|
"label": "服务器配置",
|
||||||
|
"scope": "config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-logs",
|
||||||
|
"label": "日志文件",
|
||||||
|
"scope": "logs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"key": "scum-server-settings",
|
||||||
|
"directoryKey": "scum-config",
|
||||||
|
"label": "ServerSettings.ini",
|
||||||
|
"kind": "config",
|
||||||
|
"editable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-admin-users",
|
||||||
|
"directoryKey": "scum-config",
|
||||||
|
"label": "AdminUsers.ini",
|
||||||
|
"kind": "config",
|
||||||
|
"editable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-banned-users",
|
||||||
|
"directoryKey": "scum-config",
|
||||||
|
"label": "BannedUsers.ini",
|
||||||
|
"kind": "config",
|
||||||
|
"editable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-whitelist-users",
|
||||||
|
"directoryKey": "scum-config",
|
||||||
|
"label": "WhitelistUsers.ini",
|
||||||
|
"kind": "config",
|
||||||
|
"editable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-server-log",
|
||||||
|
"directoryKey": "scum-logs",
|
||||||
|
"label": "Server.log",
|
||||||
|
"kind": "log",
|
||||||
|
"streamKey": "scum.server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-chat-log",
|
||||||
|
"directoryKey": "scum-logs",
|
||||||
|
"label": "Chat.log",
|
||||||
|
"kind": "log",
|
||||||
|
"streamKey": "scum.chat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-login-log",
|
||||||
|
"directoryKey": "scum-logs",
|
||||||
|
"label": "Login.log",
|
||||||
|
"kind": "log",
|
||||||
|
"streamKey": "scum.login"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-kill-log",
|
||||||
|
"directoryKey": "scum-logs",
|
||||||
|
"label": "Kill.log",
|
||||||
|
"kind": "log",
|
||||||
|
"streamKey": "scum.kill"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-admin-log",
|
||||||
|
"directoryKey": "scum-logs",
|
||||||
|
"label": "Admin.log",
|
||||||
|
"kind": "log",
|
||||||
|
"streamKey": "scum.admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scum-performance-log",
|
||||||
|
"directoryKey": "scum-logs",
|
||||||
|
"label": "Performance.log",
|
||||||
|
"kind": "log",
|
||||||
|
"streamKey": "scum.performance"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configFields": [
|
||||||
|
{
|
||||||
|
"key": "server-name",
|
||||||
|
"fileKey": "scum-server-settings",
|
||||||
|
"configKey": "ServerName",
|
||||||
|
"label": "服务器名称",
|
||||||
|
"description": "显示在服务器浏览器与玩家连接界面。",
|
||||||
|
"control": "text",
|
||||||
|
"defaultValue": "SCUM Server",
|
||||||
|
"restartImpact": "restart-required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "game-port",
|
||||||
|
"fileKey": "scum-server-settings",
|
||||||
|
"configKey": "GamePort",
|
||||||
|
"label": "游戏端口",
|
||||||
|
"description": "玩家连接所使用的游戏端口。",
|
||||||
|
"control": "port",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 65535,
|
||||||
|
"defaultValue": "7779",
|
||||||
|
"restartImpact": "restart-required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "query-port",
|
||||||
|
"fileKey": "scum-server-settings",
|
||||||
|
"configKey": "QueryPort",
|
||||||
|
"label": "查询端口",
|
||||||
|
"description": "服务器查询和状态发现所使用的端口。",
|
||||||
|
"control": "port",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 65535,
|
||||||
|
"defaultValue": "27015",
|
||||||
|
"restartImpact": "restart-required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "max-players",
|
||||||
|
"fileKey": "scum-server-settings",
|
||||||
|
"configKey": "MaxPlayers",
|
||||||
|
"label": "最大玩家数",
|
||||||
|
"description": "允许同时进入服务器的玩家上限。",
|
||||||
|
"control": "number",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 128,
|
||||||
|
"defaultValue": "128",
|
||||||
|
"restartImpact": "restart-required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "welcome-message",
|
||||||
|
"fileKey": "scum-server-settings",
|
||||||
|
"configKey": "WelcomeMessage",
|
||||||
|
"label": "欢迎消息",
|
||||||
|
"description": "登录成功后由已声明的服务器扩展显示给玩家。",
|
||||||
|
"control": "text",
|
||||||
|
"defaultValue": "",
|
||||||
|
"restartImpact": "none"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"install": "actions/install.json",
|
"install": "actions/install.json",
|
||||||
"start": "actions/start.json",
|
"start": "actions/start.json",
|
||||||
|
|||||||
@@ -503,7 +503,10 @@ describe("plugin manifest validation", () => {
|
|||||||
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
||||||
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
|
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
|
||||||
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||||
expect(manifest.fileWorkspace).toBeUndefined();
|
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
|
||||||
|
expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"]));
|
||||||
|
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
|
||||||
|
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"]));
|
||||||
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
|
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
|
||||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
|
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user