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

59
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
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)
**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:**
```json
@@ -806,10 +816,11 @@ Returns per-field widget hints for the frontend config editor. Used by `ConfigEd
"hostname": { "widget": "text", "label": "Server Hostname" },
"max_players": { "widget": "number", "label": "Max Players", "min": 1, "max": 1000 },
"password": { "widget": "password", "label": "Player Password" },
"forced_difficulty": { "widget": "select", "label": "Difficulty Preset", "options": ["Recruit", "Regular", "Veteran", "Custom"] },
"forced_difficulty": { "widget": "select", "label": "Difficulty Preset", "options": ["0 - Recruit", "1 - Regular", "2 - Veteran", "3 - Custom"] },
"battleye": { "widget": "toggle", "label": "BattleEye Anti-Cheat" },
"motd_lines": { "widget": "textarea", "label": "Message of the Day (one line per row)" },
"admin_uids": { "widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000" }
"admin_uids": { "widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000" },
"missions": { "widget": "hidden", "label": "Missions" }
},
"rcon": {
"rcon_password": { "widget": "password", "label": "RCon Password" }
@@ -1363,18 +1374,30 @@ List all available mods and which are currently enabled for this server.
"mods": [
{
"name": "@CBA_A3",
"folder_path": "C:/Arma3Server/@CBA_A3",
"enabled": true
"path": "D:/Arma3Server/1/mods/@CBA_A3",
"size_bytes": 12345678,
"enabled": true,
"is_server_mod": false,
"display_name": "Community Base Addons A3",
"workshop_id": "450814997"
},
{
"name": "@ACRE2",
"folder_path": "C:/Arma3Server/@ACRE2",
"enabled": true
"path": "D:/Arma3Server/1/mods/@ACRE2",
"size_bytes": 9876543,
"enabled": true,
"is_server_mod": true,
"display_name": "ACRE2",
"workshop_id": "751965892"
},
{
"name": "@USAF",
"folder_path": "C:/Arma3Server/@USAF",
"enabled": false
"path": "D:/Arma3Server/1/mods/@USAF",
"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
@@ -1394,13 +1419,18 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
```json
{
"mods": ["@CBA_A3", "@ACRE2"]
"mods": [
{ "name": "@CBA_A3", "is_server_mod": false },
{ "name": "@ACRE2", "is_server_mod": true }
]
}
```
| 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:**
@@ -1409,7 +1439,10 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
"success": true,
"data": {
"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
}

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.
- **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.
- **MissionManager**: Handles `.pbo` mission files and mission rotation config.
- **ModManager**: Builds `-mod=` and `-serverMod=` CLI arguments from mod list.
@@ -782,3 +782,5 @@ 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.
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
```bash
# Backend (from backend/)
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# Backend (from backend/, venv must be active)
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# Frontend (from frontend/)
npx vite --host
npm run dev
```
- Backend API: http://localhost:8000 (docs: http://localhost:8000/docs)
- Frontend: http://localhost:5173
- 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)
- Frontend: http://localhost:5173 (Vite proxies /api and /ws to :8000)
- Default admin: `admin` / (random, printed at first startup)
- Full setup instructions (secrets, venv, debug configs): see README.md
## Architecture
@@ -89,4 +90,18 @@ cd frontend && npx tsc --noEmit
- `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
- 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"]` |
| `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"]` |
| `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` |
| `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]` |
| `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]` |
| `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 |
| `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 |
@@ -219,7 +219,8 @@ All server data flows through TanStack Query hooks:
**Key type notes**:
- `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
- `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)
- 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 |
|---|---|---|
| `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 |
| `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 |
| `remote_admin.py` | `Arma3RemoteAdmin` + `Arma3RemoteAdminFactory` | Implements RemoteAdmin protocol using BERConClient |
| `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 |
| `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 |
| `missions_router.py` | Mission list, .pbo upload (500MB), delete, GET/PUT rotation |
| `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 |
| `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 |
| `metrics_collector.py` | `MetricsCollectorThread` — psutil CPU/RAM collection every 10s |
| `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
- **Zustand 5** — client state (auth, UI)
- **Tailwind CSS** — dark neumorphic design system
- **Playwright** — E2E testing (38 tests)
- **Vitest** + **React Testing Library** — unit tests (167 tests)
- **Playwright** — E2E testing
- **Vitest** + **React Testing Library** — unit tests (173 tests)
## Quick Start
### Backend
### 1 — Backend setup
```bash
cd backend
# Create and activate a virtual environment
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
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
cd frontend
@@ -45,28 +98,64 @@ npm install
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
### 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
cd frontend
npm test # Watch mode
npx vitest run # Single run
npx vitest run --coverage # With coverage
npm test # single run (CI-friendly)
npm run test:watch # watch mode during development
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
cd frontend
# Start backend + frontend dev server first
npx playwright test # All tests (mocked + integration)
npx playwright tests-e2e/integration/ # Full-stack integration tests only
npm run test:e2e # all E2E tests (headless)
npm run test:e2e:ui # Playwright UI mode (interactive, great for debugging)
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
```
@@ -101,7 +190,7 @@ languard-servers-manager/
│ │ ├── hooks/ # useServers, useServerDetail, useAuth, useGames, useWebSocket
│ │ ├── store/ # auth.store, ui.store (Zustand)
│ │ ├── lib/ # api.ts (Axios client)
│ │ └── __tests__/ # Vitest unit tests (~120 tests)
│ │ └── __tests__/ # Vitest unit tests (173 tests)
│ ├── tests-e2e/ # Playwright E2E tests
│ └── playwright.config.ts
├── 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:
- 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)
- Parses each line using `RPTParser.parse_line()` to extract timestamp, level, and message
- Persists parsed entries to the `logs` table via `LogRepository`

View File

@@ -6,13 +6,21 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from typing import Any, Union
from pydantic import BaseModel, Field
MissionParamValue = Union[int, float, str, bool]
# ─── Pydantic Models (config schema) ─────────────────────────────────────────
class MissionRotationItem(BaseModel):
name: str
difficulty: str = ""
params: dict[str, MissionParamValue] = Field(default_factory=dict)
class ServerConfig(BaseModel):
hostname: str = "My Arma 3 Server"
password: str | None = None
@@ -57,17 +65,18 @@ class ServerConfig(BaseModel):
headless_clients: list[str] = Field(default_factory=list)
local_clients: 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):
min_bandwidth: int = Field(default=800000, gt=0)
max_bandwidth: int = Field(default=25000000, gt=0)
max_msg_send: int = Field(default=384, gt=0)
min_bandwidth: int = Field(default=131072, gt=0)
max_bandwidth: int = Field(default=10000000000, gt=0)
max_msg_send: int = Field(default=128, gt=0)
max_size_guaranteed: int = Field(default=512, gt=0)
max_size_non_guaranteed: int = Field(default=256, gt=0)
min_error_to_send: float = Field(default=0.003, gt=0)
max_custom_file_size: int = Field(default=100000, ge=0)
min_error_to_send: float = Field(default=0.001, gt=0)
max_custom_file_size: int = Field(default=0, ge=0)
class ProfileConfig(BaseModel):
@@ -77,16 +86,16 @@ class ProfileConfig(BaseModel):
enemy_tags: 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)
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)
weapon_info: 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)
vision_aid: 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)
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)
von_id: int = Field(default=1, ge=0, le=1)
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)
multiple_saves: int = Field(default=0, ge=0, le=1)
ai_level_preset: int = Field(default=3, ge=0, le=4)
skill_ai: float = Field(default=0.5, ge=0.0, le=1.0)
precision_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.2, ge=0.0, le=1.0)
class LaunchConfig(BaseModel):
@@ -151,20 +160,64 @@ class Arma3ConfigGenerator:
return self.SENSITIVE_FIELDS.get(section, [])
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:
"""
For version 1.0.0 there is nothing to migrate.
Future versions: add migration logic here.
"""
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(
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 ───────────────────────────────────────────────────
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
def _escape(value: str) -> str:
"""
@@ -255,7 +308,7 @@ class Arma3ConfigGenerator:
if cfg.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:
return (
@@ -348,20 +401,36 @@ class Arma3ConfigGenerator:
self,
config_sections: dict[str, dict],
mod_args: list[str] | None = None,
server_dir: Path | None = None,
) -> list[str]:
from adapters.exceptions import LaunchArgsError
launch = LaunchConfig(**config_sections.get("launch", {}))
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 = [
f"-port={config_sections.get('_port', 2302)}",
"-config=server.cfg",
"-cfg=basic.cfg",
"-profiles=./server",
config_arg,
cfg_arg,
profiles_arg,
"-name=server",
f"-world={launch.world}",
f"-limitFPS={launch.limit_fps}",
"-bepath=./battleye",
bepath_arg,
]
if launch.auto_init:
args.append("-autoInit")
@@ -384,33 +453,148 @@ class Arma3ConfigGenerator:
return args
def get_ui_schema(self) -> dict:
B, A = False, True # basic / advanced shorthand
return {
"server": {
"hostname": {"widget": "text", "label": "Server Hostname"},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
"password": {"widget": "password", "label": "Player Password"},
"password_admin": {"widget": "password", "label": "Admin Password"},
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"},
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"},
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)",
"min": 0, "max": 2},
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
# Identity — basic
"hostname": {"widget": "text", "label": "Server Name", "advanced": B},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000, "advanced": B},
"password": {"widget": "password", "label": "Join Password", "advanced": B},
"password_admin": {"widget": "password", "label": "Admin Password", "advanced": B},
"server_command_password": {"widget": "password", "label": "Server Command Password", "advanced": A},
# Message of the Day — basic
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)", "advanced": B},
"motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1, "advanced": B},
# Mission / Rotation — basic
"forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
"options": ["Recruit", "Regular", "Veteran", "Custom"], "advanced": B},
"auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission", "advanced": B},
"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"},
"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": {
"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": {
# All launch/startup fields are advanced
"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": "-limitFPS=100"},
"placeholder": "-filePatching", "advanced": A},
},
"rcon": {
"rcon_password": {"widget": "password", "label": "RCon Password"},
"max_ping": {"widget": "number", "label": "RCon Port"},
"rcon_password": {"widget": "password", "label": "RCon Password", "advanced": B},
"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:
return get_server_dir(self._server_id)
def _mods_dir(self) -> Path:
return get_server_dir(self._server_id) / "mods"
# ── File / DB operations ──
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:
name: str — directory name (e.g. "@CBA_A3")
path: str — absolute directory path
size_bytes: int — total directory size (approximate, non-recursive)
"""
server_dir = self._server_dir()
if not server_dir.exists():
mods_dir = self._mods_dir()
if not mods_dir.exists():
return []
mods = []
try:
for entry in server_dir.iterdir():
for entry in mods_dir.iterdir():
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
try:
size = sum(
@@ -87,54 +90,59 @@ class Arma3ModManager:
mods.sort(key=lambda m: m["name"].lower())
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:
config_repo: ConfigRepository instance.
Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]).
Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...]
Handles migration from old string-list format automatically.
"""
mods_section = config_repo.get_section(self._server_id, "mods")
if mods_section is None:
return []
enabled = mods_section.get("enabled_mods", [])
if isinstance(enabled, str):
enabled = [m.strip() for m in enabled.split(",") if m.strip()]
return enabled
raw = mods_section.get("enabled_mods", [])
result = []
for item in raw:
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.
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.
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()}
for name in mod_names:
for entry in mod_entries:
name = entry.get("name", "")
if not _MOD_DIR_PATTERN.match(name):
raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
if name not in available:
raise AdapterError(
f"Mod '{name}' not found in server directory. "
f"Mod '{name}' not found in mods directory. "
f"Available: {sorted(available)}"
)
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(
server_id=self._server_id,
game_type="arma3",
section="mods",
data={"enabled_mods": mod_names},
expected_version=current_version,
config_data={"enabled_mods": mod_entries},
schema_version="1.0.0",
expected_config_version=current_version,
)
logger.info(
"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 ──

View File

@@ -27,4 +27,50 @@ class Arma3ProcessConfig:
def get_server_dir_layout(self) -> list[str]:
"""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
class RemoteAdminClient(Protocol):

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
@@ -10,7 +11,6 @@ from sqlalchemy.engine import Connection
from adapters.registry import GameAdapterRegistry
from core.dal.server_repository import ServerRepository
from core.utils.file_utils import get_server_dir
from database import get_db
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"])
if not adapter.has_capability("log_parser"):
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("")

View File

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

View File

@@ -24,8 +24,13 @@ def _ok(data):
return {"success": True, "data": data, "error": None}
class EnabledModEntry(BaseModel):
name: str
is_server_mod: bool = False
class SetEnabledModsRequest(BaseModel):
mods: list[str]
mods: list[EnabledModEntry]
def _get_mod_manager(server_id: int, game_type: str):
@@ -52,12 +57,15 @@ def list_mods(
config_repo = ConfigRepository(db)
try:
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:
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:
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({
"server_id": server_id,
@@ -83,7 +91,7 @@ def set_enabled_mods(
config_repo = ConfigRepository(db)
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:
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
except ValueError as exc:
@@ -97,5 +105,5 @@ def set_enabled_mods(
return _ok({
"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,
)
# Create directory layout
# Create directory layout with per-directory README files
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
config_gen = adapter.get_config_generator()
@@ -242,17 +243,17 @@ class ServerService:
# Get mod args if adapter supports mods
mod_args: list[str] = []
if adapter.has_capability("mod_manager"):
from sqlalchemy import text
mods = self._db.execute(
text("""
SELECT m.folder_path, sm.is_server_mod, sm.sort_order
FROM server_mods sm JOIN mods m ON m.id = sm.mod_id
WHERE sm.server_id = :sid ORDER BY sm.sort_order
"""),
{"sid": server_id},
).fetchall()
mod_list = [dict(r._mapping) for r in mods]
mod_args = adapter.get_mod_manager().build_mod_args(mod_list)
mod_mgr = adapter.get_mod_manager(server_id)
enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
server_dir = get_server_dir(server_id)
mod_list = [
{
"folder_path": str(server_dir / "mods" / m["name"]),
"game_data": {"is_server_mod": m.get("is_server_mod", False)},
}
for m in enabled_mods
]
mod_args = mod_mgr.build_mod_args(mod_list)
# Write config files (atomic)
server_dir = get_server_dir(server_id)
@@ -273,7 +274,7 @@ class ServerService:
# Build launch args
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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -468,6 +469,8 @@ class ServerService:
if data is None:
data = config_gen.get_defaults(section)
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
for field in sensitive:
if field in data and data[field]:

View File

@@ -159,16 +159,14 @@ class ThreadRegistry:
game_type = server["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
if adapter.has_capability("log_parser"):
log_parser = adapter.get_log_parser()
# Try to resolve log path via the adapter's log file resolver
from core.utils.file_utils import get_server_dir
server_dir = get_server_dir(server_id)
if server_dir.exists():
from pathlib import Path
exe_dir = Path(server["exe_path"]).parent
resolver = log_parser.get_log_file_resolver(server_id)
resolved = resolver(server_dir)
resolved = resolver(exe_dir)
if resolved is not None:
log_path = str(resolved)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import Callable
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)
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.
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.mkdir(parents=True, exist_ok=True)
if 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:

View File

@@ -90,7 +90,24 @@ async def lifespan(app: FastAPI):
except Exception as 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
with engine.connect() as db:
svc = AuthService(db)
@@ -104,7 +121,7 @@ async def lifespan(app: FastAPI):
logger.warning(" Change this password immediately!")
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.cleanup_jobs import 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 type { FieldSchema } from "@/hooks/useServerDetail";
import { TagListEditor } from "@/components/ui/TagListEditor";
import { MissionParamsEditor } from "@/components/servers/MissionParamsEditor";
import { useAuthStore } from "@/store/auth.store";
import { useUIStore } from "@/store/ui.store";
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 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) {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const addNotification = useUIStore((s) => s.addNotification);
const { data: configMap, isLoading } = useServerConfig(serverId);
const { data: schema } = useServerConfigSchema(serverId);
const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : [];
const [activeSection, setActiveSection] = useState<string>(sections[0] ?? "");
@@ -32,30 +50,46 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
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 (
<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) => (
<button
key={section}
onClick={() => setActiveSection(section)}
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
? "bg-accent text-text-inverse"
: "text-text-secondary hover:text-text-primary hover:bg-surface-overlay",
)}
>
{section}
{SECTION_LABELS[section] ?? section}
</button>
))}
</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 && (
<ConfigSectionForm
key={currentSection}
serverId={serverId}
section={currentSection}
sectionSchema={schema?.[currentSection] ?? {}}
isAdmin={isAdmin}
addNotification={addNotification}
/>
@@ -67,19 +101,21 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
function ConfigSectionForm({
serverId,
section,
sectionSchema,
isAdmin,
addNotification,
}: {
serverId: number;
section: string;
sectionSchema: Record<string, FieldSchema>;
isAdmin: boolean;
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
}) {
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
const { data: schema } = useServerConfigSchema(serverId);
const updateSection = useUpdateConfigSection(serverId, section);
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
if (isLoading) {
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>;
}
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 displayValues = editValues ?? Object.fromEntries(fields);
const isEditing = editValues !== null;
const sectionSchema = schema?.[section] ?? {};
const handleEdit = () => {
setEditValues(Object.fromEntries(fields));
@@ -106,7 +157,11 @@ function ConfigSectionForm({
};
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 {
await updateSection.mutateAsync({
config_version: meta.config_version,
@@ -142,9 +197,19 @@ function ConfigSectionForm({
return (
<div className="space-y-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<p className="text-text-muted text-xs">
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 && (
<button onClick={handleEdit} className="btn-ghost text-sm">
Edit
@@ -159,7 +224,7 @@ function ConfigSectionForm({
const rawValue = displayValues[key];
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>
{isEditing ? (
<FieldWidget
@@ -171,6 +236,22 @@ function ConfigSectionForm({
onTogglePassword={() => toggleShowPassword(key)}
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">
{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 (
<select
className="neu-input flex-1 text-sm"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
value={selectedOpt}
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>
))}
</select>
);
}
case "toggle":
return (
<input
type="checkbox"
className="w-5 h-5 accent-accent"
<ToggleSwitch
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":
return (
<input
type="number"
className="neu-input flex-1 text-sm"
value={String(value ?? "")}
value={value === null || value === undefined ? "" : String(value)}
min={fieldSchema?.min}
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 {
if (Array.isArray(value)) return value.join(", ") || "--";
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 {
return key
.replace(/_/g, " ")

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { Upload, Trash2, Plus, X, Save } from "lucide-react";
import { Fragment, useState, useRef, useEffect } from "react";
import { Upload, Trash2, Plus, X, Save, ChevronDown, ChevronRight } from "lucide-react";
import { MissionParamsEditor } from "./MissionParamsEditor";
import {
useServerMissions,
@@ -9,7 +10,7 @@ import {
useDeleteMission,
useServerConfigSection,
} from "@/hooks/useServerDetail";
import type { MissionRotationEntry } from "@/hooks/useServerDetail";
import type { MissionRotationEntry, MissionParamValue } from "@/hooks/useServerDetail";
import { useAuthStore } from "@/store/auth.store";
import { useUIStore } from "@/store/ui.store";
import { logger } from "@/lib/logger";
@@ -39,6 +40,7 @@ export function MissionList({ serverId }: MissionListProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
const [expandedParamsIdx, setExpandedParamsIdx] = useState<number | null>(null);
// Sync rotation from query on load
useEffect(() => {
@@ -81,7 +83,7 @@ export function MissionList({ serverId }: MissionListProps) {
const addToRotation = (missionName: string) => {
if (rotation.some((r) => r.name === missionName)) return;
setRotation([...rotation, { name: missionName, difficulty: "" }]);
setRotation([...rotation, { name: missionName, difficulty: "", params: {} }]);
};
const removeFromRotation = (idx: number) => {
@@ -92,6 +94,10 @@ export function MissionList({ serverId }: MissionListProps) {
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 () => {
try {
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">
{/* Section A: Available Missions */}
<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">
Available Missions ({missions.length})
</h3>
@@ -134,6 +140,9 @@ export function MissionList({ serverId }: MissionListProps) {
</label>
)}
</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 && (
<div className="mb-3 space-y-1">
@@ -195,10 +204,10 @@ export function MissionList({ serverId }: MissionListProps) {
onClick={() => addToRotation(mission.name)}
disabled={rotation.some((r) => r.name === mission.name)}
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} />
Rotation
{rotation.some((r) => r.name === mission.name) ? "In Rotation" : "Add to Rotation"}
</button>
<button
onClick={() => handleDelete(mission.filename)}
@@ -221,7 +230,7 @@ export function MissionList({ serverId }: MissionListProps) {
{/* Section B: Mission Rotation */}
<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">
Mission Rotation ({rotation.length})
</h3>
@@ -245,6 +254,9 @@ export function MissionList({ serverId }: MissionListProps) {
</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">
<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">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">Params</th>
{isAdmin && (
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
)}
@@ -262,16 +275,18 @@ export function MissionList({ serverId }: MissionListProps) {
<tbody>
{rotation.length === 0 ? (
<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.
</td>
</tr>
) : (
rotation.map((entry, idx) => {
const missionFile = missions.find((m) => m.name === entry.name);
const paramCount = Object.keys(entry.params ?? {}).length;
const isExpanded = expandedParamsIdx === idx;
return (
<Fragment key={`${entry.name}-${idx}`}>
<tr
key={`${entry.name}-${idx}`}
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>
@@ -302,6 +317,22 @@ export function MissionList({ serverId }: MissionListProps) {
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
)}
</td>
<td className="px-3 py-2">
<button
onClick={() => setExpandedParamsIdx(isExpanded ? null : idx)}
className="btn-ghost text-xs flex items-center gap-1"
title={isExpanded ? "Hide parameters" : "Edit parameters"}
>
{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>
</td>
{isAdmin && (
<td className="text-right px-3 py-2">
<button
@@ -314,6 +345,23 @@ export function MissionList({ serverId }: MissionListProps) {
</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>
)}
</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 { Save } from "lucide-react";
import { Save, Server } from "lucide-react";
import { useServerMods, useSetEnabledMods } 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)));
};
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 && (
selected.map((m) => m.name).sort().join(",") !==
(modsData.mods.filter((m) => m.enabled).map((m) => m.name).sort().join(","))
_selectedKey(selected) !==
_selectedKey(modsData.mods.filter((m) => m.enabled))
);
const handleApply = async () => {
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.` });
} catch (err) {
logger.error("ModList", "Failed to apply mods: %s", err);
@@ -144,6 +155,7 @@ export function ModList({ serverId }: ModListProps) {
mod={mod}
actionLabel="←"
onAction={isAdmin ? () => moveToAvailable(mod) : undefined}
onToggleServerMod={isAdmin ? () => toggleServerMod(mod.name) : undefined}
selected
/>
))
@@ -159,17 +171,17 @@ function ModRow({
mod,
actionLabel,
onAction,
onToggleServerMod,
selected = false,
}: {
mod: Mod;
actionLabel: string;
onAction?: () => void;
onToggleServerMod?: () => void;
selected?: boolean;
}) {
return (
<div
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
>
<div 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">
<p className="text-text-primary text-sm font-medium truncate">
{mod.display_name ?? mod.name}
@@ -186,6 +198,21 @@ function ModRow({
<span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span>
</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 && (
<button
type="button"

View File

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