Compare commits

..

7 Commits

Author SHA1 Message Date
Tran G. (Revernomad) Khoa
6e9a37ef00 fix: use absolute paths in Arma 3 launch args to survive cwd change
Arma 3 changes its own working directory to the exe folder on startup,
so relative paths like -config=server.cfg resolved against A3Master/
instead of the server data dir. Configs were never found, and profile/
battleye dirs pointed at the wrong location (confirmed via RPT location
in A3Master/server/ instead of the data dir).

build_launch_args() now accepts an optional server_dir: Path argument.
When provided, all four path args (-config, -cfg, -profiles, -bepath)
use absolute paths. Service passes server_dir at the call site.
2026-04-20 11:13:18 +07:00
Tran G. (Revernomad) Khoa
d45345a094 feat: fix mods tab, add client/server split, and scaffold server dirs
Mods tab bug fixes:
- mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use
  mods/ subdir instead of server root, migrate old string-list format to
  dict format on read
- service: replace dead server_mods SQL JOIN with get_enabled_mods()
  call through the mod_manager capability; pass is_server_mod to
  build_mod_args
- mods_router: accept list[EnabledModEntry] objects (name + is_server_mod)
  instead of bare strings

Client/server mod split:
- Mods now stored as list[{"name": str, "is_server_mod": bool}]; old
  string-list format auto-migrated on read
- is_server_mod=true routes to -serverMod= arg; false to -mod= arg
- ModList UI: amber Client/Server badge in selected pane; toggle button
  in split-pane selector

Directory scaffold:
- process_config: adds "mods" to dir layout; provides get_dir_readme()
  with per-directory README.txt content
- file_utils: ensure_server_dirs() gains readme_provider kwarg; writes
  README.txt idempotently if absent
- service.create_server: passes readme_provider via hasattr probe
- main.py startup: backfills all existing servers with correct subdirs
  and README files (idempotent)

Docs: API.md and FRONTEND.md updated for new mod schema and types
Test __init__.py files added for pytest discovery
2026-04-20 10:54:56 +07:00
Tran G. (Revernomad) Khoa
fa95587567 docs: expand README quick start with full dev/debug setup
- Step-by-step backend setup: venv, secret key generation (openssl +
  Fernet), .env configuration with annotated minimum values
- VS Code launch.json snippets for backend (debugpy/uvicorn) and
  frontend (Chrome with source maps)
- Vite proxy detail (/api → :8000, /ws → ws://:8000)
- Backend pytest commands alongside existing frontend test section
- Playwright headed/UI mode instructions for E2E debugging
- Updated unit test count to 173; removed stale E2E count
- CLAUDE.md quick start trimmed to point at README for full setup
2026-04-20 10:53:14 +07:00
Tran G. (Revernomad) Khoa
64b35a7aaf feat: basic/advanced config split with profile section gate
- FieldSchema gains optional `advanced` boolean flag
- ConfigEditor reads schema at top level and passes sectionSchema as prop
- ConfigSectionForm filters out advanced fields by default; "Show advanced"
  toggle reveals them without entering edit mode
- Profile (Difficulty) section shows an inline banner when
  forced_difficulty is not "Custom", guiding users to the right setting
- All 173 frontend tests pass; tsc clean
2026-04-20 10:49:08 +07:00
Tran G. (Revernomad) Khoa
03ea623536 test: add RED test for config schema advanced flags (TDD checkpoint)
Adds test_config_schema.py verifying every visible field has an
explicit 'advanced' bool, basic fields are advanced=False, and
sampled advanced fields are advanced=True.
2026-04-20 10:48:59 +07:00
Tran G. (Revernomad) Khoa
3025c2021c feat: per-mission params, default config values, and mods bug docs
- Add per-mission params to rotation (MissionRotationItem.params); falls
  back to default_mission_params, then omits entirely if both empty
- Add key-value widget to ConfigEditor for default_mission_params field
- Add MissionParamsEditor component for editing param key/value/type rows
- Bump config schema to 1.1.0 with migration from 1.0.0
- Add normalize_section() to Protocol and ArmaConfigGenerator for
  read-time backfill of old stored rows
- Set Arma3 BasicConfig and ProfileConfig defaults from basic.cfg /
  Administrator.Arma3Profile
- Document 3 known Mods tab bugs in CLAUDE.md for next-session fix
2026-04-19 19:28:46 +07:00
Tran G. (Revernomad) Khoa
bf09a6ed1c fix: fix Arma 3 log discovery and improve config editor UX
- Fix logfiles_router and thread_registry to resolve .rpt log files
  from Path(server["exe_path"]).parent/server/ instead of the languard
  data dir, which never contained log files — log list and live tail
  both now work correctly
- Rewrite get_ui_schema() in config_generator to cover all ~80 fields
  across all 5 sections (server/basic/profile/launch/rcon) with proper
  toggle/select/number/password/tag-list/hidden widgets and labels;
  missions field is hidden (managed by Missions tab)
- Add formatSelectDisplay() to ConfigEditor so select fields show
  descriptive text (e.g. "0 - Never") instead of raw numbers in view mode
- Add ToggleDisplay for boolean fields (Enabled/Disabled with indicator dot)
- Add section tab labels and descriptions to ConfigEditor
- Add MissionList UX hints and dynamic Add/In Rotation button labels
- Add "hidden" to FieldSchema widget union type
- Update API.md, ARCHITECTURE.md, CLAUDE.md, FRONTEND.md, MODULES.md,
  THREADING.md to document log path fix and schema coverage
2026-04-18 15:56:04 +07:00
28 changed files with 1347 additions and 227 deletions

61
API.md
View File

@@ -792,10 +792,20 @@ Get all config sections combined. Sensitive fields (passwords) are masked with `
### GET /servers/{server_id}/config/schema ### GET /servers/{server_id}/config/schema
Returns per-field widget hints for the frontend config editor. Used by `ConfigEditor` to render the correct UI widget (text box, toggle, select, tag list, etc.) for each field. Returns per-field widget hints for the frontend config editor. Used by `ConfigEditor` to render the correct UI widget for each field. Covers all ~80 Arma 3 config fields across 5 sections.
**Auth:** Required (any role) **Auth:** Required (any role)
**Widget types:**
- `text` — Text input
- `password` — Password input (masked)
- `number` — Numeric input with optional `min`/`max`
- `toggle` — Boolean toggle (0/1)
- `select` — Dropdown with `options` array. Options may be `["value1", "value2"]` or `["0 - Never", "1 - Always"]` format
- `textarea` — Multi-line text area
- `tag-list` — Dynamic string list (add/remove items)
- `hidden` — Field not displayed in UI (managed elsewhere; e.g., `missions` managed by Missions tab)
**Response 200:** **Response 200:**
```json ```json
@@ -806,10 +816,11 @@ Returns per-field widget hints for the frontend config editor. Used by `ConfigEd
"hostname": { "widget": "text", "label": "Server Hostname" }, "hostname": { "widget": "text", "label": "Server Hostname" },
"max_players": { "widget": "number", "label": "Max Players", "min": 1, "max": 1000 }, "max_players": { "widget": "number", "label": "Max Players", "min": 1, "max": 1000 },
"password": { "widget": "password", "label": "Player Password" }, "password": { "widget": "password", "label": "Player Password" },
"forced_difficulty": { "widget": "select", "label": "Difficulty Preset", "options": ["Recruit", "Regular", "Veteran", "Custom"] }, "forced_difficulty": { "widget": "select", "label": "Difficulty Preset", "options": ["0 - Recruit", "1 - Regular", "2 - Veteran", "3 - Custom"] },
"battleye": { "widget": "toggle", "label": "BattleEye Anti-Cheat" }, "battleye": { "widget": "toggle", "label": "BattleEye Anti-Cheat" },
"motd_lines": { "widget": "textarea", "label": "Message of the Day (one line per row)" }, "motd_lines": { "widget": "textarea", "label": "Message of the Day (one line per row)" },
"admin_uids": { "widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000" } "admin_uids": { "widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000" },
"missions": { "widget": "hidden", "label": "Missions" }
}, },
"rcon": { "rcon": {
"rcon_password": { "widget": "password", "label": "RCon Password" } "rcon_password": { "widget": "password", "label": "RCon Password" }
@@ -1363,18 +1374,30 @@ List all available mods and which are currently enabled for this server.
"mods": [ "mods": [
{ {
"name": "@CBA_A3", "name": "@CBA_A3",
"folder_path": "C:/Arma3Server/@CBA_A3", "path": "D:/Arma3Server/1/mods/@CBA_A3",
"enabled": true "size_bytes": 12345678,
"enabled": true,
"is_server_mod": false,
"display_name": "Community Base Addons A3",
"workshop_id": "450814997"
}, },
{ {
"name": "@ACRE2", "name": "@ACRE2",
"folder_path": "C:/Arma3Server/@ACRE2", "path": "D:/Arma3Server/1/mods/@ACRE2",
"enabled": true "size_bytes": 9876543,
"enabled": true,
"is_server_mod": true,
"display_name": "ACRE2",
"workshop_id": "751965892"
}, },
{ {
"name": "@USAF", "name": "@USAF",
"folder_path": "C:/Arma3Server/@USAF", "path": "D:/Arma3Server/1/mods/@USAF",
"enabled": false "size_bytes": 55000000,
"enabled": false,
"is_server_mod": false,
"display_name": null,
"workshop_id": null
} }
] ]
}, },
@@ -1382,6 +1405,8 @@ List all available mods and which are currently enabled for this server.
} }
``` ```
Mod folders are scanned from `{server_data_dir}/{server_id}/mods/@*`. `display_name` is parsed from `mod.cpp`; `workshop_id` from `meta.cpp`. `is_server_mod: true` means the mod is passed via `-serverMod=` instead of `-mod=`.
--- ---
### PUT /servers/{server_id}/mods/enabled ### PUT /servers/{server_id}/mods/enabled
@@ -1394,13 +1419,18 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
```json ```json
{ {
"mods": ["@CBA_A3", "@ACRE2"] "mods": [
{ "name": "@CBA_A3", "is_server_mod": false },
{ "name": "@ACRE2", "is_server_mod": true }
]
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
|--------|---------------|----------|------------------------------------| |---------------------|---------|----------|-------------|
| `mods` | array[string] | Yes | Complete list of mod names to enable | | `mods` | array | Yes | Complete list of mod entries to enable |
| `mods[].name` | string | Yes | Mod folder name (must start with `@`) |
| `mods[].is_server_mod` | bool | No | `true``-serverMod=`, `false` (default) → `-mod=` |
**Response 200:** **Response 200:**
@@ -1409,7 +1439,10 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
"success": true, "success": true,
"data": { "data": {
"message": "Enabled mods updated. Restart the server for changes to take effect.", "message": "Enabled mods updated. Restart the server for changes to take effect.",
"enabled_mods": ["@CBA_A3", "@ACRE2"] "enabled_mods": [
{ "name": "@CBA_A3", "is_server_mod": false },
{ "name": "@ACRE2", "is_server_mod": true }
]
}, },
"error": null "error": null
} }

View File

@@ -186,7 +186,7 @@ Implements all 7 capabilities:
- **ConfigGenerator**: Defines `server`, `rcon`, `mission` sections using Pydantic models. Writes `server.cfg` using atomic write pattern (`.tmp` then `os.replace()`). Builds launch args from config + mod list. - **ConfigGenerator**: Defines `server`, `rcon`, `mission` sections using Pydantic models. Writes `server.cfg` using atomic write pattern (`.tmp` then `os.replace()`). Builds launch args from config + mod list.
- **ProcessConfig**: Allows `arma3server_x64.exe` and `arma3server.exe`. Derives 4 ports from `game_port` (game, steam_query, von, steam_auth). Default game port 2302, default RCon port game+4. - **ProcessConfig**: Allows `arma3server_x64.exe` and `arma3server.exe`. Derives 4 ports from `game_port` (game, steam_query, von, steam_auth). Default game port 2302, default RCon port game+4.
- **LogParser**: Parses Arma 3 `.rpt` log files. Resolves log path from server config or defaults to `server_dir/server/*.rpt`. - **LogParser**: Parses Arma 3 `.rpt` log files. Resolves log path using `Path(server["exe_path"]).parent / "server"` — Arma 3 writes .rpt files next to its executable, not in the languard server data directory.
- **RemoteAdmin**: Implements BattlEye RCon protocol via `Arma3RemoteAdminFactory`. Supports login, command sending, player listing, kick, ban, say-all, and shutdown. - **RemoteAdmin**: Implements BattlEye RCon protocol via `Arma3RemoteAdminFactory`. Supports login, command sending, player listing, kick, ban, say-all, and shutdown.
- **MissionManager**: Handles `.pbo` mission files and mission rotation config. - **MissionManager**: Handles `.pbo` mission files and mission rotation config.
- **ModManager**: Builds `-mod=` and `-serverMod=` CLI arguments from mod list. - **ModManager**: Builds `-mod=` and `-serverMod=` CLI arguments from mod list.
@@ -781,4 +781,6 @@ frontend/
8. **Atomic config file writes**: Config files are written to temporary `.tmp` files first, then `os.replace()` renames them to the final path. This prevents partial writes on crash. 8. **Atomic config file writes**: Config files are written to temporary `.tmp` files first, then `os.replace()` renames them to the final path. This prevents partial writes on crash.
9. **PID recovery on restart**: `ProcessManager.recover_on_startup()` uses `psutil` to check if a PID from a previous run is still alive and running an allowed executable. This handles the case where the Languard process restarts but game servers are still running. 9. **PID recovery on restart**: `ProcessManager.recover_on_startup()` uses `psutil` to check if a PID from a previous run is still alive and running an allowed executable. This handles the case where the Languard process restarts but game servers are still running.
10. **Game-relative log file discovery**: For Arma 3, log files (.rpt) are written by the game next to its executable (`{exe_path_parent}/server/*.rpt`), not in Languard's data directory. `LogTailThread` resolves the log path from `Path(server["exe_path"]).parent`, not from `get_server_dir(server_id)`. This respects the game's native file layout.

