diff --git a/.claude/plan/arma3-ux-enhancement.md b/.claude/plan/arma3-ux-enhancement.md index 56e498f..c76479f 100644 --- a/.claude/plan/arma3-ux-enhancement.md +++ b/.claude/plan/arma3-ux-enhancement.md @@ -12,7 +12,7 @@ | Phase | Status | Last session note | |-------|--------|------------------| -| 1 — Config UI Schema | `[ ] not started` | | +| 1 — Config UI Schema | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint | | 2 — Mission Rotation | `[ ] not started` | | | 3 — Mod Display Names + Split Pane | `[ ] not started` | | | 4 — Player Kick/Ban | `[ ] not started` | | diff --git a/backend/adapters/arma3/config_generator.py b/backend/adapters/arma3/config_generator.py index d7c3992..7188704 100644 --- a/backend/adapters/arma3/config_generator.py +++ b/backend/adapters/arma3/config_generator.py @@ -382,6 +382,37 @@ class Arma3ConfigGenerator: args.extend(mod_args) return args + def get_ui_schema(self) -> dict: + return { + "server": { + "hostname": {"widget": "text", "label": "Server Hostname"}, + "max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000}, + "password": {"widget": "password", "label": "Player Password"}, + "password_admin": {"widget": "password", "label": "Admin Password"}, + "motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"}, + "forced_difficulty": {"widget": "select", "label": "Difficulty Preset", + "options": ["Recruit", "Regular", "Veteran", "Custom"]}, + "battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, + "disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, + "verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)", + "min": 0, "max": 2}, + "persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"}, + "admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs", + "placeholder": "76561198000000000"}, + }, + "basic": { + "max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"}, + }, + "launch": { + "extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters", + "placeholder": "-limitFPS=100"}, + }, + "rcon": { + "rcon_password": {"widget": "password", "label": "RCon Password"}, + "max_ping": {"widget": "number", "label": "RCon Port"}, + }, + } + def preview_config( self, server_id: int, diff --git a/backend/core/servers/router.py b/backend/core/servers/router.py index 3a4977b..bc7a5fc 100644 --- a/backend/core/servers/router.py +++ b/backend/core/servers/router.py @@ -134,6 +134,15 @@ def get_config( return _ok(ServerService(db).get_config(server_id)) +@router.get("/{server_id}/config/schema") +def get_config_schema( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _user: Annotated[dict, Depends(get_current_user)] = None, +): + return _ok(ServerService(db).get_config_schema(server_id)) + + @router.get("/{server_id}/config/preview") def get_config_preview( server_id: int, diff --git a/backend/core/servers/service.py b/backend/core/servers/service.py index 632e364..8cc091a 100644 --- a/backend/core/servers/service.py +++ b/backend/core/servers/service.py @@ -396,6 +396,14 @@ class ServerService: data[field] = "***" return sections + def get_config_schema(self, server_id: int) -> dict: + server = self.get_server(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) + config_gen = adapter.get_config_generator() + if hasattr(config_gen, "get_ui_schema"): + return config_gen.get_ui_schema() + return {} + def get_config_section(self, server_id: int, section: str) -> dict: server = self.get_server(server_id) adapter = GameAdapterRegistry.get(server["game_type"]) diff --git a/frontend/src/__tests__/ConfigSchemaPhase1.test.tsx b/frontend/src/__tests__/ConfigSchemaPhase1.test.tsx new file mode 100644 index 0000000..631d66d --- /dev/null +++ b/frontend/src/__tests__/ConfigSchemaPhase1.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { useServerConfigSchema } from "@/hooks/useServerDetail"; +import { TagListEditor } from "@/components/ui/TagListEditor"; + +vi.mock("@/lib/api", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +import { apiClient } from "@/lib/api"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const MOCK_SCHEMA = { + server: { + hostname: { widget: "text", label: "Server Hostname" }, + max_players: { widget: "number", label: "Max Players", min: 1, max: 1000 }, + password: { widget: "password", label: "Player Password" }, + forced_difficulty: { + widget: "select", + label: "Difficulty Preset", + options: ["Recruit", "Regular", "Veteran", "Custom"], + }, + battleye: { widget: "toggle", label: "BattleEye Anti-Cheat" }, + motd_lines: { widget: "textarea", label: "Message of the Day (one line per row)" }, + admin_uids: { widget: "tag-list", label: "Admin Steam UIDs", placeholder: "76561198000000000" }, + }, +}; + +describe("useServerConfigSchema", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("fetches schema from /api/servers/:id/config/schema", async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { success: true, data: MOCK_SCHEMA } }); + const { result } = renderHook(() => useServerConfigSchema(1), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(MOCK_SCHEMA); + expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1/config/schema"); + }); + + it("is disabled when serverId is 0", () => { + const { result } = renderHook(() => useServerConfigSchema(0), { wrapper: createWrapper() }); + expect(result.current.fetchStatus).toBe("idle"); + }); +}); + +describe("TagListEditor", () => { + it("renders existing items", () => { + render( {}} />); + expect(screen.getByDisplayValue("uid1")).toBeInTheDocument(); + expect(screen.getByDisplayValue("uid2")).toBeInTheDocument(); + }); + + it("calls onChange with new item when Add is clicked", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("+ Add")); + expect(onChange).toHaveBeenCalledWith(["uid1", ""]); + }); + + it("calls onChange without item when remove is clicked", () => { + const onChange = vi.fn(); + render(); + const removeButtons = screen.getAllByText("✕"); + fireEvent.click(removeButtons[0]); + expect(onChange).toHaveBeenCalledWith(["uid2"]); + }); + + it("calls onChange with updated value on input change", () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByDisplayValue("uid1"), { target: { value: "uid_new" } }); + expect(onChange).toHaveBeenCalledWith(["uid_new"]); + }); + + it("renders placeholder text", () => { + render( {}} placeholder="Enter UID" />); + expect(screen.getByPlaceholderText("Enter UID")).toBeInTheDocument(); + }); + + it("disables inputs when disabled prop is true", () => { + render( {}} disabled />); + expect(screen.getByDisplayValue("uid1")).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/servers/ConfigEditor.tsx b/frontend/src/components/servers/ConfigEditor.tsx index 776a607..c0c5e7a 100644 --- a/frontend/src/components/servers/ConfigEditor.tsx +++ b/frontend/src/components/servers/ConfigEditor.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; import clsx from "clsx"; -import { useServerConfig, useServerConfigSection, useUpdateConfigSection } from "@/hooks/useServerDetail"; +import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail"; +import type { FieldSchema } from "@/hooks/useServerDetail"; +import { TagListEditor } from "@/components/ui/TagListEditor"; import { useAuthStore } from "@/store/auth.store"; import { useUIStore } from "@/store/ui.store"; import { logger } from "@/lib/logger"; @@ -74,8 +76,10 @@ function ConfigSectionForm({ addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void; }) { const { data: sectionData, isLoading } = useServerConfigSection(serverId, section); + const { data: schema } = useServerConfigSchema(serverId); const updateSection = useUpdateConfigSection(serverId, section); const [editValues, setEditValues] = useState | null>(null); + const [showPassword, setShowPassword] = useState>({}); if (isLoading) { return
Loading section...
; @@ -89,13 +93,16 @@ function ConfigSectionForm({ const meta = sectionData._meta; const displayValues = editValues ?? Object.fromEntries(fields); const isEditing = editValues !== null; + const sectionSchema = schema?.[section] ?? {}; const handleEdit = () => { setEditValues(Object.fromEntries(fields)); + setShowPassword({}); }; const handleCancel = () => { setEditValues(null); + setShowPassword({}); }; const handleSave = async () => { @@ -107,6 +114,7 @@ function ConfigSectionForm({ }); addNotification({ type: "success", message: `${section} config updated` }); setEditValues(null); + setShowPassword({}); } catch (err) { logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err); if (err instanceof Error && "response" in err) { @@ -127,6 +135,10 @@ function ConfigSectionForm({ setEditValues({ ...editValues, [key]: value }); }; + const toggleShowPassword = (key: string) => { + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + }; + return (
@@ -140,23 +152,33 @@ function ConfigSectionForm({ )}
- {fields.map(([key, value]) => ( -
- - {isEditing && !SENSITIVE_KEYS.has(key) ? ( - handleChange(key, e.target.value)} - type={typeof value === "number" ? "number" : "text"} - /> - ) : ( - - {SENSITIVE_KEYS.has(key) ? "••••••••" : String(value ?? "--")} - - )} -
- ))} + {fields.map(([key]) => { + const fieldSchema: FieldSchema | undefined = sectionSchema[key]; + const widget = fieldSchema?.widget ?? (SENSITIVE_KEYS.has(key) ? "password" : "text"); + const label = fieldSchema?.label ?? formatLabel(key); + const rawValue = displayValues[key]; + + return ( +
+ + {isEditing ? ( + toggleShowPassword(key)} + onChange={(v) => handleChange(key, v)} + /> + ) : ( + + {widget === "password" ? "••••••••" : formatDisplayValue(rawValue)} + + )} +
+ ); + })} {isEditing && (
@@ -172,8 +194,117 @@ function ConfigSectionForm({ ); } +function FieldWidget({ + fieldKey, + widget, + fieldSchema, + value, + showPassword, + onTogglePassword, + onChange, +}: { + fieldKey: string; + widget: string; + fieldSchema: FieldSchema | undefined; + value: unknown; + showPassword: boolean; + onTogglePassword: () => void; + onChange: (v: unknown) => void; +}) { + switch (widget) { + case "textarea": + return ( +