# Plan: Arma 3 Adapter UX Enhancement **Status:** APPROVED — Ready to implement **Branch:** main **Estimated effort:** ~20h total (5 active phases) --- ## Progress Tracker > **IMPLEMENTING AGENT:** Update this section at the start and end of each session. Mark each phase `[x]` when ALL its checklist items pass. This is the only reliable way for the next session to know where to pick up. | Phase | Status | Last session note | |-------|--------|------------------| | 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` | | | 5 — Log File Browser | `[ ] not started` | | **How to resume:** Read this table first. Find the first phase that is not `[x] done`. Read only that phase section — do not re-read earlier phases. Run `cd frontend && npx tsc --noEmit` to confirm the build is clean before making any changes. --- ## Background & Context ### What was analyzed `arma-server-web-admin` (Node.js/Express + Backbone.js) was deep-analyzed as a UX benchmark. Reference documentation is in `docs/` (this repo): - `docs/ANALYSIS.md` — feature inventory, tech stack, directory structure - `docs/HOW_IT_WORKS.md` — internal flows (server start/stop, mission upload, mod discovery, Socket.IO bridge) - `docs/CHERRY_PICK.md` — adapter candidates with file paths and adapter strategies **You do NOT need to read the original arma-server-web-admin source.** All relevant patterns are documented in `docs/` and self-contained below. ### Problem statement languard-servers-manager has a **complete backend** (42+ endpoints, Arma3ConfigGenerator, Arma3MissionManager, Arma3ModManager, Arma3BanManager, Arma3RemoteAdmin) but the **frontend has gaps** that make daily Arma 3 server administration painful: | Problem | Root cause | |---------|-----------| | All config fields render as text boxes | ConfigEditor is generic; no per-field widget hints | | Can't build a mission rotation | Backend config supports `missions[]` array; no UI | | Mods show `@CBA_A3` not "Community Base Addons" | `mod.cpp` not parsed; no `display_name` field | | Can't kick a player from the UI | `Arma3RemoteAdmin.kick_player()` exists; endpoint missing | | Can't browse/download historical log files | Only real-time WebSocket stream; no file browser | | Must navigate to detail page to start/stop | No quick-action buttons on ServerCard | --- ## Cross-Reference: Cherry-Pick Classification ### MUST HAVE | Feature | Gap in languard | |---------|----------------| | Config field UI widgets (textarea/dropdown/toggle/tag-list) | Generic `` for everything | | Kick player from Players tab | `Arma3RemoteAdmin.kick_player(slot_id, reason)` exists but no HTTP endpoint | | Mission rotation table + per-mission difficulty | Backend: `Arma3MissionManager.get_rotation_config()` exists. Frontend: nothing | | Multi-file mission upload | `useUploadMission` accepts single `File` only | ### GOOD TO HAVE | Feature | Gap | |---------|-----| | Mod display names (mod.cpp parsing) | `list_available_mods()` returns path only, no `display_name` | | Split-pane mod assignment UI | Flat checkbox list | | Server card start/stop quick actions | Must navigate to ServerDetailPage | | Log file browser + download | WebSocket stream only | | Admin UIDs tag list in config | Hidden in raw JSON config editor | | Ban from player list quick action | User must go to Bans tab manually | ### OPTIONAL | Feature | Note | |---------|------| | Steam Workshop mission download | Requires external `steamcmd` binary | | Mod Steam Workshop ID (meta.cpp) | Informational only | | Log viewer level filter | Pure client-side | | Headless client count in UI | Niche advanced feature | --- ## Current Codebase — Critical File Inventory ### Backend (FastAPI + Python) ``` backend/ ├── adapters/arma3/ │ ├── adapter.py # Arma3Adapter — registers capabilities, returns sub-managers │ ├── config_generator.py # Arma3ConfigGenerator │ │ SECTIONS: ServerSection, BasicSection, ProfileSection, │ │ LaunchSection, RconSection │ │ KEY FIELDS IN ServerSection: │ │ hostname (str), max_players (int), password (str), │ │ admin_password (str), motd_lines (list[str]), │ │ forced_difficulty (str), battle_eye (bool), von (bool), │ │ admin_uids (list[str]) │ │ KEY FIELDS IN LaunchSection: │ │ additional_args (list[str]) │ │ METHODS: get_sections(), write_configs(), build_launch_args(), │ │ get_sensitive_fields(), get_defaults() │ │ ADD: get_ui_schema() -> dict │ ├── mission_manager.py # Arma3MissionManager │ │ list_missions() -> [{name, filename, size_bytes}] │ │ parse_mission_filename(fn) -> {mission_name, terrain, filename} │ │ get_rotation_config(entries) -> Arma3 missions config block string │ │ UPDATE: list_missions() add terrain field │ ├── mod_manager.py # Arma3ModManager │ │ list_available_mods() -> [{name, path, size_bytes, enabled}] │ │ get_enabled_mods(), set_enabled_mods(), build_mod_args() │ │ UPDATE: add display_name, workshop_id to list_available_mods() │ ├── log_parser.py # RPTParser │ │ parse_line(), get_log_file_resolver() │ │ ADD: list_log_files(server_dir), get_log_file_path(server_dir, filename) │ ├── remote_admin.py # Arma3RemoteAdmin (DO NOT MODIFY — stable) │ │ kick_player(slot_id, reason) -> bool ← USE THIS │ │ ban_player(uid, duration_minutes, reason) -> bool │ │ get_players() -> list[dict] │ └── ban_manager.py # Arma3BanManager — bans.txt sync (stable) │ ├── core/servers/ │ ├── router.py # Main server endpoints │ │ ADD: GET /api/servers/{id}/config/schema │ ├── service.py # ServerService │ │ ADD: get_config_schema(), kick_player(), ban_from_player() │ ├── missions_router.py # GET/POST/DELETE /api/servers/{id}/missions/* │ │ ADD: GET /api/servers/{id}/missions/rotation │ │ ADD: PUT /api/servers/{id}/missions/rotation │ ├── players_router.py # GET /api/servers/{id}/players │ │ ADD: POST /api/servers/{id}/players/{slot_id}/kick │ │ ADD: POST /api/servers/{id}/players/{slot_id}/ban │ └── [NEW] logfiles_router.py │ GET /api/servers/{id}/logfiles │ GET /api/servers/{id}/logfiles/{filename}/download │ DELETE /api/servers/{id}/logfiles/{filename} │ └── main.py # ADD: include_router(logfiles_router) ``` ### Frontend (React 19 + TypeScript + TanStack Query) ``` frontend/src/ ├── hooks/ │ └── useServerDetail.ts # All query/mutation hooks for server detail │ ADD: useServerConfigSchema() │ ADD: useServerMissionRotation(), useUpdateMissionRotation() │ ADD: useKickPlayer(), useBanPlayer() │ ADD: useServerLogFiles(), useDeleteLogFile() │ UPDATE: useUploadMission(File[]) — was single File │ UPDATE: Mod type — add display_name, workshop_id │ UPDATE: Mission type — add terrain field │ ├── components/servers/ │ ├── ConfigEditor.tsx # UPDATE: consume schema, render per-widget type │ ├── MissionList.tsx # REDESIGN: Available section + Rotation section │ ├── ModList.tsx # REDESIGN: split pane (Available vs Selected) │ ├── PlayerTable.tsx # UPDATE: add Actions column (Kick/Ban buttons, admin only) │ ├── LogViewer.tsx # UPDATE: level filter + Log Files browser section │ └── ServerCard.tsx # UPDATE: Start/Stop quick-action buttons │ └── components/ui/ └── [NEW] TagListEditor.tsx # Dynamic string-list editor (reused in ConfigEditor) ``` --- ## Execution Order | Phase | Description | Priority | Est. | |-------|-------------|----------|------| | 1 | Config UI Schema (widget hints per field) | MUST | ~4h | | 4 | Player Kick/Ban | MUST | ~3h | | 2 | Mission Rotation + Multi-file upload | MUST | ~5h | | 3 | Mod Display Names + Split Pane | GOOD | ~4h | | 5 | Log File Browser + Level Filter | GOOD | ~3h | | 6 | Server Card Quick Actions | ~~GOOD~~ | **DONE** | --- ## Phase 1 — Config UI Schema System (MUST HAVE, ~4h) **Goal:** Each Arma 3 config field renders with the right UI widget instead of a generic text box. ### 1.1 `backend/adapters/arma3/config_generator.py` — add `get_ui_schema()` Add this method to `Arma3ConfigGenerator`: ```python def get_ui_schema(self) -> dict: return { "server": { # Field names MUST match Arma3ConfigGenerator.ServerConfig exactly "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"}, # NOT 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"}, # NOT battle_eye "disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, # NOT von — and it's inverted "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_packet_size": {"widget": "number", "label": "Max Packet Size"}, "max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"}, }, "launch": { "additional_args": {"widget": "tag-list", "label": "Additional Startup Parameters", "placeholder": "-limitFPS=100"}, }, "rcon": { "password": {"widget": "password", "label": "RCon Password"}, "port": {"widget": "number", "label": "RCon Port"}, }, } ``` ### 1.2 `backend/core/servers/service.py` — add `get_config_schema()` Follow the existing pattern: `ServerService.__init__` already stores `self._server_repo` and `self._config_repo`. No `db` param needed: ```python def get_config_schema(self, server_id: int) -> dict: server = self.get_server(server_id) # raises 404 if not found, uses self._server_repo 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 {} ``` ### 1.3 `backend/core/servers/router.py` — new endpoint Add after existing config routes, following the `ServerService(db)` inline pattern: ```python @router.get("/{server_id}/config/schema") def get_config_schema( server_id: int, db: Annotated[Connection, Depends(get_db)], _user: Annotated[dict, Depends(get_current_user)], ) -> dict: schema = ServerService(db).get_config_schema(server_id) return {"success": True, "data": schema, "error": None} ``` ### 1.4 `frontend/src/hooks/useServerDetail.ts` — add schema types + hook ```typescript export interface FieldSchema { widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list"; label?: string; placeholder?: string; min?: number; max?: number; options?: string[]; } export interface ConfigSchema { [section: string]: { [field: string]: FieldSchema }; } export function useServerConfigSchema(serverId: number) { return useQuery({ queryKey: ["servers", serverId, "config", "schema"], queryFn: async () => { const res = await apiClient.get<{ success: boolean; data: ConfigSchema }>( `/api/servers/${serverId}/config/schema`, ); return res.data.data; }, enabled: serverId > 0, }); } ``` ### 1.5 `frontend/src/components/ui/TagListEditor.tsx` — NEW component ```typescript interface TagListEditorProps { value: string[]; onChange: (v: string[]) => void; placeholder?: string; disabled?: boolean; } export function TagListEditor({ value, onChange, placeholder, disabled }: TagListEditorProps) { const update = (idx: number, val: string) => onChange(value.map((v, i) => (i === idx ? val : v))); const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx)); const add = () => onChange([...value, ""]); return (
{value.map((item, idx) => (
update(idx, e.target.value)} />
))}
); } ``` ### 1.6 `frontend/src/components/servers/ConfigEditor.tsx` — consume schema - Import `useServerConfigSchema` and `TagListEditor` - Call `useServerConfigSchema(serverId)` alongside existing config queries - For each field in a config section, look up `schema?.[sectionName]?.[fieldName]` - Render based on `widget`: - `"text"` → `` - `"number"` → `` - `"password"` → `` - `"textarea"` → `