View File

@@ -3,16 +3,17 @@
## Quick Start ## Quick Start
```bash ```bash
# Backend (from backend/) # Backend (from backend/, venv must be active)
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# Frontend (from frontend/) # Frontend (from frontend/)
npx vite --host npm run dev
``` ```
- Backend API: http://localhost:8000 (docs: http://localhost:8000/docs) - Backend API: http://localhost:8000 (docs: http://localhost:8000/docs)
- Frontend: http://localhost:5173 - Frontend: http://localhost:5173 (Vite proxies /api and /ws to :8000)
- Default admin: `admin` / (random, printed at first startup; reset via `python -c "from core.auth.utils import hash_password; print(hash_password('admin123'))"` then update SQLite) - Default admin: `admin` / (random, printed at first startup)
- Full setup instructions (secrets, venv, debug configs): see README.md
## Architecture ## Architecture
@@ -89,4 +90,18 @@ cd frontend && npx tsc --noEmit
- `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service - `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service
- `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries - `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries
- Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`) - Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`)
- Log directory defaults to `ARMA3_LOG_DIR` env var, falls back to `{server_dir}/logs` - **Arma 3 log files** are located at `{exe_path_parent}/server/*.rpt` (next to the .exe), NOT in languard's `servers/{id}/` data directory. Code that finds log files must use `Path(server["exe_path"]).parent` to resolve the log directory.
- Config UI schema now covers all ~80 Arma 3 fields across 5 sections (server, basic, profile, launch, rcon) with per-field widget hints (text, toggle, select, number, password, tag-list, hidden, textarea, key-value). The `missions` field in the server section is marked `hidden` because mission rotation is managed via the dedicated Missions tab.
- **Arma 3 per-mission params**: `ServerConfig.missions` is now `list[MissionRotationItem]` (adds optional `params: dict`). A new `default_mission_params` field holds server-wide defaults. Config version bumped to `"1.1.0"`. `_render_server_cfg()` now emits a `class Missions { ... }` block when the rotation is non-empty; `class Params` inside each mission uses per-mission params → global defaults → omit (in that priority order). The `MissionRotationEntry.params` is edited per-row in the Missions tab via `MissionParamsEditor`; `default_mission_params` is edited in the Config tab via the `key-value` widget.
- **Config version migration**: `migrate_config("1.0.0", ...)` backfills `params: {}` on each existing rotation entry and adds `default_mission_params: {}`. `normalize_section()` does the same on reads for stored rows that pre-date the migration run.
## Mods Tab — Implementation Notes
- Mods go in `{server_data_dir}/{server_id}/mods/@ModName` (e.g. `D:/ImContainer/Arma3Server/1/mods/@CBA_A3/`)
- Enabled mods config schema: `{"enabled_mods": [{"name": "@CBA_A3", "is_server_mod": false}]}`
- Old string-list format is auto-migrated to the dict format on read
- `is_server_mod: true``-serverMod=` arg; `false``-mod=` arg
- `list_available_mods()` scans `{server_dir}/mods/` for `@*` directories
- `set_enabled_mods()` stores the new dict format; validates names against disk
- Server start reads mods from `game_configs` via `config_repo`, NOT from the dead `server_mods` table
- Directory scaffold: all 4 Arma3 subdirs (`server/`, `battleye/`, `mpmissions/`, `mods/`) are created on server create and backfilled on startup; each gets a `README.txt` if not already present

View File

@@ -175,7 +175,7 @@ All server data flows through TanStack Query hooks:
|---|---|---|---| |---|---|---|---|
| `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` | | `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` |
| `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` | | `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` |
| `useServerConfigSchema(id)` | Query | `GET /api/servers/:id/config/schema` | `["servers", id, "config", "schema"]` | | `useServerConfigSchema(id)` | Query | `GET /api/servers/:id/config/schema` (per-field widget hints for ~80 fields: text, toggle, select, number, password, tag-list, hidden, textarea) | `["servers", id, "config", "schema"]` |
| `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` | | `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` |
| `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` | | `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` |
| `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` | | `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` |
@@ -189,7 +189,7 @@ All server data flows through TanStack Query hooks:
| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` | | `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` |
| `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config | | `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config |
| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` | | `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` |
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` | Invalidates `["mods", id]` | | `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` body: `EnabledModEntry[]` | Invalidates `["mods", id]` |
| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation | | `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation |
| `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` | | `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` |
| `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans | | `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans |
@@ -219,7 +219,8 @@ All server data flows through TanStack Query hooks:
**Key type notes**: **Key type notes**:
- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response) - `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response)
- `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename - `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename
- `Mod` type: `{ name, path, size_bytes, enabled, display_name, workshop_id }``display_name`/`workshop_id` from mod.cpp/meta.cpp - `Mod` type: `{ name, path, size_bytes, enabled, is_server_mod, display_name, workshop_id }``display_name`/`workshop_id` from mod.cpp/meta.cpp; `is_server_mod` controls `-serverMod=` vs `-mod=`
- `EnabledModEntry` type: `{ name: string, is_server_mod: boolean }` — used as `useSetEnabledMods` mutation input
- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API) - `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API)
- There is no REST endpoint for logs — logs are only pushed via WebSocket events - There is no REST endpoint for logs — logs are only pushed via WebSocket events

View File

@@ -49,9 +49,9 @@ All 7 capabilities implemented:
| Module | Class | Purpose | | Module | Class | Purpose |
|---|---|---| |---|---|---|
| `adapter.py` | `Arma3Adapter` | Composite adapter declaring all capabilities | | `adapter.py` | `Arma3Adapter` | Composite adapter declaring all capabilities |
| `config_generator.py` | `Arma3ConfigGenerator` | 5 Pydantic config models, writes server.cfg/basic.cfg/Arma3Profile/beserver.cfg, builds launch args, `get_ui_schema()` for per-field widget hints | | `config_generator.py` | `Arma3ConfigGenerator` | 5 Pydantic config models, writes server.cfg/basic.cfg/Arma3Profile/beserver.cfg, builds launch args, `get_ui_schema()` returns per-field widget hints for all ~80 fields across 5 sections (text, toggle, select, number, password, tag-list, hidden, textarea); `missions` field marked hidden |
| `process_config.py` | `Arma3ProcessConfig` | Allowed executables, port conventions (game+1/+2/+3), directory layout | | `process_config.py` | `Arma3ProcessConfig` | Allowed executables, port conventions (game+1/+2/+3), directory layout |
| `log_parser.py` | `RPTParser` | Regex-based .rpt log parser, log file resolver, `list_log_files()`, `get_log_file_path()` | | `log_parser.py` | `RPTParser` | Regex-based .rpt log parser, resolves log path using `Path(server["exe_path"]).parent / "server"` (not languard data dir), `list_log_files()`, `get_log_file_path()` |
| `rcon_client.py` | `BERConClient` | BattlEye RCon v2 UDP protocol implementation | | `rcon_client.py` | `BERConClient` | BattlEye RCon v2 UDP protocol implementation |
| `remote_admin.py` | `Arma3RemoteAdmin` + `Arma3RemoteAdminFactory` | Implements RemoteAdmin protocol using BERConClient | | `remote_admin.py` | `Arma3RemoteAdmin` + `Arma3RemoteAdminFactory` | Implements RemoteAdmin protocol using BERConClient |
| `mission_manager.py` | `Arma3MissionManager` | .pbo upload, delete, list, rotation config generation | | `mission_manager.py` | `Arma3MissionManager` | .pbo upload, delete, list, rotation config generation |
@@ -73,7 +73,7 @@ All 7 capabilities implemented:
|---|---| |---|---|
| `router.py` | Server CRUD, lifecycle (start/stop/restart/kill), config read/write/preview, RCon command | | `router.py` | Server CRUD, lifecycle (start/stop/restart/kill), config read/write/preview, RCon command |
| `players_router.py` | Player list, player history, kick/ban by slot_id | | `players_router.py` | Player list, player history, kick/ban by slot_id |
| `logfiles_router.py` | List, download, and delete historical `.rpt` log files | | `logfiles_router.py` | List, download, and delete historical `.rpt` log files from `Path(server["exe_path"]).parent / "server"` (not languard data dir) |
| `bans_router.py` | Ban CRUD with bans.txt file sync | | `bans_router.py` | Ban CRUD with bans.txt file sync |
| `missions_router.py` | Mission list, .pbo upload (500MB), delete, GET/PUT rotation | | `missions_router.py` | Mission list, .pbo upload (500MB), delete, GET/PUT rotation |
| `mods_router.py` | List mods, set enabled mods | | `mods_router.py` | List mods, set enabled mods |
@@ -107,7 +107,7 @@ All 7 capabilities implemented:
|---|---| |---|---|
| `base_thread.py` | `BaseServerThread` — abstract base with stop event, thread-local DB, exception backoff | | `base_thread.py` | `BaseServerThread` — abstract base with stop event, thread-local DB, exception backoff |
| `thread_registry.py` | `ThreadRegistry` — manages per-server thread bundles, start/stop/reattach; `get_rcon_client(server_id)` class method exposes live RCon client | | `thread_registry.py` | `ThreadRegistry` — manages per-server thread bundles, start/stop/reattach; `get_rcon_client(server_id)` class method exposes live RCon client |
| `log_tail.py` | `LogTailThread` — tails log files, parses lines, persists to DB, broadcasts | | `log_tail.py` | `LogTailThread` resolves log path from `Path(server["exe_path"]).parent`, tails .rpt files, parses lines, persists to DB, broadcasts |
| `process_monitor.py` | `ProcessMonitorThread` — detects crashes, triggers auto-restart | | `process_monitor.py` | `ProcessMonitorThread` — detects crashes, triggers auto-restart |
| `metrics_collector.py` | `MetricsCollectorThread` — psutil CPU/RAM collection every 10s | | `metrics_collector.py` | `MetricsCollectorThread` — psutil CPU/RAM collection every 10s |
| `remote_admin_poller.py` | `RemoteAdminPollerThread` — polls player list via RCon, syncs join/leave events | | `remote_admin_poller.py` | `RemoteAdminPollerThread` — polls player list via RCon, syncs join/leave events |

125
README.md
View File

