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:
17
API.md
17
API.md
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
@@ -89,4 +89,5 @@ 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). The `missions` field in the server section is marked `hidden` because mission rotation is managed via the dedicated Missions tab.
|
||||
@@ -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]` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -386,31 +386,140 @@ class Arma3ConfigGenerator:
|
||||
def get_ui_schema(self) -> dict:
|
||||
return {
|
||||
"server": {
|
||||
"hostname": {"widget": "text", "label": "Server Hostname"},
|
||||
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
|
||||
"password": {"widget": "password", "label": "Player Password"},
|
||||
"password_admin": {"widget": "password", "label": "Admin Password"},
|
||||
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
|
||||
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
|
||||
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
|
||||
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"},
|
||||
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"},
|
||||
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)",
|
||||
"min": 0, "max": 2},
|
||||
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
|
||||
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
|
||||
"placeholder": "76561198000000000"},
|
||||
# Identity
|
||||
"hostname": {"widget": "text", "label": "Server Name"},
|
||||
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
|
||||
"password": {"widget": "password", "label": "Join Password"},
|
||||
"password_admin": {"widget": "password", "label": "Admin Password"},
|
||||
"server_command_password": {"widget": "password", "label": "Server Command Password"},
|
||||
# Message of the Day
|
||||
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
|
||||
"motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1},
|
||||
# Mission / Rotation
|
||||
"forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
|
||||
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
|
||||
"auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission"},
|
||||
"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 (0–30)", "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.0–1.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": {
|
||||
"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.0–1.0)", "min": 0, "max": 1},
|
||||
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.0–1.0)", "min": 0, "max": 1},
|
||||
},
|
||||
"launch": {
|
||||
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
|
||||
"placeholder": "-limitFPS=100"},
|
||||
"world": {"widget": "text", "label": "Default World (map name)"},
|
||||
"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_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"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -159,18 +159,16 @@ 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():
|
||||
resolver = log_parser.get_log_file_resolver(server_id)
|
||||
resolved = resolver(server_dir)
|
||||
if resolved is not None:
|
||||
log_path = str(resolved)
|
||||
from pathlib import Path
|
||||
exe_dir = Path(server["exe_path"]).parent
|
||||
resolver = log_parser.get_log_file_resolver(server_id)
|
||||
resolved = resolver(exe_dir)
|
||||
if resolved is not None:
|
||||
log_path = str(resolved)
|
||||
|
||||
bundle: dict = {
|
||||
"log_tail": None,
|
||||
|
||||
@@ -14,6 +14,22 @@ 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);
|
||||
@@ -34,23 +50,27 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{currentSection && (
|
||||
<ConfigSectionForm
|
||||
key={currentSection}
|
||||
@@ -89,11 +109,15 @@ 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 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 displayValues = editValues ?? Object.fromEntries(fields);
|
||||
const isEditing = editValues !== null;
|
||||
const sectionSchema = schema?.[section] ?? {};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditValues(Object.fromEntries(fields));
|
||||
@@ -171,6 +195,14 @@ function ConfigSectionForm({
|
||||
onTogglePassword={() => toggleShowPassword(key)}
|
||||
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">
|
||||
{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 (
|
||||
<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 (
|
||||
@@ -241,7 +288,7 @@ function FieldWidget({
|
||||
type="checkbox"
|
||||
className="w-5 h-5 accent-accent"
|
||||
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 {
|
||||
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, " ")
|
||||
|
||||
@@ -114,7 +114,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 +134,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 +198,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 +224,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 +248,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, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -126,7 +126,7 @@ export interface Mod {
|
||||
}
|
||||
|
||||
export interface FieldSchema {
|
||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list";
|
||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden";
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
|
||||
Reference in New Issue
Block a user