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
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-18 15:56:04 +07:00
parent b7d670a91c
commit bf09a6ed1c
12 changed files with 253 additions and 56 deletions

17
API.md
View File

@@ -792,10 +792,20 @@ Get all config sections combined. Sensitive fields (passwords) are masked with `
### GET /servers/{server_id}/config/schema ### GET /servers/{server_id}/config/schema
Returns per-field widget hints for the frontend config editor. Used by `ConfigEditor` to render the correct UI widget (text box, toggle, select, tag list, etc.) for each field. Returns per-field widget hints for the frontend config editor. Used by `ConfigEditor` to render the correct UI widget for each field. Covers all ~80 Arma 3 config fields across 5 sections.
**Auth:** Required (any role) **Auth:** Required (any role)
**Widget types:**
- `text` — Text input
- `password` — Password input (masked)
- `number` — Numeric input with optional `min`/`max`
- `toggle` — Boolean toggle (0/1)
- `select` — Dropdown with `options` array. Options may be `["value1", "value2"]` or `["0 - Never", "1 - Always"]` format
- `textarea` — Multi-line text area
- `tag-list` — Dynamic string list (add/remove items)
- `hidden` — Field not displayed in UI (managed elsewhere; e.g., `missions` managed by Missions tab)
**Response 200:** **Response 200:**
```json ```json
@@ -806,10 +816,11 @@ Returns per-field widget hints for the frontend config editor. Used by `ConfigEd
"hostname": { "widget": "text", "label": "Server Hostname" }, "hostname": { "widget": "text", "label": "Server Hostname" },
"max_players": { "widget": "number", "label": "Max Players", "min": 1, "max": 1000 }, "max_players": { "widget": "number", "label": "Max Players", "min": 1, "max": 1000 },
"password": { "widget": "password", "label": "Player Password" }, "password": { "widget": "password", "label": "Player Password" },
"forced_difficulty": { "widget": "select", "label": "Difficulty Preset", "options": ["Recruit", "Regular", "Veteran", "Custom"] }, "forced_difficulty": { "widget": "select", "label": "Difficulty Preset", "options": ["0 - Recruit", "1 - Regular", "2 - Veteran", "3 - Custom"] },
"battleye": { "widget": "toggle", "label": "BattleEye Anti-Cheat" }, "battleye": { "widget": "toggle", "label": "BattleEye Anti-Cheat" },
"motd_lines": { "widget": "textarea", "label": "Message of the Day (one line per row)" }, "motd_lines": { "widget": "textarea", "label": "Message of the Day (one line per row)" },
"admin_uids": { "widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000" } "admin_uids": { "widget": "tag-list", "label": "Admin Steam UIDs", "placeholder": "76561198000000000" },
"missions": { "widget": "hidden", "label": "Missions" }
}, },
"rcon": { "rcon": {
"rcon_password": { "widget": "password", "label": "RCon Password" } "rcon_password": { "widget": "password", "label": "RCon Password" }

View File

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

View File

@@ -89,4 +89,5 @@ cd frontend && npx tsc --noEmit
- `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service - `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service
- `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries - `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries
- Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`) - Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`)
- Log directory defaults to `ARMA3_LOG_DIR` env var, falls back to `{server_dir}/logs` - **Arma 3 log files** are located at `{exe_path_parent}/server/*.rpt` (next to the .exe), NOT in languard's `servers/{id}/` data directory. Code that finds log files must use `Path(server["exe_path"]).parent` to resolve the log directory.
- Config UI schema now covers all ~80 Arma 3 fields across 5 sections (server, basic, profile, launch, rcon) with per-field widget hints (text, toggle, select, number, password, tag-list, hidden, textarea). The `missions` field in the server section is marked `hidden` because mission rotation is managed via the dedicated Missions tab.

View File

@@ -175,7 +175,7 @@ All server data flows through TanStack Query hooks:
|---|---|---|---| |---|---|---|---|
| `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` | | `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` |
| `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` | | `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` |
| `useServerConfigSchema(id)` | Query | `GET /api/servers/:id/config/schema` | `["servers", id, "config", "schema"]` | | `useServerConfigSchema(id)` | Query | `GET /api/servers/:id/config/schema` (per-field widget hints for ~80 fields: text, toggle, select, number, password, tag-list, hidden, textarea) | `["servers", id, "config", "schema"]` |
| `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` | | `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` |
| `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` | | `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` |
| `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` | | `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` |

View File

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

View File

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

View File

@@ -386,31 +386,140 @@ class Arma3ConfigGenerator:
def get_ui_schema(self) -> dict: def get_ui_schema(self) -> dict:
return { return {
"server": { "server": {
"hostname": {"widget": "text", "label": "Server Hostname"}, # Identity
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000}, "hostname": {"widget": "text", "label": "Server Name"},
"password": {"widget": "password", "label": "Player Password"}, "max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
"password_admin": {"widget": "password", "label": "Admin Password"}, "password": {"widget": "password", "label": "Join Password"},
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"}, "password_admin": {"widget": "password", "label": "Admin Password"},
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset", "server_command_password": {"widget": "password", "label": "Server Command Password"},
"options": ["Recruit", "Regular", "Veteran", "Custom"]}, # Message of the Day
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, "motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, "motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1},
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)", # Mission / Rotation
"min": 0, "max": 2}, "forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"}, "options": ["Recruit", "Regular", "Veteran", "Custom"]},
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs", "auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission"},
"placeholder": "76561198000000000"}, "random_mission_order": {"widget": "toggle", "label": "Random Mission Order"},
# Behaviour
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
"kick_duplicate": {"widget": "toggle", "label": "Kick Duplicate Connections"},
"skip_lobby": {"widget": "toggle", "label": "Skip Lobby (go straight to briefing)"},
"drawing_in_map": {"widget": "toggle", "label": "Allow Drawing in Map"},
# Security
"battleye": {"widget": "toggle", "label": "BattlEye Anti-Cheat"},
"verify_signatures": {"widget": "select", "label": "Verify Addon Signatures",
"options": ["0 - Off", "1 - Kick unsigned", "2 - Strict (kick mismatched)"]},
"allowed_file_patching": {"widget": "select", "label": "Allow File Patching",
"options": ["0 - Nobody", "1 - Lobby only", "2 - Everyone"]},
# Voice
"disable_von": {"widget": "toggle", "label": "Disable Voice-over-Network (VoN)"},
"von_codec": {"widget": "toggle", "label": "Use Opus VoN Codec"},
"von_codec_quality": {"widget": "number", "label": "VoN Codec Quality (030)", "min": 0, "max": 30},
# Network / Kick thresholds
"kick_on_ping": {"widget": "toggle", "label": "Kick on High Ping"},
"kick_on_packet_loss": {"widget": "toggle", "label": "Kick on High Packet Loss"},
"kick_on_desync": {"widget": "toggle", "label": "Kick on High Desync"},
"kick_on_timeout": {"widget": "toggle", "label": "Kick on Timeout"},
"max_ping": {"widget": "number", "label": "Max Ping (ms)", "min": 1},
"max_packet_loss": {"widget": "number", "label": "Max Packet Loss (%)", "min": 0, "max": 100},
"max_desync": {"widget": "number", "label": "Max Desync", "min": 0},
"disconnect_timeout": {"widget": "number", "label": "Disconnect Timeout (sec)", "min": 0},
# Voting
"vote_threshold": {"widget": "number", "label": "Vote Threshold (0.01.0)", "min": 0, "max": 1},
"vote_mission_players": {"widget": "number", "label": "Min Players to Start Vote", "min": 0},
"vote_timeout": {"widget": "number", "label": "Vote Timeout (sec)", "min": 0},
# Timeouts
"role_timeout": {"widget": "number", "label": "Role Selection Timeout (sec)", "min": 0},
"briefing_timeout": {"widget": "number", "label": "Briefing Timeout (sec)", "min": 0},
"debriefing_timeout": {"widget": "number", "label": "Debriefing Timeout (sec)", "min": 0},
"lobby_idle_timeout": {"widget": "number", "label": "Lobby Idle Timeout (sec)", "min": 0},
# Misc
"statistics_enabled": {"widget": "toggle", "label": "Enable Steam Statistics"},
"upnp": {"widget": "toggle", "label": "Enable UPnP"},
"loopback": {"widget": "toggle", "label": "Loopback Mode (LAN only)"},
"timestamp_format": {"widget": "select", "label": "Log Timestamp Format",
"options": ["none", "short", "full"]},
"log_file": {"widget": "text", "label": "Log File Name"},
# Admin / Headless
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
"placeholder": "76561198000000000"},
"headless_clients": {"widget": "tag-list", "label": "Headless Client IPs",
"placeholder": "127.0.0.1"},
"local_clients": {"widget": "tag-list", "label": "Local Client IPs",
"placeholder": "127.0.0.1"},
# missions managed by the Missions tab — hidden here
"missions": {"widget": "hidden"},
}, },
"basic": { "basic": {
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"}, "min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1},
"max_bandwidth": {"widget": "number", "label": "Max Bandwidth (bps)", "min": 1},
"max_msg_send": {"widget": "number", "label": "Max Messages Sent per Frame", "min": 1},
"max_size_guaranteed": {"widget": "number", "label": "Max Guaranteed Packet Size (bytes)", "min": 1},
"max_size_non_guaranteed": {"widget": "number", "label": "Max Non-Guaranteed Packet Size (bytes)", "min": 1},
"min_error_to_send": {"widget": "number", "label": "Min Error to Send"},
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)", "min": 0},
},
"profile": {
# Damage / health
"reduced_damage": {"widget": "toggle", "label": "Reduced Damage"},
# Indicators (0=Never, 1=Limited distance, 2=Fade out, 3=Always)
"group_indicators": {"widget": "select", "label": "Group Indicators",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"friendly_tags": {"widget": "select", "label": "Friendly Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"enemy_tags": {"widget": "select", "label": "Enemy Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"detected_mines": {"widget": "select", "label": "Detected Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"commands": {"widget": "select", "label": "Map Commands",
"options": ["0 - Never", "1 - High command", "2 - Fade out", "3 - Always"]},
"waypoints": {"widget": "select", "label": "Waypoints",
"options": ["0 - Never", "1 - Known positions", "2 - Fade out", "3 - Always"]},
"tactical_ping": {"widget": "toggle", "label": "Tactical Ping"},
"weapon_info": {"widget": "select", "label": "Weapon Info",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"]},
"stamina_bar": {"widget": "toggle", "label": "Stamina Bar"},
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair"},
"vision_aid": {"widget": "toggle", "label": "Vision Aid"},
"third_person_view": {"widget": "toggle", "label": "Third Person View"},
"camera_shake": {"widget": "toggle", "label": "Camera Shake"},
"score_table": {"widget": "toggle", "label": "Show Score Table"},
"death_messages": {"widget": "toggle", "label": "Death Messages"},
"von_id": {"widget": "toggle", "label": "Show VoN Speaker ID"},
"map_content_friendly": {"widget": "select", "label": "Map — Friendly Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"map_content_enemy": {"widget": "select", "label": "Map — Enemy Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"map_content_mines": {"widget": "select", "label": "Map — Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"]},
"auto_report": {"widget": "toggle", "label": "Auto Report (automatic radio reports)"},
"multiple_saves": {"widget": "toggle", "label": "Multiple Saves"},
"ai_level_preset": {"widget": "select", "label": "AI Level Preset",
"options": ["0 - Low", "1 - Normal", "2 - High", "3 - Custom", "4 - Ultra"]},
"skill_ai": {"widget": "number", "label": "AI Skill (0.01.0)", "min": 0, "max": 1},
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.01.0)", "min": 0, "max": 1},
}, },
"launch": { "launch": {
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters", "world": {"widget": "text", "label": "Default World (map name)"},
"placeholder": "-limitFPS=100"}, "limit_fps": {"widget": "number", "label": "FPS Limit", "min": 1, "max": 1000},
"cpu_count": {"widget": "number", "label": "CPU Core Count (0 = auto)", "min": 0},
"ex_threads": {"widget": "number", "label": "Extra Thread Count", "min": 0},
"max_mem": {"widget": "number", "label": "Max RAM (MB, 0 = auto)", "min": 0},
"auto_init": {"widget": "toggle", "label": "Auto-Init (skip mission select)"},
"load_mission_to_memory": {"widget": "toggle", "label": "Load Mission to Memory"},
"enable_ht": {"widget": "toggle", "label": "Enable HyperThreading"},
"huge_pages": {"widget": "toggle", "label": "Enable Huge Pages (performance)"},
"no_logs": {"widget": "toggle", "label": "Disable Server Logging"},
"netlog": {"widget": "toggle", "label": "Enable Network Log"},
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
"placeholder": "-filePatching"},
}, },
"rcon": { "rcon": {
"rcon_password": {"widget": "password", "label": "RCon Password"}, "rcon_password": {"widget": "password", "label": "RCon Password"},
"max_ping": {"widget": "number", "label": "RCon Port"}, "max_ping": {"widget": "number", "label": "Max Ping for RCon (ms)", "min": 1},
"enabled": {"widget": "toggle", "label": "Enable RCon"},
}, },
} }