@@ -19,25 +19,78 @@ A multi-game server management platform with a Python/FastAPI backend and React/
- **TanStack Query v5** — server state management - **TanStack Query v5** — server state management
- **Zustand 5** — client state (auth, UI) - **Zustand 5** — client state (auth, UI)
- **Tailwind CSS** — dark neumorphic design system - **Tailwind CSS** — dark neumorphic design system
- **Playwright** — E2E testing (38 tests) - **Playwright** — E2E testing
- **Vitest** + **React Testing Library** — unit tests (167 tests) - **Vitest** + **React Testing Library** — unit tests (173 tests)
## Quick Start ## Quick Start
### Backend ### 1 — Backend setup
```bash ```bash
cd backend cd backend
# Create and activate a virtual environment
python -m venv venv python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate source venv/bin/activate # macOS / Linux
# venv\Scripts\activate # Windows (cmd)
# venv\Scripts\Activate.ps1 # Windows (PowerShell)
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env # Edit with your settings
uvicorn main:app --reload
``` ```
First run prints a generated admin password. Change it immediately via `PUT /api/auth/password`. **Generate required secrets** (one-time):
### Frontend ```bash
# Secret key (JWT signing)
openssl rand -hex 32
# Fernet encryption key (sensitive config fields at rest)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Copy `.env.example` to `.env` and fill in the two keys:
```bash
cp .env.example .env # then open .env in your editor
```
```ini
# .env — minimum required values
LANGUARD_SECRET_KEY=<output of openssl command>
LANGUARD_ENCRYPTION_KEY=<output of Fernet command>
LANGUARD_ARMA3_DEFAULT_EXE=C:/path/to/arma3server_x64.exe
```
**Start the backend** (development — auto-reload on file changes):
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
First run prints a randomly-generated admin password to the console. Log in and change it immediately via Settings → Change Password (or `PUT /api/auth/password`).
- API root: `http://localhost:8000`
- Interactive docs: `http://localhost:8000/docs`
**Debug in VS Code:** add this `launch.json` configuration:
```json
{
"name": "Backend — uvicorn",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": ["main:app", "--host", "0.0.0.0", "--port", "8000"],
"cwd": "${workspaceFolder}/backend",
"env": { "PYTHONDONTWRITEBYTECODE": "1" },
"jinja": true,
"justMyCode": false
}
```
---
### 2 — Frontend setup
```bash ```bash
cd frontend cd frontend
@@ -45,28 +98,64 @@ npm install
npm run dev npm run dev
``` ```
Opens at `http://localhost:5173`. The dev server proxies `/api` to the backend on port 8000. The Vite dev server starts at `http://localhost:5173` and automatically proxies:
- `/api/*``http://localhost:8000` (REST)
- `/ws/*``ws://localhost:8000` (WebSocket)
**Debug in VS Code:** install the [JavaScript Debugger](https://marketplace.visualstudio.com/items?itemName=ms-vscode.js-debug) extension (bundled by default), then add:
```json
{
"name": "Frontend — Vite (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/src",
"sourceMapPathOverrides": {
"/@fs/*": "${workspaceFolder}/frontend/*"
}
}
```
Start the Vite dev server first (`npm run dev`), then launch this config to attach Chrome DevTools with source-map support.
---
## Running Tests ## Running Tests
### Frontend Unit Tests ### Backend
```bash
cd backend
source venv/bin/activate # (if not already active)
pytest # all tests
pytest tests/adapters/arma3/ -v # adapter tests only
pytest --tb=short -q # quiet output
```
### Frontend unit tests
```bash ```bash
cd frontend cd frontend
npm test # Watch mode npm test # single run (CI-friendly)
npx vitest run # Single run npm run test:watch # watch mode during development
npx vitest run --coverage # With coverage npx vitest run --coverage # with coverage report
``` ```
### Frontend E2E Tests ### Frontend E2E tests (Playwright)
Start the backend and the Vite dev server first, then:
```bash ```bash
cd frontend cd frontend
# Start backend + frontend dev server first npm run test:e2e # all E2E tests (headless)
npx playwright test # All tests (mocked + integration) npm run test:e2e:ui # Playwright UI mode (interactive, great for debugging)
npx playwright tests-e2e/integration/ # Full-stack integration tests only npx playwright test --headed # watch tests run in an actual browser
``` ```
Integration tests (require a live backend) live in `tests-e2e/integration/`. All other tests use API mocks and run without a backend.
## Project Structure ## Project Structure
``` ```
@@ -101,7 +190,7 @@ languard-servers-manager/
│ │ ├── hooks/ # useServers, useServerDetail, useAuth, useGames, useWebSocket │ │ ├── hooks/ # useServers, useServerDetail, useAuth, useGames, useWebSocket
│ │ ├── store/ # auth.store, ui.store (Zustand) │ │ ├── store/ # auth.store, ui.store (Zustand)
│ │ ├── lib/ # api.ts (Axios client) │ │ ├── lib/ # api.ts (Axios client)
│ │ └── __tests__/ # Vitest unit tests (~120 tests) │ │ └── __tests__/ # Vitest unit tests (173 tests)
│ ├── tests-e2e/ # Playwright E2E tests │ ├── tests-e2e/ # Playwright E2E tests
│ └── playwright.config.ts │ └── playwright.config.ts
├── API.md # REST + WebSocket API reference ├── API.md # REST + WebSocket API reference

View File

@@ -87,7 +87,7 @@ Events are broadcast to WebSocket clients subscribed to the relevant `server_id`
Tails the Arma 3 .rpt log file for each server: Tails the Arma 3 .rpt log file for each server:
- Resolves the latest log file path using the adapter's `LogParser.get_latest_log_file()` - Resolves the latest log file path using `Path(server["exe_path"]).parent / "server"` — Arma 3 writes .rpt files next to its executable, not in the languard server data directory
- Reads new lines from the end of the file, detecting log rotation (Windows/NTFS safe) - Reads new lines from the end of the file, detecting log rotation (Windows/NTFS safe)
- Parses each line using `RPTParser.parse_line()` to extract timestamp, level, and message - Parses each line using `RPTParser.parse_line()` to extract timestamp, level, and message
- Persists parsed entries to the `logs` table via `LogRepository` - Persists parsed entries to the `logs` table via `LogRepository`

View File

@@ -6,13 +6,21 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
MissionParamValue = Union[int, float, str, bool]
# ─── Pydantic Models (config schema) ───────────────────────────────────────── # ─── Pydantic Models (config schema) ─────────────────────────────────────────
class MissionRotationItem(BaseModel):
name: str
difficulty: str = ""
params: dict[str, MissionParamValue] = Field(default_factory=dict)
class ServerConfig(BaseModel): class ServerConfig(BaseModel):
hostname: str = "My Arma 3 Server" hostname: str = "My Arma 3 Server"
password: str | None = None password: str | None = None
@@ -57,17 +65,18 @@ class ServerConfig(BaseModel):
headless_clients: list[str] = Field(default_factory=list) headless_clients: list[str] = Field(default_factory=list)
local_clients: list[str] = Field(default_factory=list) local_clients: list[str] = Field(default_factory=list)
admin_uids: list[str] = Field(default_factory=list) admin_uids: list[str] = Field(default_factory=list)
missions: list[dict] = Field(default_factory=list) missions: list[MissionRotationItem] = Field(default_factory=list)
default_mission_params: dict[str, MissionParamValue] = Field(default_factory=dict)
class BasicConfig(BaseModel): class BasicConfig(BaseModel):
min_bandwidth: int = Field(default=800000, gt=0) min_bandwidth: int = Field(default=131072, gt=0)
max_bandwidth: int = Field(default=25000000, gt=0) max_bandwidth: int = Field(default=10000000000, gt=0)
max_msg_send: int = Field(default=384, gt=0) max_msg_send: int = Field(default=128, gt=0)
max_size_guaranteed: int = Field(default=512, gt=0) max_size_guaranteed: int = Field(default=512, gt=0)
max_size_non_guaranteed: int = Field(default=256, gt=0) max_size_non_guaranteed: int = Field(default=256, gt=0)
min_error_to_send: float = Field(default=0.003, gt=0) min_error_to_send: float = Field(default=0.001, gt=0)
max_custom_file_size: int = Field(default=100000, ge=0) max_custom_file_size: int = Field(default=0, ge=0)
class ProfileConfig(BaseModel): class ProfileConfig(BaseModel):
@@ -77,16 +86,16 @@ class ProfileConfig(BaseModel):
enemy_tags: int = Field(default=0, ge=0, le=3) enemy_tags: int = Field(default=0, ge=0, le=3)
detected_mines: int = Field(default=0, ge=0, le=3) detected_mines: int = Field(default=0, ge=0, le=3)
commands: int = Field(default=1, ge=0, le=3) commands: int = Field(default=1, ge=0, le=3)
waypoints: int = Field(default=1, ge=0, le=3) waypoints: int = Field(default=0, ge=0, le=3)
tactical_ping: int = Field(default=0, ge=0, le=1) tactical_ping: int = Field(default=0, ge=0, le=1)
weapon_info: int = Field(default=2, ge=0, le=3) weapon_info: int = Field(default=2, ge=0, le=3)
stance_indicator: int = Field(default=2, ge=0, le=3) stance_indicator: int = Field(default=2, ge=0, le=3)
stamina_bar: int = Field(default=0, ge=0, le=1) stamina_bar: int = Field(default=2, ge=0, le=2)
weapon_crosshair: int = Field(default=0, ge=0, le=1) weapon_crosshair: int = Field(default=0, ge=0, le=1)
vision_aid: int = Field(default=0, ge=0, le=1) vision_aid: int = Field(default=0, ge=0, le=1)
third_person_view: int = Field(default=0, ge=0, le=1) third_person_view: int = Field(default=0, ge=0, le=1)
camera_shake: int = Field(default=1, ge=0, le=1) camera_shake: int = Field(default=1, ge=0, le=1)
score_table: int = Field(default=1, ge=0, le=1) score_table: int = Field(default=0, ge=0, le=1)
death_messages: int = Field(default=1, ge=0, le=1) death_messages: int = Field(default=1, ge=0, le=1)
von_id: int = Field(default=1, ge=0, le=1) von_id: int = Field(default=1, ge=0, le=1)
map_content_friendly: int = Field(default=0, ge=0, le=3) map_content_friendly: int = Field(default=0, ge=0, le=3)
@@ -95,8 +104,8 @@ class ProfileConfig(BaseModel):
auto_report: int = Field(default=0, ge=0, le=1) auto_report: int = Field(default=0, ge=0, le=1)
multiple_saves: int = Field(default=0, ge=0, le=1) multiple_saves: int = Field(default=0, ge=0, le=1)
ai_level_preset: int = Field(default=3, ge=0, le=4) ai_level_preset: int = Field(default=3, ge=0, le=4)
skill_ai: float = Field(default=0.5, ge=0.0, le=1.0) skill_ai: float = Field(default=1.0, ge=0.0, le=1.0)
precision_ai: float = Field(default=0.5, ge=0.0, le=1.0) precision_ai: float = Field(default=0.2, ge=0.0, le=1.0)
class LaunchConfig(BaseModel): class LaunchConfig(BaseModel):
@@ -151,20 +160,64 @@ class Arma3ConfigGenerator:
return self.SENSITIVE_FIELDS.get(section, []) return self.SENSITIVE_FIELDS.get(section, [])
def get_config_version(self) -> str: def get_config_version(self) -> str:
return "1.0.0" return "1.1.0"
def migrate_config(self, old_version: str, config_json: dict) -> dict: def migrate_config(self, old_version: str, config_json: dict) -> dict:
"""
For version 1.0.0 there is nothing to migrate.
Future versions: add migration logic here.
"""
from adapters.exceptions import ConfigMigrationError from adapters.exceptions import ConfigMigrationError
if old_version == "1.0.0":
server = config_json.get("server", {})
for m in server.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
server.setdefault("default_mission_params", {})
return config_json
raise ConfigMigrationError( raise ConfigMigrationError(
old_version, f"No migration path from {old_version} to {self.get_config_version()}" old_version, f"No migration path from {old_version} to {self.get_config_version()}"
) )
def normalize_section(self, section: str, data: dict) -> dict:
"""Backfill new optional fields on server section for pre-1.1.0 stored data."""
if section == "server":
for m in data.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
data.setdefault("default_mission_params", {})
return data
# ── Config file writers ─────────────────────────────────────────────────── # ── Config file writers ───────────────────────────────────────────────────
def _render_param_value(self, val: MissionParamValue) -> str:
if isinstance(val, bool):
return "1" if val else "0"
if isinstance(val, (int, float)):
return str(val)
return f'"{self._escape(str(val))}"'
def _render_missions_block(self, cfg: ServerConfig) -> str:
"""Render the class Missions { ... } block for server.cfg.
Per-mission params take priority; falls back to default_mission_params;
if both are empty the class Params block is omitted entirely.
"""
if not cfg.missions:
return ""
lines = ["class Missions {"]
for idx, entry in enumerate(cfg.missions):
effective = entry.params if entry.params else cfg.default_mission_params
lines.append(f" class Mission_{idx} {{")
lines.append(f' template = "{self._escape(entry.name)}";')
if entry.difficulty:
lines.append(f' difficulty = "{self._escape(entry.difficulty)}";')
if effective:
lines.append(" class Params {")
for key, val in effective.items():
lines.append(f" {key} = {self._render_param_value(val)};")
lines.append(" };")
lines.append(" };")
lines.append("};")
return "\n".join(lines) + "\n"
@staticmethod @staticmethod
def _escape(value: str) -> str: def _escape(value: str) -> str:
""" """
@@ -255,7 +308,7 @@ class Arma3ConfigGenerator:
if cfg.admin_uids: if cfg.admin_uids:
lines.append(f"admins[] = {{{admin_uids}}};") lines.append(f"admins[] = {{{admin_uids}}};")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n" + self._render_missions_block(cfg)
def _render_basic_cfg(self, cfg: BasicConfig) -> str: def _render_basic_cfg(self, cfg: BasicConfig) -> str:
return ( return (
@@ -348,20 +401,36 @@ class Arma3ConfigGenerator:
self, self,
config_sections: dict[str, dict], config_sections: dict[str, dict],
mod_args: list[str] | None = None, mod_args: list[str] | None = None,
server_dir: Path | None = None,
) -> list[str]: ) -> list[str]:
from adapters.exceptions import LaunchArgsError from adapters.exceptions import LaunchArgsError
launch = LaunchConfig(**config_sections.get("launch", {})) launch = LaunchConfig(**config_sections.get("launch", {}))
server = ServerConfig(**config_sections.get("server", {})) server = ServerConfig(**config_sections.get("server", {}))
# Arma 3 changes its own cwd to the exe directory at startup, so relative
# paths in launch args resolve against the exe dir, not server_dir.
# Use absolute paths when server_dir is provided so configs are always found.
if server_dir is not None:
d = Path(server_dir)
config_arg = f"-config={d / 'server.cfg'}"
cfg_arg = f"-cfg={d / 'basic.cfg'}"
profiles_arg = f"-profiles={d / 'server'}"
bepath_arg = f"-bepath={d / 'battleye'}"
else:
config_arg = "-config=server.cfg"
cfg_arg = "-cfg=basic.cfg"
profiles_arg = "-profiles=./server"
bepath_arg = "-bepath=./battleye"
args = [ args = [
f"-port={config_sections.get('_port', 2302)}", f"-port={config_sections.get('_port', 2302)}",
"-config=server.cfg", config_arg,
"-cfg=basic.cfg", cfg_arg,
"-profiles=./server", profiles_arg,
"-name=server", "-name=server",
f"-world={launch.world}", f"-world={launch.world}",
f"-limitFPS={launch.limit_fps}", f"-limitFPS={launch.limit_fps}",
"-bepath=./battleye", bepath_arg,
] ]
if launch.auto_init: if launch.auto_init:
args.append("-autoInit") args.append("-autoInit")
@@ -384,33 +453,148 @@ class Arma3ConfigGenerator:
return args return args
def get_ui_schema(self) -> dict: def get_ui_schema(self) -> dict:
B, A = False, True # basic / advanced shorthand
return { return {
"server": { "server": {
"hostname": {"widget": "text", "label": "Server Hostname"}, # Identity — basic
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000}, "hostname": {"widget": "text", "label": "Server Name", "advanced": B},
"password": {"widget": "password", "label": "Player Password"}, "max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000, "advanced": B},
"password_admin": {"widget": "password", "label": "Admin Password"}, "password": {"widget": "password", "label": "Join Password", "advanced": B},
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"}, "password_admin": {"widget": "password", "label": "Admin Password", "advanced": B},
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset", "server_command_password": {"widget": "password", "label": "Server Command Password", "advanced": A},
"options": ["Recruit", "Regular", "Veteran", "Custom"]}, # Message of the Day — basic
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, "motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)", "advanced": B},
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, "motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1, "advanced": B},
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)", # Mission / Rotation — basic
"min": 0, "max": 2}, "forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"}, "options": ["Recruit", "Regular", "Veteran", "Custom"], "advanced": B},
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs", "auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission", "advanced": B},
"placeholder": "76561198000000000"}, "random_mission_order": {"widget": "toggle", "label": "Random Mission Order", "advanced": B},
# Behaviour — mixed
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)", "advanced": B},
"kick_duplicate": {"widget": "toggle", "label": "Kick Duplicate Connections", "advanced": A},
"skip_lobby": {"widget": "toggle", "label": "Skip Lobby (go straight to briefing)", "advanced": B},
"drawing_in_map": {"widget": "toggle", "label": "Allow Drawing in Map", "advanced": B},
# Security — basic
"battleye": {"widget": "toggle", "label": "BattlEye Anti-Cheat", "advanced": B},
"verify_signatures": {"widget": "select", "label": "Verify Addon Signatures",
"options": ["0 - Off", "1 - Kick unsigned", "2 - Strict (kick mismatched)"], "advanced": B},
"allowed_file_patching": {"widget": "select", "label": "Allow File Patching",
"options": ["0 - Nobody", "1 - Lobby only", "2 - Everyone"], "advanced": B},
# Voice — basic
"disable_von": {"widget": "toggle", "label": "Disable Voice-over-Network (VoN)", "advanced": B},
"von_codec": {"widget": "toggle", "label": "Use Opus VoN Codec", "advanced": B},
"von_codec_quality": {"widget": "number", "label": "VoN Codec Quality (030)", "min": 0, "max": 30, "advanced": A},
# Network / Kick thresholds — advanced
"kick_on_ping": {"widget": "toggle", "label": "Kick on High Ping", "advanced": A},
"kick_on_packet_loss": {"widget": "toggle", "label": "Kick on High Packet Loss", "advanced": A},
"kick_on_desync": {"widget": "toggle", "label": "Kick on High Desync", "advanced": A},
"kick_on_timeout": {"widget": "toggle", "label": "Kick on Timeout", "advanced": A},
"max_ping": {"widget": "number", "label": "Max Ping (ms)", "min": 1, "advanced": A},
"max_packet_loss": {"widget": "number", "label": "Max Packet Loss (%)", "min": 0, "max": 100, "advanced": A},
"max_desync": {"widget": "number", "label": "Max Desync", "min": 0, "advanced": A},
"disconnect_timeout": {"widget": "number", "label": "Disconnect Timeout (sec)", "min": 0, "advanced": A},
# Voting — advanced
"vote_threshold": {"widget": "number", "label": "Vote Threshold (0.01.0)", "min": 0, "max": 1, "advanced": A},
"vote_mission_players": {"widget": "number", "label": "Min Players to Start Vote", "min": 0, "advanced": A},
"vote_timeout": {"widget": "number", "label": "Vote Timeout (sec)", "min": 0, "advanced": A},
# Timeouts — advanced
"role_timeout": {"widget": "number", "label": "Role Selection Timeout (sec)", "min": 0, "advanced": A},
"briefing_timeout": {"widget": "number", "label": "Briefing Timeout (sec)", "min": 0, "advanced": A},
"debriefing_timeout": {"widget": "number", "label": "Debriefing Timeout (sec)", "min": 0, "advanced": A},
"lobby_idle_timeout": {"widget": "number", "label": "Lobby Idle Timeout (sec)", "min": 0, "advanced": A},
# Misc — advanced
"statistics_enabled": {"widget": "toggle", "label": "Enable Steam Statistics", "advanced": A},
"upnp": {"widget": "toggle", "label": "Enable UPnP", "advanced": A},
"loopback": {"widget": "toggle", "label": "Loopback Mode (LAN only)", "advanced": A},
"timestamp_format": {"widget": "select", "label": "Log Timestamp Format",
"options": ["none", "short", "full"], "advanced": A},
"log_file": {"widget": "text", "label": "Log File Name", "advanced": A},
# Admin / Headless — advanced
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
"placeholder": "76561198000000000", "advanced": A},
"headless_clients": {"widget": "tag-list", "label": "Headless Client IPs",
"placeholder": "127.0.0.1", "advanced": A},
"local_clients": {"widget": "tag-list", "label": "Local Client IPs",
"placeholder": "127.0.0.1", "advanced": A},
# missions managed by the Missions tab — hidden here
"missions": {"widget": "hidden"},
# default params — advanced
"default_mission_params": {"widget": "key-value", "label": "Default Mission Parameters",
"help": "Applied to all missions without custom params.", "advanced": A},
}, },
"basic": { "basic": {
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"}, # All network tuning fields are advanced
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1, "advanced": A},
"max_bandwidth": {"widget": "number", "label": "Max Bandwidth (bps)", "min": 1, "advanced": A},
"max_msg_send": {"widget": "number", "label": "Max Messages Sent per Frame", "min": 1, "advanced": A},
"max_size_guaranteed": {"widget": "number", "label": "Max Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
"max_size_non_guaranteed": {"widget": "number", "label": "Max Non-Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
"min_error_to_send": {"widget": "number", "label": "Min Error to Send", "advanced": A},
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)", "min": 0, "advanced": A},
},
"profile": {
# Basic difficulty options
"reduced_damage": {"widget": "toggle", "label": "Reduced Damage", "advanced": A},
"group_indicators": {"widget": "select", "label": "Group Indicators",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"friendly_tags": {"widget": "select", "label": "Friendly Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"enemy_tags": {"widget": "select", "label": "Enemy Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"detected_mines": {"widget": "select", "label": "Detected Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"commands": {"widget": "select", "label": "Map Commands",
"options": ["0 - Never", "1 - High command", "2 - Fade out", "3 - Always"], "advanced": B},
"waypoints": {"widget": "select", "label": "Waypoints",
"options": ["0 - Never", "1 - Known positions", "2 - Fade out", "3 - Always"], "advanced": B},
"tactical_ping": {"widget": "toggle", "label": "Tactical Ping", "advanced": A},
"weapon_info": {"widget": "select", "label": "Weapon Info",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"], "advanced": B},
"stamina_bar": {"widget": "select", "label": "Stamina Bar",
"options": ["0 - Never", "1 - Low stamina only", "2 - Always"], "advanced": A},
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair", "advanced": A},
"vision_aid": {"widget": "toggle", "label": "Vision Aid", "advanced": A},
"third_person_view": {"widget": "toggle", "label": "Third Person View", "advanced": A},
"camera_shake": {"widget": "toggle", "label": "Camera Shake", "advanced": A},
"score_table": {"widget": "toggle", "label": "Show Score Table", "advanced": A},
"death_messages": {"widget": "toggle", "label": "Death Messages", "advanced": A},
"von_id": {"widget": "toggle", "label": "Show VoN Speaker ID", "advanced": A},
"map_content_friendly": {"widget": "select", "label": "Map — Friendly Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"map_content_enemy": {"widget": "select", "label": "Map — Enemy Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"map_content_mines": {"widget": "select", "label": "Map — Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"auto_report": {"widget": "toggle", "label": "Auto Report (automatic radio reports)", "advanced": A},
"multiple_saves": {"widget": "toggle", "label": "Multiple Saves", "advanced": A},
"ai_level_preset": {"widget": "select", "label": "AI Level Preset",
"options": ["0 - Low", "1 - Normal", "2 - High", "3 - Custom", "4 - Ultra"], "advanced": B},
"skill_ai": {"widget": "number", "label": "AI Skill (0.01.0)", "min": 0, "max": 1, "advanced": B},
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.01.0)", "min": 0, "max": 1, "advanced": B},
}, },
"launch": { "launch": {
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters", # All launch/startup fields are advanced
"placeholder": "-limitFPS=100"}, "world": {"widget": "text", "label": "Default World (map name)", "advanced": A},
"limit_fps": {"widget": "number", "label": "FPS Limit", "min": 1, "max": 1000, "advanced": A},
"cpu_count": {"widget": "number", "label": "CPU Core Count (0 = auto)", "min": 0, "advanced": A},
"ex_threads": {"widget": "number", "label": "Extra Thread Count", "min": 0, "advanced": A},
"max_mem": {"widget": "number", "label": "Max RAM (MB, 0 = auto)", "min": 0, "advanced": A},
"auto_init": {"widget": "toggle", "label": "Auto-Init (skip mission select)", "advanced": A},
"load_mission_to_memory": {"widget": "toggle", "label": "Load Mission to Memory", "advanced": A},
"enable_ht": {"widget": "toggle", "label": "Enable HyperThreading", "advanced": A},
"huge_pages": {"widget": "toggle", "label": "Enable Huge Pages (performance)", "advanced": A},
"no_logs": {"widget": "toggle", "label": "Disable Server Logging", "advanced": A},
"netlog": {"widget": "toggle", "label": "Enable Network Log", "advanced": A},
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
"placeholder": "-filePatching", "advanced": A},
}, },
"rcon": { "rcon": {
"rcon_password": {"widget": "password", "label": "RCon Password"}, "rcon_password": {"widget": "password", "label": "RCon Password", "advanced": B},
"max_ping": {"widget": "number", "label": "RCon Port"}, "max_ping": {"widget": "number", "label": "Max Ping for RCon (ms)", "min": 1, "advanced": A},
"enabled": {"widget": "toggle", "label": "Enable RCon", "advanced": B},
}, },
} }

View File

@@ -47,24 +47,27 @@ class Arma3ModManager:
def _server_dir(self) -> Path: def _server_dir(self) -> Path:
return get_server_dir(self._server_id) return get_server_dir(self._server_id)
def _mods_dir(self) -> Path:
return get_server_dir(self._server_id) / "mods"
# ── File / DB operations ── # ── File / DB operations ──
def list_available_mods(self) -> list[dict]: def list_available_mods(self) -> list[dict]:
""" """
Scan the server directory for mod folders (directories starting with '@'). Scan the server's mods/ subdirectory for mod folders (directories starting with '@').
Returns list of dicts: Returns list of dicts:
name: str — directory name (e.g. "@CBA_A3") name: str — directory name (e.g. "@CBA_A3")
path: str — absolute directory path path: str — absolute directory path
size_bytes: int — total directory size (approximate, non-recursive) size_bytes: int — total directory size (approximate, non-recursive)
""" """
server_dir = self._server_dir() mods_dir = self._mods_dir()
if not server_dir.exists(): if not mods_dir.exists():
return [] return []
mods = [] mods = []
try: try:
for entry in server_dir.iterdir(): for entry in mods_dir.iterdir():
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name): if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
try: try:
size = sum( size = sum(
@@ -87,54 +90,59 @@ class Arma3ModManager:
mods.sort(key=lambda m: m["name"].lower()) mods.sort(key=lambda m: m["name"].lower())
return mods return mods
def get_enabled_mods(self, config_repo) -> list[str]: def get_enabled_mods(self, config_repo) -> list[dict]:
""" """
Get the list of enabled mod names from the database config. Get the list of enabled mods from the database config.
Args: Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...]
config_repo: ConfigRepository instance. Handles migration from old string-list format automatically.
Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]).
""" """
mods_section = config_repo.get_section(self._server_id, "mods") mods_section = config_repo.get_section(self._server_id, "mods")
if mods_section is None: if mods_section is None:
return [] return []
enabled = mods_section.get("enabled_mods", []) raw = mods_section.get("enabled_mods", [])
if isinstance(enabled, str): result = []
enabled = [m.strip() for m in enabled.split(",") if m.strip()] for item in raw:
return enabled if isinstance(item, str):
result.append({"name": item, "is_server_mod": False})
elif isinstance(item, dict):
result.append({"name": item.get("name", ""), "is_server_mod": bool(item.get("is_server_mod", False))})
return result
def set_enabled_mods(self, mod_names: list[str], config_repo) -> None: def set_enabled_mods(self, mod_entries: list[dict], config_repo) -> None:
""" """
Update the enabled mods list in the database config. Update the enabled mods list in the database config.
Args: Args:
mod_names: List of mod directory names to enable. mod_entries: List of dicts with "name" (str) and "is_server_mod" (bool).
config_repo: ConfigRepository instance. config_repo: ConfigRepository instance.
Raises AdapterError if any mod name doesn't exist on disk. Raises AdapterError if any mod name is invalid or not found on disk.
""" """
available = {m["name"] for m in self.list_available_mods()} available = {m["name"] for m in self.list_available_mods()}
for name in mod_names: for entry in mod_entries:
name = entry.get("name", "")
if not _MOD_DIR_PATTERN.match(name): if not _MOD_DIR_PATTERN.match(name):
raise AdapterError(f"Invalid mod name '{name}': must start with '@'") raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
if name not in available: if name not in available:
raise AdapterError( raise AdapterError(
f"Mod '{name}' not found in server directory. " f"Mod '{name}' not found in mods directory. "
f"Available: {sorted(available)}" f"Available: {sorted(available)}"
) )
mods_section = config_repo.get_section(self._server_id, "mods") or {} mods_section = config_repo.get_section(self._server_id, "mods") or {}
current_version = mods_section.get("config_version", 0) current_version = mods_section.get("_meta", {}).get("config_version")
config_repo.upsert_section( config_repo.upsert_section(
server_id=self._server_id, server_id=self._server_id,
game_type="arma3",
section="mods", section="mods",
data={"enabled_mods": mod_names}, config_data={"enabled_mods": mod_entries},
expected_version=current_version, schema_version="1.0.0",
expected_config_version=current_version,
) )
logger.info( logger.info(
"Updated enabled mods for server %d: %s", "Updated enabled mods for server %d: %s",
self._server_id, mod_names, self._server_id, [e["name"] for e in mod_entries],
) )
# ── CLI argument building ── # ── CLI argument building ──