View File

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

View File

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

View File

@@ -14,6 +14,22 @@ interface ConfigEditorProps {
const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]); const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]);
const SECTION_LABELS: Record<string, string> = {
server: "Server",
basic: "Network",
profile: "Difficulty",
launch: "Startup",
rcon: "RCon",
};
const SECTION_DESCRIPTIONS: Record<string, string> = {
server: "General server settings — identity, players, security, voting, and timeouts.",
basic: "Low-level network bandwidth and packet tuning.",
profile: "Custom difficulty settings applied when Forced Difficulty is set to 'Custom'.",
launch: "Startup parameters passed to the server executable.",
rcon: "Remote console access for live server administration.",
};
export function ConfigEditor({ serverId }: ConfigEditorProps) { export function ConfigEditor({ serverId }: ConfigEditorProps) {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const addNotification = useUIStore((s) => s.addNotification); const addNotification = useUIStore((s) => s.addNotification);
@@ -34,23 +50,27 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
return ( return (
<div data-testid="config-editor"> <div data-testid="config-editor">
<div className="flex gap-1 mb-4 overflow-x-auto"> <div className="flex gap-1 mb-3 overflow-x-auto">
{sections.map((section) => ( {sections.map((section) => (
<button <button
key={section} key={section}
onClick={() => setActiveSection(section)} onClick={() => setActiveSection(section)}
className={clsx( className={clsx(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-colors capitalize", "px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
currentSection === section currentSection === section
? "bg-accent text-text-inverse" ? "bg-accent text-text-inverse"
: "text-text-secondary hover:text-text-primary hover:bg-surface-overlay", : "text-text-secondary hover:text-text-primary hover:bg-surface-overlay",
)} )}
> >
{section} {SECTION_LABELS[section] ?? section}
</button> </button>
))} ))}
</div> </div>
{SECTION_DESCRIPTIONS[currentSection] && (
<p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p>
)}
{currentSection && ( {currentSection && (
<ConfigSectionForm <ConfigSectionForm
key={currentSection} key={currentSection}
@@ -89,11 +109,15 @@ function ConfigSectionForm({
return <div className="text-text-muted text-sm">No data for this section.</div>; return <div className="text-text-muted text-sm">No data for this section.</div>;
} }
const fields = Object.entries(sectionData).filter(([key]) => key !== "_meta"); const sectionSchema = schema?.[section] ?? {};
const fields = Object.entries(sectionData).filter(([key]) => {
if (key === "_meta") return false;
if (sectionSchema[key]?.widget === "hidden") return false;
return true;
});
const meta = sectionData._meta; const meta = sectionData._meta;
const displayValues = editValues ?? Object.fromEntries(fields); const displayValues = editValues ?? Object.fromEntries(fields);
const isEditing = editValues !== null; const isEditing = editValues !== null;
const sectionSchema = schema?.[section] ?? {};
const handleEdit = () => { const handleEdit = () => {
setEditValues(Object.fromEntries(fields)); setEditValues(Object.fromEntries(fields));
@@ -171,6 +195,14 @@ function ConfigSectionForm({
onTogglePassword={() => toggleShowPassword(key)} onTogglePassword={() => toggleShowPassword(key)}
onChange={(v) => handleChange(key, v)} onChange={(v) => handleChange(key, v)}
/> />
) : widget === "toggle" ? (
<div className="flex-1 px-3 py-2">
<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>
) : ( ) : (
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2"> <span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)} {widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
@@ -222,18 +254,33 @@ 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 selectedOpt = isNumericOptions
? (options.find((opt) => parseInt(opt, 10) === Number(value)) ?? String(value ?? ""))
: String(value ?? "");
return ( return (
<select <select
className="neu-input flex-1 text-sm" className="neu-input flex-1 text-sm"
value={String(value ?? "")} value={selectedOpt}
onChange={(e) => onChange(e.target.value)} onChange={(e) => {
const raw = e.target.value;
if (isNumericOptions) {
const num = parseInt(raw, 10);
onChange(isNaN(num) ? raw : num);
} else {
onChange(raw);
}
}}
> >
{(fieldSchema?.options ?? []).map((opt) => ( {options.map((opt) => (
<option key={opt} value={opt}>{opt}</option> <option key={opt} value={opt}>{opt}</option>
))} ))}
</select> </select>
); );
}
case "toggle": case "toggle":
return ( return (
@@ -241,7 +288,7 @@ function FieldWidget({
type="checkbox" type="checkbox"
className="w-5 h-5 accent-accent" className="w-5 h-5 accent-accent"
checked={value === true || value === 1 || value === "true"} checked={value === true || value === 1 || value === "true"}
onChange={(e) => onChange(e.target.checked ? "true" : "false")} onChange={(e) => onChange(e.target.checked ? 1 : 0)}
/> />
); );
@@ -298,11 +345,32 @@ function FieldWidget({
} }
} }
function ToggleDisplay({ value }: { value: unknown }) {
const on = value === true || value === 1 || value === "true" || value === "1";
return (
<span className={clsx("inline-flex items-center gap-1.5 text-sm font-sans", on ? "text-green-400" : "text-text-muted")}>
<span className={clsx("w-3 h-3 rounded-full", on ? "bg-green-400" : "bg-surface-overlay border border-text-muted")} />
{on ? "Enabled" : "Disabled"}
</span>
);
}
function formatDisplayValue(value: unknown): string { function formatDisplayValue(value: unknown): string {
if (Array.isArray(value)) return value.join(", ") || "--"; if (Array.isArray(value)) return value.join(", ") || "--";
return String(value ?? "--"); return String(value ?? "--");
} }
function formatSelectDisplay(value: unknown, fieldSchema: FieldSchema | undefined): string {
const options = fieldSchema?.options;
if (!options?.length) return formatDisplayValue(value);
const isNumeric = /^\d+ /.test(options[0]);
if (isNumeric) {
const match = options.find((opt) => parseInt(opt, 10) === Number(value));
return match ?? String(value ?? "--");
}
return String(value ?? "--");
}
function formatLabel(key: string): string { function formatLabel(key: string): string {
return key return key
.replace(/_/g, " ") .replace(/_/g, " ")

View File

@@ -114,7 +114,7 @@ export function MissionList({ serverId }: MissionListProps) {
<div data-testid="mission-list" className="space-y-8"> <div data-testid="mission-list" className="space-y-8">
{/* Section A: Available Missions */} {/* Section A: Available Missions */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-1">
<h3 className="text-text-primary font-semibold"> <h3 className="text-text-primary font-semibold">
Available Missions ({missions.length}) Available Missions ({missions.length})
</h3> </h3>
@@ -134,6 +134,9 @@ export function MissionList({ serverId }: MissionListProps) {
</label> </label>
)} )}
</div> </div>
<p className="text-text-muted text-xs mb-3">
Upload .pbo mission files, then click <strong className="text-text-secondary">+ Add to Rotation</strong> to schedule them for the server.
</p>
{uploadProgress.length > 0 && ( {uploadProgress.length > 0 && (
<div className="mb-3 space-y-1"> <div className="mb-3 space-y-1">
@@ -195,10 +198,10 @@ export function MissionList({ serverId }: MissionListProps) {
onClick={() => addToRotation(mission.name)} onClick={() => addToRotation(mission.name)}
disabled={rotation.some((r) => r.name === mission.name)} disabled={rotation.some((r) => r.name === mission.name)}
className="btn-ghost text-xs flex items-center gap-1" className="btn-ghost text-xs flex items-center gap-1"
title="Add to rotation" title={rotation.some((r) => r.name === mission.name) ? "Already in rotation" : "Add to mission rotation"}
> >
<Plus size={12} /> <Plus size={12} />
Rotation {rotation.some((r) => r.name === mission.name) ? "In Rotation" : "Add to Rotation"}
</button> </button>
<button <button
onClick={() => handleDelete(mission.filename)} onClick={() => handleDelete(mission.filename)}
@@ -221,7 +224,7 @@ export function MissionList({ serverId }: MissionListProps) {
{/* Section B: Mission Rotation */} {/* Section B: Mission Rotation */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-1">
<h3 className="text-text-primary font-semibold"> <h3 className="text-text-primary font-semibold">
Mission Rotation ({rotation.length}) Mission Rotation ({rotation.length})
</h3> </h3>
@@ -245,6 +248,9 @@ export function MissionList({ serverId }: MissionListProps) {
</div> </div>
)} )}
</div> </div>
<p className="text-text-muted text-xs mb-3">
The server cycles through these missions in order. Set per-mission difficulty, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
</p>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">

View File

@@ -126,7 +126,7 @@ export interface Mod {
} }
export interface FieldSchema { export interface FieldSchema {
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list"; widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden";
label?: string; label?: string;
placeholder?: string; placeholder?: string;
min?: number; min?: number;