View File

@@ -27,4 +27,50 @@ class Arma3ProcessConfig:
def get_server_dir_layout(self) -> list[str]: def get_server_dir_layout(self) -> list[str]:
"""Subdirectories to create inside servers/{id}/.""" """Subdirectories to create inside servers/{id}/."""
return ["server", "battleye", "mpmissions"] return ["server", "battleye", "mpmissions", "mods"]
_DIR_READMES: dict[str, str] = {
"server": (
"Arma 3 Server — Log Directory\n"
"==============================\n\n"
"Arma 3 writes RPT log files here (e.g. arma3server_2024-01-01_12-00-00.rpt).\n"
"These are viewable in Languard's Logs tab.\n\n"
"Do NOT place files here manually."
),
"battleye": (
"BattlEye Anti-Cheat\n"
"===================\n\n"
"BattlEye configuration and GUID ban list files live here.\n"
"Managed automatically by Arma 3 and Languard.\n\n"
"Do NOT modify these files manually unless you know what you are doing."
),
"mpmissions": (
"Mission Files\n"
"=============\n\n"
"Place Arma 3 mission files (.pbo) here to make them available for the server.\n"
"Once placed here they will appear in Languard's Missions tab.\n\n"
"Example: Wasteland_A3.Altis.pbo"
),
"mods": (
"Mods\n"
"====\n\n"
"Place Arma 3 mod folders here. Each mod folder must start with '@'.\n\n"
"Example layout:\n"
" mods/\n"
" @CBA_A3/\n"
" addons/\n"
" @ACE/\n"
" addons/\n\n"
"After placing mods here:\n"
" 1. Go to the Mods tab in Languard.\n"
" 2. Select the mods you want to enable.\n"
" 3. Toggle 'Server-only' for mods that should use -serverMod= (e.g. task force radio server plugin).\n"
" 4. Click 'Apply Selection'.\n"
" 5. Restart the server for changes to take effect.\n\n"
"Mods with a mod.cpp file will display their friendly name in the UI.\n"
"Workshop mods with meta.cpp will show their Workshop ID."
),
}
def get_dir_readme(self, dir_name: str) -> str | None:
return self._DIR_READMES.get(dir_name)

View File

@@ -92,6 +92,14 @@ class ConfigGenerator(Protocol):
""" """
... ...
def normalize_section(self, section: str, data: dict) -> dict:
"""
Optional: backfill / migrate a stored section dict before returning it to callers.
Called by service.get_config_section() via hasattr guard.
Default: return data unchanged. Implement to add new optional fields with defaults.
"""
return data
@runtime_checkable @runtime_checkable
class RemoteAdminClient(Protocol): class RemoteAdminClient(Protocol):

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -10,7 +11,6 @@ from sqlalchemy.engine import Connection
from adapters.registry import GameAdapterRegistry from adapters.registry import GameAdapterRegistry
from core.dal.server_repository import ServerRepository from core.dal.server_repository import ServerRepository
from core.utils.file_utils import get_server_dir
from database import get_db from database import get_db
from dependencies import get_current_user, require_admin from dependencies import get_current_user, require_admin
@@ -30,7 +30,9 @@ def _get_rpt_parser(server_id: int, db: Connection):
adapter = GameAdapterRegistry.get(server["game_type"]) adapter = GameAdapterRegistry.get(server["game_type"])
if not adapter.has_capability("log_parser"): if not adapter.has_capability("log_parser"):
raise HTTPException(status_code=404, detail="Server does not support log files") raise HTTPException(status_code=404, detail="Server does not support log files")
return adapter.get_log_parser(), get_server_dir(server_id) # RPT files live next to the server exe (e.g. A3Master/server/*.rpt)
exe_dir = Path(server["exe_path"]).parent
return adapter.get_log_parser(), exe_dir
@router.get("") @router.get("")

View File

@@ -5,7 +5,7 @@ import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from adapters.exceptions import AdapterError from adapters.exceptions import AdapterError
@@ -24,6 +24,7 @@ _MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB
class MissionRotationEntry(BaseModel): class MissionRotationEntry(BaseModel):
name: str name: str
difficulty: str = "" difficulty: str = ""
params: dict[str, int | float | str | bool] = Field(default_factory=dict)
class MissionRotationUpdate(BaseModel): class MissionRotationUpdate(BaseModel):

View File

@@ -24,8 +24,13 @@ def _ok(data):
return {"success": True, "data": data, "error": None} return {"success": True, "data": data, "error": None}
class EnabledModEntry(BaseModel):
name: str
is_server_mod: bool = False
class SetEnabledModsRequest(BaseModel): class SetEnabledModsRequest(BaseModel):
mods: list[str] mods: list[EnabledModEntry]
def _get_mod_manager(server_id: int, game_type: str): def _get_mod_manager(server_id: int, game_type: str):
@@ -52,12 +57,15 @@ def list_mods(
config_repo = ConfigRepository(db) config_repo = ConfigRepository(db)
try: try:
available = mgr.list_available_mods() available = mgr.list_available_mods()
enabled = set(mgr.get_enabled_mods(config_repo)) enabled_mods = mgr.get_enabled_mods(config_repo)
except AdapterError as exc: except AdapterError as exc:
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
enabled_map = {m["name"]: m for m in enabled_mods}
for mod in available: for mod in available:
mod["enabled"] = mod["name"] in enabled entry = enabled_map.get(mod["name"])
mod["enabled"] = entry is not None
mod["is_server_mod"] = entry["is_server_mod"] if entry else False
return _ok({ return _ok({
"server_id": server_id, "server_id": server_id,
@@ -83,7 +91,7 @@ def set_enabled_mods(
config_repo = ConfigRepository(db) config_repo = ConfigRepository(db)
try: try:
mgr.set_enabled_mods(body.mods, config_repo) mgr.set_enabled_mods([m.model_dump() for m in body.mods], config_repo)
except AdapterError as exc: except AdapterError as exc:
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
except ValueError as exc: except ValueError as exc:
@@ -97,5 +105,5 @@ def set_enabled_mods(
return _ok({ return _ok({
"message": "Enabled mods updated. Restart the server for changes to take effect.", "message": "Enabled mods updated. Restart the server for changes to take effect.",
"enabled_mods": body.mods, "enabled_mods": [m.model_dump() for m in body.mods],
}) })

View File

@@ -126,9 +126,10 @@ class ServerService:
max_restarts=max_restarts, max_restarts=max_restarts,
) )
# Create directory layout # Create directory layout with per-directory README files
layout = process_config.get_server_dir_layout() layout = process_config.get_server_dir_layout()
ensure_server_dirs(server_id, layout) readme_fn = getattr(process_config, "get_dir_readme", None)
ensure_server_dirs(server_id, layout, readme_provider=readme_fn)
# Seed default config sections # Seed default config sections
config_gen = adapter.get_config_generator() config_gen = adapter.get_config_generator()
@@ -242,17 +243,17 @@ class ServerService:
# Get mod args if adapter supports mods # Get mod args if adapter supports mods
mod_args: list[str] = [] mod_args: list[str] = []
if adapter.has_capability("mod_manager"): if adapter.has_capability("mod_manager"):
from sqlalchemy import text mod_mgr = adapter.get_mod_manager(server_id)
mods = self._db.execute( enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
text(""" server_dir = get_server_dir(server_id)
SELECT m.folder_path, sm.is_server_mod, sm.sort_order mod_list = [
FROM server_mods sm JOIN mods m ON m.id = sm.mod_id {
WHERE sm.server_id = :sid ORDER BY sm.sort_order "folder_path": str(server_dir / "mods" / m["name"]),
"""), "game_data": {"is_server_mod": m.get("is_server_mod", False)},
{"sid": server_id}, }
).fetchall() for m in enabled_mods
mod_list = [dict(r._mapping) for r in mods] ]
mod_args = adapter.get_mod_manager().build_mod_args(mod_list) mod_args = mod_mgr.build_mod_args(mod_list)
# Write config files (atomic) # Write config files (atomic)
server_dir = get_server_dir(server_id) server_dir = get_server_dir(server_id)
@@ -273,7 +274,7 @@ class ServerService:
# Build launch args # Build launch args
try: try:
launch_args = config_gen.build_launch_args(raw_sections, mod_args) launch_args = config_gen.build_launch_args(raw_sections, mod_args, server_dir=server_dir)
except LaunchArgsError as e: except LaunchArgsError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -468,6 +469,8 @@ class ServerService:
if data is None: if data is None:
data = config_gen.get_defaults(section) data = config_gen.get_defaults(section)
data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()} data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()}
if hasattr(config_gen, "normalize_section"):
data = config_gen.normalize_section(section, data)
# Mask sensitive fields # Mask sensitive fields
for field in sensitive: for field in sensitive:
if field in data and data[field]: if field in data and data[field]:

View File

@@ -159,18 +159,16 @@ class ThreadRegistry:
game_type = server["game_type"] game_type = server["game_type"]
adapter = self._adapter_registry.get(game_type) adapter = self._adapter_registry.get(game_type)
# Log path: read from config if present, else use adapter default # Log path: RPT files live next to the server exe, not in the languard data dir
log_path = None log_path = None
if adapter.has_capability("log_parser"): if adapter.has_capability("log_parser"):
log_parser = adapter.get_log_parser() log_parser = adapter.get_log_parser()
# Try to resolve log path via the adapter's log file resolver from pathlib import Path
from core.utils.file_utils import get_server_dir exe_dir = Path(server["exe_path"]).parent
server_dir = get_server_dir(server_id) resolver = log_parser.get_log_file_resolver(server_id)
if server_dir.exists(): resolved = resolver(exe_dir)
resolver = log_parser.get_log_file_resolver(server_id) if resolved is not None:
resolved = resolver(server_dir) log_path = str(resolved)
if resolved is not None:
log_path = str(resolved)
bundle: dict = { bundle: dict = {
"log_tail": None, "log_tail": None,

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import re import re
from pathlib import Path from pathlib import Path
from typing import Callable
def get_server_dir(server_id: int) -> Path: def get_server_dir(server_id: int) -> Path:
@@ -12,16 +13,27 @@ def get_server_dir(server_id: int) -> Path:
return base / str(server_id) return base / str(server_id)
def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None: def ensure_server_dirs(
server_id: int,
layout: list[str] | None = None,
readme_provider: Callable[[str], str | None] | None = None,
) -> None:
""" """
Create servers/{id}/ and any subdirectories from adapter layout. Create servers/{id}/ and any subdirectories from adapter layout.
layout example: ["server", "battleye", "mpmissions"] If readme_provider is given, writes README.txt into each subdir (skips if file already exists).
""" """
server_dir = get_server_dir(server_id) server_dir = get_server_dir(server_id)
server_dir.mkdir(parents=True, exist_ok=True) server_dir.mkdir(parents=True, exist_ok=True)
if layout: if layout:
for subdir in layout: for subdir in layout:
(server_dir / subdir).mkdir(parents=True, exist_ok=True) subdir_path = server_dir / subdir
subdir_path.mkdir(parents=True, exist_ok=True)
if readme_provider:
content = readme_provider(subdir)
if content:
readme_path = subdir_path / "README.txt"
if not readme_path.exists():
readme_path.write_text(content, encoding="utf-8")
def safe_delete_file(path: Path) -> bool: def safe_delete_file(path: Path) -> bool:

View File

@@ -90,7 +90,24 @@ async def lifespan(app: FastAPI):
except Exception as exc: except Exception as exc:
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc) logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
# 8. Seed default admin if no users exist # 8. Backfill server directory scaffold for existing servers (idempotent)
from core.dal.server_repository import ServerRepository as _ServerRepo
from core.utils.file_utils import ensure_server_dirs as _ensure_dirs
from adapters.registry import GameAdapterRegistry as _Registry
with engine.connect() as db:
for server in _ServerRepo(db).get_all():
try:
_adapter = _Registry.get(server["game_type"])
_pc = _adapter.get_process_config()
_ensure_dirs(
server["id"],
_pc.get_server_dir_layout(),
readme_provider=getattr(_pc, "get_dir_readme", None),
)
except Exception as exc:
logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc)
# 9. Seed default admin if no users exist
from core.auth.service import AuthService from core.auth.service import AuthService
with engine.connect() as db: with engine.connect() as db:
svc = AuthService(db) svc = AuthService(db)
@@ -104,7 +121,7 @@ async def lifespan(app: FastAPI):
logger.warning(" Change this password immediately!") logger.warning(" Change this password immediately!")
logger.warning("=" * 60) logger.warning("=" * 60)
# 9. Register and start APScheduler cleanup jobs # 10. Register and start APScheduler cleanup jobs
from core.jobs.scheduler import start_scheduler, stop_scheduler from core.jobs.scheduler import start_scheduler, stop_scheduler
from core.jobs.cleanup_jobs import register_cleanup_jobs from core.jobs.cleanup_jobs import register_cleanup_jobs
register_cleanup_jobs() register_cleanup_jobs()

View File

View File

View File

View File

@@ -0,0 +1,89 @@
"""Tests for Arma3ConfigGenerator.get_ui_schema() — advanced flag completeness."""
import pytest
from adapters.arma3.config_generator import Arma3ConfigGenerator
BASIC_FIELDS = {
"server": {
"hostname", "max_players", "password", "password_admin",
"motd_lines", "motd_interval",
"forced_difficulty", "auto_select_mission", "random_mission_order",
"persistent", "skip_lobby", "drawing_in_map",
"battleye", "verify_signatures", "allowed_file_patching",
"disable_von", "von_codec",
},
"profile": {
"group_indicators", "friendly_tags", "enemy_tags",
"commands", "waypoints", "weapon_info", "stance_indicator",
"ai_level_preset", "skill_ai", "precision_ai",
},
"rcon": {"rcon_password", "enabled"},
}
ADVANCED_SAMPLES = {
"server": {
"server_command_password", "kick_duplicate", "vote_threshold",
"max_ping", "max_packet_loss", "disconnect_timeout",
"kick_on_ping", "log_file", "upnp", "loopback",
"admin_uids", "headless_clients", "local_clients",
"default_mission_params",
},
"basic": {
"min_bandwidth", "max_bandwidth", "max_msg_send",
"max_size_guaranteed", "min_error_to_send",
},
"profile": {
"reduced_damage", "tactical_ping", "weapon_crosshair",
"vision_aid", "third_person_view", "score_table",
"death_messages", "von_id",
},
"launch": {
"world", "limit_fps", "cpu_count", "max_mem",
"enable_ht", "huge_pages", "no_logs", "netlog",
},
"rcon": {"max_ping"},
}
@pytest.fixture
def schema():
return Arma3ConfigGenerator().get_ui_schema()
def test_every_visible_field_has_advanced_key(schema):
"""Every non-hidden field must carry an explicit `advanced` bool."""
for section, fields in schema.items():
for field, entry in fields.items():
if entry.get("widget") == "hidden":
continue
assert "advanced" in entry, (
f"section={section!r} field={field!r} is missing 'advanced' key"
)
def test_basic_fields_are_not_advanced(schema):
"""Confirmed basic fields must have advanced=False."""
for section, field_names in BASIC_FIELDS.items():
for field in field_names:
entry = schema[section][field]
assert entry["advanced"] is False, (
f"section={section!r} field={field!r} should be basic (advanced=False)"
)
def test_advanced_samples_are_marked_advanced(schema):
"""Sampled advanced fields must have advanced=True."""
for section, field_names in ADVANCED_SAMPLES.items():
for field in field_names:
entry = schema[section][field]
assert entry["advanced"] is True, (
f"section={section!r} field={field!r} should be advanced (advanced=True)"
)
def test_hidden_fields_excluded_from_advanced_requirement(schema):
"""Hidden fields (e.g. missions) are exempt from the advanced check."""
for section, fields in schema.items():
for field, entry in fields.items():
if entry.get("widget") == "hidden":
# No advanced key required — just confirm widget is hidden
assert entry["widget"] == "hidden"

View File

@@ -0,0 +1,210 @@
/**
* TDD RED → GREEN: basic/advanced field filtering and profile section gate.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("@/lib/api", () => ({
apiClient: {
get: vi.fn(),
put: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
vi.mock("@/lib/logger", () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));
import { apiClient } from "@/lib/api";
import { ConfigEditor } from "@/components/servers/ConfigEditor";
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
const BASIC_SERVER_SECTION = {
_meta: { config_version: "v1", schema_version: "1.1.0" },
hostname: "My Server",
max_players: 10,
kick_duplicate: 1, // advanced
server_command_password: "", // advanced
vote_threshold: 0.33, // advanced
};
const SCHEMA_WITH_ADVANCED = {
server: {
hostname: { widget: "text", label: "Hostname", advanced: false },
max_players: { widget: "number", label: "Max Players", advanced: false },
kick_duplicate: { widget: "toggle", label: "Kick Duplicate", advanced: true },
server_command_password: { widget: "password", label: "SC Password", advanced: true },
vote_threshold: { widget: "number", label: "Vote Threshold", advanced: true },
},
};
const PROFILE_SECTION = {
_meta: { config_version: "v1", schema_version: "1.1.0" },
group_indicators: 1,
reduced_damage: 0,
};
const PROFILE_SCHEMA = {
profile: {
group_indicators: { widget: "toggle", label: "Group Indicators", advanced: false },
reduced_damage: { widget: "toggle", label: "Reduced Damage", advanced: true },
},
};
describe("ConfigEditor — basic/advanced filtering", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
function mockServerConfig(sections: Record<string, Record<string, unknown>>) {
const configMap: Record<string, unknown> = {};
for (const [k, v] of Object.entries(sections)) {
configMap[k] = v;
}
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url.endsWith("/config/schema")) {
return Promise.resolve({ data: { success: true, data: { ...SCHEMA_WITH_ADVANCED } } });
}
if (url.endsWith("/config")) {
return Promise.resolve({ data: { success: true, data: configMap } });
}
if (url.includes("/config/")) {
const section = url.split("/config/")[1];
return Promise.resolve({ data: { success: true, data: sections[section] ?? {} } });
}
return Promise.resolve({ data: { success: true, data: {} } });
});
}
it("hides advanced fields by default", async () => {
mockServerConfig({ server: BASIC_SERVER_SECTION });
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
expect(screen.getByText("Hostname")).toBeInTheDocument();
expect(screen.queryByText("Kick Duplicate")).not.toBeInTheDocument();
expect(screen.queryByText("SC Password")).not.toBeInTheDocument();
});
it("shows advanced fields after clicking Show advanced", async () => {
mockServerConfig({ server: BASIC_SERVER_SECTION });
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
fireEvent.click(screen.getByText(/show advanced/i));
expect(screen.getByText("Kick Duplicate")).toBeInTheDocument();
expect(screen.getByText("SC Password")).toBeInTheDocument();
});
it("hides advanced fields again after toggling off", async () => {
mockServerConfig({ server: BASIC_SERVER_SECTION });
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
fireEvent.click(screen.getByText(/show advanced/i));
await screen.findByText("Kick Duplicate");
fireEvent.click(screen.getByText(/hide advanced/i));
await waitFor(() => {
expect(screen.queryByText("Kick Duplicate")).not.toBeInTheDocument();
});
});
it("shows all fields when no schema is available (graceful fallback)", async () => {
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url.endsWith("/config")) {
return Promise.resolve({
data: { success: true, data: { server: BASIC_SERVER_SECTION } },
});
}
if (url.includes("/config/server")) {
return Promise.resolve({ data: { success: true, data: BASIC_SERVER_SECTION } });
}
if (url.endsWith("/config/schema")) {
return Promise.resolve({ data: { success: true, data: {} } }); // no schema
}
return Promise.resolve({ data: { success: true, data: {} } });
});
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Hostname");
// Without schema, fields have no advanced flag — all should display
expect(screen.getByText("Hostname")).toBeInTheDocument();
});
});
describe("ConfigEditor — profile section gate", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
function mockWithDifficulty(difficulty: string) {
const serverSectionData = {
_meta: { config_version: "v1", schema_version: "1.1.0" },
forced_difficulty: difficulty,
hostname: "My Server",
};
vi.mocked(apiClient.get).mockImplementation((url: string) => {
if (url.endsWith("/config/schema")) {
return Promise.resolve({
data: {
success: true,
data: {
server: {
forced_difficulty: { widget: "select", label: "Difficulty", advanced: false, options: ["Recruit", "Regular", "Veteran", "Custom"] },
hostname: { widget: "text", label: "Hostname", advanced: false },
},
...PROFILE_SCHEMA,
},
},
});
}
if (url.endsWith("/config")) {
return Promise.resolve({
data: { success: true, data: { server: serverSectionData, profile: PROFILE_SECTION } },
});
}
if (url.includes("/config/server")) {
return Promise.resolve({ data: { success: true, data: serverSectionData } });
}
if (url.includes("/config/profile")) {
return Promise.resolve({ data: { success: true, data: PROFILE_SECTION } });
}
return Promise.resolve({ data: { success: true, data: {} } });
});
}
it("shows profile warning banner when difficulty is not Custom", async () => {
mockWithDifficulty("Regular");
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
// Switch to Difficulty tab
await screen.findByText("Difficulty");
fireEvent.click(screen.getByText("Difficulty"));
await screen.findByText(/only apply when/i);
});
it("does not show profile warning banner when difficulty is Custom", async () => {
mockWithDifficulty("Custom");
render(<ConfigEditor serverId={1} />, { wrapper: createWrapper() });
await screen.findByText("Difficulty");
fireEvent.click(screen.getByText("Difficulty"));
// Fields should show without the gating banner
await screen.findByText("Group Indicators");
expect(screen.queryByText(/only apply when/i)).not.toBeInTheDocument();
});
});

View File

@@ -4,6 +4,7 @@ import clsx from "clsx";
import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail"; import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail";
import type { FieldSchema } from "@/hooks/useServerDetail"; import type { FieldSchema } from "@/hooks/useServerDetail";
import { TagListEditor } from "@/components/ui/TagListEditor"; import { TagListEditor } from "@/components/ui/TagListEditor";
import { MissionParamsEditor } from "@/components/servers/MissionParamsEditor";
import { useAuthStore } from "@/store/auth.store"; import { useAuthStore } from "@/store/auth.store";
import { useUIStore } from "@/store/ui.store"; import { useUIStore } from "@/store/ui.store";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -14,10 +15,27 @@ interface ConfigEditorProps {
const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]); const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]);
const SECTION_LABELS: Record<string, string> = {
server: "Server",
basic: "Network",
profile: "Difficulty",
launch: "Startup",
rcon: "RCon",
};
const SECTION_DESCRIPTIONS: Record<string, string> = {
server: "General server settings — identity, players, security, voting, and timeouts.",
basic: "Low-level network bandwidth and packet tuning.",
profile: "Custom difficulty settings applied when Forced Difficulty is set to 'Custom'.",
launch: "Startup parameters passed to the server executable.",
rcon: "Remote console access for live server administration.",
};
export function ConfigEditor({ serverId }: ConfigEditorProps) { export function ConfigEditor({ serverId }: ConfigEditorProps) {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const addNotification = useUIStore((s) => s.addNotification); const addNotification = useUIStore((s) => s.addNotification);
const { data: configMap, isLoading } = useServerConfig(serverId); const { data: configMap, isLoading } = useServerConfig(serverId);
const { data: schema } = useServerConfigSchema(serverId);
const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : []; const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : [];
const [activeSection, setActiveSection] = useState<string>(sections[0] ?? ""); const [activeSection, setActiveSection] = useState<string>(sections[0] ?? "");
@@ -32,30 +50,46 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
const currentSection = activeSection || sections[0]; const currentSection = activeSection || sections[0];
// Derive forced_difficulty from server section for profile gate
const serverSection = configMap["server"] as Record<string, unknown> | undefined;
const forcedDifficulty = serverSection?.["forced_difficulty"] as string | undefined;
const profileGated = currentSection === "profile" && forcedDifficulty !== "Custom";
return ( return (
<div data-testid="config-editor"> <div data-testid="config-editor">
<div className="flex gap-1 mb-4 overflow-x-auto"> <div className="flex gap-1 mb-3 overflow-x-auto">
{sections.map((section) => ( {sections.map((section) => (
<button <button
key={section} key={section}
onClick={() => setActiveSection(section)} onClick={() => setActiveSection(section)}
className={clsx( className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-colors capitalize", "px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
currentSection === section currentSection === section
? "bg-accent text-text-inverse" ? "bg-accent text-text-inverse"
: "text-text-secondary hover:text-text-primary hover:bg-surface-overlay", : "text-text-secondary hover:text-text-primary hover:bg-surface-overlay",
)} )}
> >
{section} {SECTION_LABELS[section] ?? section}
</button> </button>
))} ))}
</div> </div>
{SECTION_DESCRIPTIONS[currentSection] && (
<p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p>
)}
{profileGated && (
<div className="rounded-lg border border-surface-raised bg-surface-overlay px-4 py-3 mb-4 text-sm text-text-secondary">
These settings only apply when <strong>Forced Difficulty</strong> is set to <strong>Custom</strong> in the Server tab. Current value: <em>{forcedDifficulty ?? "—"}</em>
</div>
)}
{currentSection && ( {currentSection && (
<ConfigSectionForm <ConfigSectionForm
key={currentSection} key={currentSection}
serverId={serverId} serverId={serverId}
section={currentSection} section={currentSection}
sectionSchema={schema?.[currentSection] ?? {}}
isAdmin={isAdmin} isAdmin={isAdmin}
addNotification={addNotification} addNotification={addNotification}
/> />
@@ -67,19 +101,21 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
function ConfigSectionForm({ function ConfigSectionForm({
serverId, serverId,
section, section,
sectionSchema,
isAdmin, isAdmin,
addNotification, addNotification,
}: { }: {
serverId: number; serverId: number;
section: string; section: string;
sectionSchema: Record<string, FieldSchema>;
isAdmin: boolean; isAdmin: boolean;
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void; addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
}) { }) {
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section); const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
const { data: schema } = useServerConfigSchema(serverId);
const updateSection = useUpdateConfigSection(serverId, section); const updateSection = useUpdateConfigSection(serverId, section);
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null); const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({}); const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
if (isLoading) { if (isLoading) {
return <div className="text-text-muted text-sm">Loading section...</div>; return <div className="text-text-muted text-sm">Loading section...</div>;
@@ -89,11 +125,26 @@ function ConfigSectionForm({
return <div className="text-text-muted text-sm">No data for this section.</div>; return <div className="text-text-muted text-sm">No data for this section.</div>;
} }
const fields = Object.entries(sectionData).filter(([key]) => key !== "_meta"); const hasAdvancedFields = Object.values(sectionSchema).some((f) => f.advanced === true);
const allFields = Object.entries(sectionData).filter(([key]) => {
if (key === "_meta") return false;
if (sectionSchema[key]?.widget === "hidden") return false;
return true;
});
// When schema has advanced flags, filter by them; otherwise show everything
const schemaHasFlags = Object.keys(sectionSchema).length > 0 && Object.values(sectionSchema).some((f) => "advanced" in f);
const fields = schemaHasFlags
? allFields.filter(([key]) => {
const fieldSchema = sectionSchema[key];
if (!fieldSchema || fieldSchema.advanced === undefined) return true;
return showAdvanced ? true : !fieldSchema.advanced;
})
: allFields;
const meta = sectionData._meta; const meta = sectionData._meta;
const displayValues = editValues ?? Object.fromEntries(fields); const displayValues = editValues ?? Object.fromEntries(fields);
const isEditing = editValues !== null; const isEditing = editValues !== null;
const sectionSchema = schema?.[section] ?? {};
const handleEdit = () => { const handleEdit = () => {
setEditValues(Object.fromEntries(fields)); setEditValues(Object.fromEntries(fields));
@@ -106,7 +157,11 @@ function ConfigSectionForm({
}; };
const handleSave = async () => { const handleSave = async () => {
if (!editValues || !meta) return; if (!editValues) return;
if (!meta) {
addNotification({ type: "error", message: "Cannot save: config metadata is missing. Please refresh the page." });
return;
}
try { try {
await updateSection.mutateAsync({ await updateSection.mutateAsync({
config_version: meta.config_version, config_version: meta.config_version,
@@ -142,9 +197,19 @@ function ConfigSectionForm({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-text-muted text-xs"> <div className="flex items-center gap-3">
Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"} <p className="text-text-muted text-xs">
</p> Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"}
</p>
{hasAdvancedFields && !isEditing && (
<button
onClick={() => setShowAdvanced((v) => !v)}
className="text-xs text-text-secondary hover:text-text-primary underline underline-offset-2"
>
{showAdvanced ? "Hide advanced" : "Show advanced"}
</button>
)}
</div>
{isAdmin && !isEditing && ( {isAdmin && !isEditing && (
<button onClick={handleEdit} className="btn-ghost text-sm"> <button onClick={handleEdit} className="btn-ghost text-sm">
Edit Edit
@@ -159,7 +224,7 @@ function ConfigSectionForm({
const rawValue = displayValues[key]; const rawValue = displayValues[key];
return ( return (
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" ? "flex-col" : "items-center")}> <div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" || widget === "key-value" ? "flex-col" : "items-center")}>
<label className="text-text-secondary text-sm w-40 shrink-0">{label}</label> <label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
{isEditing ? ( {isEditing ? (
<FieldWidget <FieldWidget
@@ -171,6 +236,22 @@ function ConfigSectionForm({
onTogglePassword={() => toggleShowPassword(key)} onTogglePassword={() => toggleShowPassword(key)}
onChange={(v) => handleChange(key, v)} onChange={(v) => handleChange(key, v)}
/> />
) : widget === "toggle" ? (
<div className="flex items-center flex-1 px-1">
<ToggleDisplay value={rawValue} />
</div>
) : widget === "select" ? (
<span className="text-text-primary text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
{formatSelectDisplay(rawValue, fieldSchema)}
</span>
) : widget === "key-value" ? (
<div className="bg-surface-recessed rounded-lg px-3 py-2">
<MissionParamsEditor
value={rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, number | string | boolean>) : {}}
onChange={() => {}}
readOnly
/>
</div>
) : ( ) : (
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2"> <span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)} {widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
@@ -222,26 +303,41 @@ function FieldWidget({
/> />
); );
case "select": case "select": {
const options = fieldSchema?.options ?? [];
// Options may use "N - Description" format for numeric fields
const isNumericOptions = options.length > 0 && /^\d+ /.test(options[0]);
const matchedOpt = isNumericOptions
? options.find((opt) => parseInt(opt, 10) === Number(value))
: options.find((opt) => opt === String(value ?? ""));
// Fall back to first option when stored value has no match (avoids silent blank selection)
const selectedOpt = matchedOpt ?? options[0] ?? String(value ?? "");
return ( return (
<select <select
className="neu-input flex-1 text-sm" className="neu-input flex-1 text-sm"
value={String(value ?? "")} value={selectedOpt}
onChange={(e) => onChange(e.target.value)} onChange={(e) => {
const raw = e.target.value;
if (isNumericOptions) {
const num = parseInt(raw, 10);
onChange(isNaN(num) ? raw : num);
} else {
onChange(raw);
}
}}
> >
{(fieldSchema?.options ?? []).map((opt) => ( {options.map((opt) => (
<option key={opt} value={opt}>{opt}</option> <option key={opt} value={opt}>{opt}</option>
))} ))}
</select> </select>
); );
}
case "toggle": case "toggle":
return ( return (
<input <ToggleSwitch
type="checkbox"
className="w-5 h-5 accent-accent"
checked={value === true || value === 1 || value === "true"} checked={value === true || value === 1 || value === "true"}
onChange={(e) => onChange(e.target.checked ? "true" : "false")} onChange={(checked) => onChange(checked ? 1 : 0)}
/> />
); );
@@ -254,15 +350,26 @@ function FieldWidget({
/> />
); );
case "key-value":
return (
<MissionParamsEditor
value={value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, number | string | boolean>) : {}}
onChange={onChange}
/>
);
case "number": case "number":
return ( return (
<input <input
type="number" type="number"
className="neu-input flex-1 text-sm" className="neu-input flex-1 text-sm"
value={String(value ?? "")} value={value === null || value === undefined ? "" : String(value)}
min={fieldSchema?.min} min={fieldSchema?.min}
max={fieldSchema?.max} max={fieldSchema?.max}
onChange={(e) => onChange(Number(e.target.value))} onChange={(e) => {
const v = e.target.value;
onChange(v === "" ? null : Number(v));
}}
/> />
); );
@@ -298,11 +405,69 @@ function FieldWidget({
} }
} }
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={clsx(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full",
"transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-surface-base",
checked
? "bg-accent shadow-glow-amber"
: "bg-surface-overlay border border-surface-raised shadow-neu-recessed",
)}
>
<span
className={clsx(
"inline-block h-4 w-4 rounded-full shadow-md transform transition-transform duration-200",
checked ? "translate-x-6 bg-text-inverse" : "translate-x-1 bg-text-muted",
)}
/>
</button>
);
}
function ToggleDisplay({ value }: { value: unknown }) {
const on = value === true || value === 1 || value === "true" || value === "1";
return (
<div
className={clsx(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full cursor-not-allowed opacity-75",
on
? "bg-accent shadow-glow-amber"
: "bg-surface-overlay border border-surface-raised shadow-neu-recessed",
)}
aria-hidden="true"
>
<span
className={clsx(
"inline-block h-4 w-4 rounded-full shadow-md transform",
on ? "translate-x-6 bg-text-inverse" : "translate-x-1 bg-text-muted",
)}
/>
</div>
);
}
function formatDisplayValue(value: unknown): string { function formatDisplayValue(value: unknown): string {
if (Array.isArray(value)) return value.join(", ") || "--"; if (Array.isArray(value)) return value.join(", ") || "--";
return String(value ?? "--"); return String(value ?? "--");
} }
function formatSelectDisplay(value: unknown, fieldSchema: FieldSchema | undefined): string {
const options = fieldSchema?.options;
if (!options?.length) return formatDisplayValue(value);
const isNumeric = /^\d+ /.test(options[0]);
if (isNumeric) {
const match = options.find((opt) => parseInt(opt, 10) === Number(value));
return match ?? String(value ?? "--");
}
return String(value ?? "--");
}
function formatLabel(key: string): string { function formatLabel(key: string): string {
return key return key
.replace(/_/g, " ") .replace(/_/g, " ")

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { Fragment, useState, useRef, useEffect } from "react";
import { Upload, Trash2, Plus, X, Save } from "lucide-react"; import { Upload, Trash2, Plus, X, Save, ChevronDown, ChevronRight } from "lucide-react";
import { MissionParamsEditor } from "./MissionParamsEditor";
import { import {
useServerMissions, useServerMissions,
@@ -9,7 +10,7 @@ import {
useDeleteMission, useDeleteMission,
useServerConfigSection, useServerConfigSection,
} from "@/hooks/useServerDetail"; } from "@/hooks/useServerDetail";
import type { MissionRotationEntry } from "@/hooks/useServerDetail"; import type { MissionRotationEntry, MissionParamValue } from "@/hooks/useServerDetail";
import { useAuthStore } from "@/store/auth.store"; import { useAuthStore } from "@/store/auth.store";
import { useUIStore } from "@/store/ui.store"; import { useUIStore } from "@/store/ui.store";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -39,6 +40,7 @@ export function MissionList({ serverId }: MissionListProps) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]); const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]); const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [expandedParamsIdx, setExpandedParamsIdx] = useState<number | null>(null);
// Sync rotation from query on load // Sync rotation from query on load
useEffect(() => { useEffect(() => {
@@ -81,7 +83,7 @@ export function MissionList({ serverId }: MissionListProps) {
const addToRotation = (missionName: string) => { const addToRotation = (missionName: string) => {
if (rotation.some((r) => r.name === missionName)) return; if (rotation.some((r) => r.name === missionName)) return;
setRotation([...rotation, { name: missionName, difficulty: "" }]); setRotation([...rotation, { name: missionName, difficulty: "", params: {} }]);
}; };
const removeFromRotation = (idx: number) => { const removeFromRotation = (idx: number) => {
@@ -92,6 +94,10 @@ export function MissionList({ serverId }: MissionListProps) {
setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r))); setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r)));
}; };
const updateParams = (idx: number, params: Record<string, MissionParamValue>) => {
setRotation(rotation.map((r, i) => (i === idx ? { ...r, params } : r)));
};
const handleSaveRotation = async () => { const handleSaveRotation = async () => {
try { try {
await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion }); await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion });
@@ -114,7 +120,7 @@ export function MissionList({ serverId }: MissionListProps) {
<div data-testid="mission-list" className="space-y-8"> <div data-testid="mission-list" className="space-y-8">
{/* Section A: Available Missions */} {/* Section A: Available Missions */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-1">
<h3 className="text-text-primary font-semibold"> <h3 className="text-text-primary font-semibold">
Available Missions ({missions.length}) Available Missions ({missions.length})
</h3> </h3>
@@ -134,6 +140,9 @@ export function MissionList({ serverId }: MissionListProps) {
</label> </label>
)} )}
</div> </div>
<p className="text-text-muted text-xs mb-3">
Upload .pbo mission files, then click <strong className="text-text-secondary">+ Add to Rotation</strong> to schedule them for the server.
</p>
{uploadProgress.length > 0 && ( {uploadProgress.length > 0 && (
<div className="mb-3 space-y-1"> <div className="mb-3 space-y-1">
@@ -195,10 +204,10 @@ export function MissionList({ serverId }: MissionListProps) {
onClick={() => addToRotation(mission.name)} onClick={() => addToRotation(mission.name)}
disabled={rotation.some((r) => r.name === mission.name)} disabled={rotation.some((r) => r.name === mission.name)}
className="btn-ghost text-xs flex items-center gap-1" className="btn-ghost text-xs flex items-center gap-1"
title="Add to rotation" title={rotation.some((r) => r.name === mission.name) ? "Already in rotation" : "Add to mission rotation"}
> >
<Plus size={12} /> <Plus size={12} />
Rotation {rotation.some((r) => r.name === mission.name) ? "In Rotation" : "Add to Rotation"}
</button> </button>
<button <button
onClick={() => handleDelete(mission.filename)} onClick={() => handleDelete(mission.filename)}
@@ -221,7 +230,7 @@ export function MissionList({ serverId }: MissionListProps) {
{/* Section B: Mission Rotation */} {/* Section B: Mission Rotation */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-1">
<h3 className="text-text-primary font-semibold"> <h3 className="text-text-primary font-semibold">
Mission Rotation ({rotation.length}) Mission Rotation ({rotation.length})
</h3> </h3>
@@ -245,6 +254,9 @@ export function MissionList({ serverId }: MissionListProps) {
</div> </div>
)} )}
</div> </div>
<p className="text-text-muted text-xs mb-3">
The server cycles through these missions in order. Set per-mission difficulty and optional params, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
</p>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -254,6 +266,7 @@ export function MissionList({ serverId }: MissionListProps) {
<th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th> <th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th> <th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
<th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th> <th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th>
<th className="text-left text-text-muted font-medium px-3 py-2">Params</th>
{isAdmin && ( {isAdmin && (
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th> <th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
)} )}
@@ -262,58 +275,93 @@ export function MissionList({ serverId }: MissionListProps) {
<tbody> <tbody>
{rotation.length === 0 ? ( {rotation.length === 0 ? (
<tr> <tr>
<td colSpan={isAdmin ? 5 : 4} className="text-text-muted text-center py-6"> <td colSpan={isAdmin ? 6 : 5} className="text-text-muted text-center py-6">
No missions in rotation. Add from Available above. No missions in rotation. Add from Available above.
</td> </td>
</tr> </tr>
) : ( ) : (
rotation.map((entry, idx) => { rotation.map((entry, idx) => {
const missionFile = missions.find((m) => m.name === entry.name); const missionFile = missions.find((m) => m.name === entry.name);
const paramCount = Object.keys(entry.params ?? {}).length;
const isExpanded = expandedParamsIdx === idx;
return ( return (
<tr <Fragment key={`${entry.name}-${idx}`}>
key={`${entry.name}-${idx}`} <tr
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30" className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
> >
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td> <td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
<td className="text-text-primary px-3 py-2">{entry.name}</td> <td className="text-text-primary px-3 py-2">{entry.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{missionFile?.terrain ? ( {missionFile?.terrain ? (
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded"> <span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
{missionFile.terrain} {missionFile.terrain}
</span> </span>
) : ( ) : (
<span className="text-text-muted text-xs"></span> <span className="text-text-muted text-xs"></span>
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{isAdmin ? ( {isAdmin ? (
<select <select
className="neu-input text-sm py-1" className="neu-input text-sm py-1"
value={entry.difficulty} value={entry.difficulty}
onChange={(e) => updateDifficulty(idx, e.target.value)} onChange={(e) => updateDifficulty(idx, e.target.value)}
> >
{DIFFICULTY_OPTIONS.map((opt) => ( {DIFFICULTY_OPTIONS.map((opt) => (
<option key={opt} value={opt}> <option key={opt} value={opt}>
{opt || "Default"} {opt || "Default"}
</option> </option>
))} ))}
</select> </select>
) : ( ) : (
<span className="text-text-secondary">{entry.difficulty || "Default"}</span> <span className="text-text-secondary">{entry.difficulty || "Default"}</span>
)} )}
</td> </td>
{isAdmin && ( <td className="px-3 py-2">
<td className="text-right px-3 py-2">
<button <button
onClick={() => removeFromRotation(idx)} onClick={() => setExpandedParamsIdx(isExpanded ? null : idx)}
className="btn-ghost text-status-crashed" className="btn-ghost text-xs flex items-center gap-1"
aria-label={`Remove ${entry.name} from rotation`} title={isExpanded ? "Hide parameters" : "Edit parameters"}
> >
<X size={14} /> {isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{paramCount > 0 ? (
<span className="bg-accent/20 text-accent px-1.5 py-0.5 rounded text-xs">
{paramCount} param{paramCount !== 1 ? "s" : ""}
</span>
) : (
<span className="text-text-muted">Default</span>
)}
</button> </button>
</td> </td>
{isAdmin && (
<td className="text-right px-3 py-2">
<button
onClick={() => removeFromRotation(idx)}
className="btn-ghost text-status-crashed"
aria-label={`Remove ${entry.name} from rotation`}
>
<X size={14} />
</button>
</td>
)}
</tr>
{isExpanded && (
<tr className="bg-surface-overlay/10 border-b border-surface-overlay/50">
<td colSpan={isAdmin ? 6 : 5} className="px-6 py-3">
<div className="space-y-2">
<p className="text-text-muted text-xs">
Per-mission parameters override the server default. Leave empty to use defaults from the Config tab.
</p>
<MissionParamsEditor
value={entry.params ?? {}}
onChange={(next) => updateParams(idx, next)}
readOnly={!isAdmin}
/>
</div>
</td>
</tr>
)} )}
</tr> </Fragment>
); );
}) })
)} )}

View File

@@ -0,0 +1,144 @@
import { Plus, X } from "lucide-react";
import type { MissionParamValue } from "@/hooks/useServerDetail";
type ParamsRecord = Record<string, MissionParamValue>;
type ParamType = "number" | "boolean" | "string";
interface MissionParamsEditorProps {
value: ParamsRecord;
onChange: (next: ParamsRecord) => void;
readOnly?: boolean;
}
export function MissionParamsEditor({ value, onChange, readOnly = false }: MissionParamsEditorProps) {
const entries = Object.entries(value);
const getType = (val: MissionParamValue): ParamType => {
if (typeof val === "boolean") return "boolean";
if (typeof val === "number") return "number";
return "string";
};
const updateKey = (oldKey: string, newKey: string) => {
if (oldKey === newKey || !newKey.trim()) return;
const next: ParamsRecord = {};
for (const [k, v] of Object.entries(value)) {
next[k === oldKey ? newKey.trim() : k] = v;
}
onChange(next);
};
const updateValue = (key: string, val: MissionParamValue) => {
onChange({ ...value, [key]: val });
};
const changeType = (key: string, type: ParamType) => {
const defaultByType: Record<ParamType, MissionParamValue> = {
number: 0,
boolean: false,
string: "",
};
updateValue(key, defaultByType[type]);
};
const removeEntry = (key: string) => {
const next = { ...value };
delete next[key];
onChange(next);
};
const addEntry = () => {
let base = "param";
let i = 1;
while (value[base] !== undefined) base = `param${i++}`;
onChange({ ...value, [base]: 0 });
};
if (entries.length === 0 && readOnly) {
return <span className="text-text-muted text-sm italic">No parameters set</span>;
}
return (
<div className="space-y-1.5">
{entries.map(([key, val]) => {
const t = getType(val);
return (
<div key={key} className="flex gap-2 items-center">
{readOnly ? (
<span className="font-mono text-sm text-text-primary w-40 shrink-0">{key}</span>
) : (
<input
className="neu-input text-sm font-mono"
style={{ width: "10rem" }}
value={key}
onChange={(e) => updateKey(key, e.target.value)}
onBlur={(e) => updateKey(key, e.target.value)}
placeholder="param name"
/>
)}
{!readOnly && (
<select
className="neu-input text-sm"
style={{ width: "6.5rem" }}
value={t}
onChange={(e) => changeType(key, e.target.value as ParamType)}
>
<option value="number">Number</option>
<option value="string">String</option>
<option value="boolean">Bool</option>
</select>
)}
{readOnly ? (
<span className="font-mono text-sm text-text-secondary flex-1">{String(val)}</span>
) : t === "boolean" ? (
<input
type="checkbox"
className="w-5 h-5 accent-accent"
checked={Boolean(val)}
onChange={(e) => updateValue(key, e.target.checked)}
/>
) : t === "number" ? (
<input
type="number"
className="neu-input text-sm flex-1"
value={String(val)}
onChange={(e) => updateValue(key, Number(e.target.value))}
/>
) : (
<input
type="text"
className="neu-input text-sm flex-1"
value={String(val)}
onChange={(e) => updateValue(key, e.target.value)}
/>
)}
{!readOnly && (
<button
type="button"
onClick={() => removeEntry(key)}
className="btn-ghost text-status-crashed p-1"
aria-label={`Remove ${key} parameter`}
>
<X size={12} />
</button>
)}
</div>
);
})}
{!readOnly && (
<button
type="button"
onClick={addEntry}
className="btn-ghost text-sm flex items-center gap-1 mt-1"
>
<Plus size={12} />
Add Parameter
</button>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Save } from "lucide-react"; import { Save, Server } from "lucide-react";
import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail"; import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail";
import type { Mod } from "@/hooks/useServerDetail"; import type { Mod } from "@/hooks/useServerDetail";
@@ -38,14 +38,25 @@ export function ModList({ serverId }: ModListProps) {
setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name))); setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name)));
}; };
const toggleServerMod = (modName: string) => {
setSelected((prev) =>
prev.map((m) => m.name === modName ? { ...m, is_server_mod: !m.is_server_mod } : m),
);
};
const _selectedKey = (mods: Mod[]) =>
mods.map((m) => `${m.name}:${m.is_server_mod}`).sort().join(",");
const hasChanges = modsData !== undefined && ( const hasChanges = modsData !== undefined && (
selected.map((m) => m.name).sort().join(",") !== _selectedKey(selected) !==
(modsData.mods.filter((m) => m.enabled).map((m) => m.name).sort().join(",")) _selectedKey(modsData.mods.filter((m) => m.enabled))
); );
const handleApply = async () => { const handleApply = async () => {
try { try {
await setEnabledMods.mutateAsync(selected.map((m) => m.name)); await setEnabledMods.mutateAsync(
selected.map((m) => ({ name: m.name, is_server_mod: m.is_server_mod })),
);
addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` }); addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` });
} catch (err) { } catch (err) {
logger.error("ModList", "Failed to apply mods: %s", err); logger.error("ModList", "Failed to apply mods: %s", err);
@@ -144,6 +155,7 @@ export function ModList({ serverId }: ModListProps) {
mod={mod} mod={mod}
actionLabel="←" actionLabel="←"
onAction={isAdmin ? () => moveToAvailable(mod) : undefined} onAction={isAdmin ? () => moveToAvailable(mod) : undefined}
onToggleServerMod={isAdmin ? () => toggleServerMod(mod.name) : undefined}
selected selected
/> />
)) ))
@@ -159,17 +171,17 @@ function ModRow({
mod, mod,
actionLabel, actionLabel,
onAction, onAction,
onToggleServerMod,
selected = false, selected = false,
}: { }: {
mod: Mod; mod: Mod;
actionLabel: string; actionLabel: string;
onAction?: () => void; onAction?: () => void;
onToggleServerMod?: () => void;
selected?: boolean; selected?: boolean;
}) { }) {
return ( return (
<div <div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed">
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-text-primary text-sm font-medium truncate"> <p className="text-text-primary text-sm font-medium truncate">
{mod.display_name ?? mod.name} {mod.display_name ?? mod.name}
@@ -186,6 +198,21 @@ function ModRow({
<span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span> <span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span>
</div> </div>
</div> </div>
{selected && onToggleServerMod && (
<button
type="button"
onClick={onToggleServerMod}
title={mod.is_server_mod ? "Server-only mod (-serverMod). Click to switch to client mod (-mod)" : "Client mod (-mod). Click to switch to server-only (-serverMod)"}
className={`flex items-center gap-1 text-xs px-1.5 py-0.5 rounded shrink-0 transition-colors ${
mod.is_server_mod
? "bg-amber-500/20 text-amber-400"
: "bg-surface-raised text-text-muted hover:text-text-secondary"
}`}
>
<Server size={10} />
{mod.is_server_mod ? "Server" : "Client"}
</button>
)}
{onAction && ( {onAction && (
<button <button
type="button" type="button"

View File

@@ -105,9 +105,12 @@ export interface Mission {
terrain: string; terrain: string;
} }
export type MissionParamValue = number | string | boolean;
export interface MissionRotationEntry { export interface MissionRotationEntry {
name: string; name: string;
difficulty: string; difficulty: string;
params: Record<string, MissionParamValue>;
} }
export interface MissionsResponse { export interface MissionsResponse {
@@ -121,17 +124,24 @@ export interface Mod {
path: string; path: string;
size_bytes: number; size_bytes: number;
enabled: boolean; enabled: boolean;
is_server_mod: boolean;
display_name: string | null; display_name: string | null;
workshop_id: string | null; workshop_id: string | null;
} }
export interface EnabledModEntry {
name: string;
is_server_mod: boolean;
}
export interface FieldSchema { export interface FieldSchema {
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list"; widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value";
label?: string; label?: string;
placeholder?: string; placeholder?: string;
min?: number; min?: number;
max?: number; max?: number;
options?: string[]; options?: string[];
advanced?: boolean;
} }
export interface ConfigSchema { export interface ConfigSchema {
@@ -377,7 +387,7 @@ export function useDeleteMission(serverId: number) {
export function useSetEnabledMods(serverId: number) { export function useSetEnabledMods(serverId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (mods: string[]) => mutationFn: (mods: EnabledModEntry[]) =>
apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }), apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mods", serverId] }); queryClient.invalidateQueries({ queryKey: ["mods", serverId] });