diff --git a/API.md b/API.md index 436c3f4..30328e0 100644 --- a/API.md +++ b/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" } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d5d85ac..f8ceaef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. @@ -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. -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b2f3811..e65747d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` \ No newline at end of file +- **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. \ No newline at end of file diff --git a/FRONTEND.md b/FRONTEND.md index 33fb99a..a8c54ff 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -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]` | diff --git a/MODULES.md b/MODULES.md index c9bc752..22a1430 100644 --- a/MODULES.md +++ b/MODULES.md @@ -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 | diff --git a/THREADING.md b/THREADING.md index 09274d8..361cb6e 100644 --- a/THREADING.md +++ b/THREADING.md @@ -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` diff --git a/backend/adapters/arma3/config_generator.py b/backend/adapters/arma3/config_generator.py index 2f57027..002628d 100644 --- a/backend/adapters/arma3/config_generator.py +++ b/backend/adapters/arma3/config_generator.py @@ -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"}, }, } diff --git a/backend/core/servers/logfiles_router.py b/backend/core/servers/logfiles_router.py index 208ffa3..7d8b117 100644 --- a/backend/core/servers/logfiles_router.py +++ b/backend/core/servers/logfiles_router.py @@ -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("") diff --git a/backend/core/threads/thread_registry.py b/backend/core/threads/thread_registry.py index e06db30..3c2d170 100644 --- a/backend/core/threads/thread_registry.py +++ b/backend/core/threads/thread_registry.py @@ -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, diff --git a/frontend/src/components/servers/ConfigEditor.tsx b/frontend/src/components/servers/ConfigEditor.tsx index c0c5e7a..def2b43 100644 --- a/frontend/src/components/servers/ConfigEditor.tsx +++ b/frontend/src/components/servers/ConfigEditor.tsx @@ -14,6 +14,22 @@ interface ConfigEditorProps { const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]); +const SECTION_LABELS: Record = { + server: "Server", + basic: "Network", + profile: "Difficulty", + launch: "Startup", + rcon: "RCon", +}; + +const SECTION_DESCRIPTIONS: Record = { + 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 (
-
+
{sections.map((section) => ( ))}
+ {SECTION_DESCRIPTIONS[currentSection] && ( +

{SECTION_DESCRIPTIONS[currentSection]}

+ )} + {currentSection && ( No data for this section.
; } - 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" ? ( +
+ +
+ ) : widget === "select" ? ( + + {formatSelectDisplay(rawValue, fieldSchema)} + ) : ( {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 ( ); + } 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 ( + + + {on ? "Enabled" : "Disabled"} + + ); +} + 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, " ") diff --git a/frontend/src/components/servers/MissionList.tsx b/frontend/src/components/servers/MissionList.tsx index ad65670..168b325 100644 --- a/frontend/src/components/servers/MissionList.tsx +++ b/frontend/src/components/servers/MissionList.tsx @@ -114,7 +114,7 @@ export function MissionList({ serverId }: MissionListProps) {
{/* Section A: Available Missions */}
-
+

Available Missions ({missions.length})

@@ -134,6 +134,9 @@ export function MissionList({ serverId }: MissionListProps) { )}
+

+ Upload .pbo mission files, then click + Add to Rotation to schedule them for the server. +

{uploadProgress.length > 0 && (
@@ -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"} > - Rotation + {rotation.some((r) => r.name === mission.name) ? "In Rotation" : "Add to Rotation"}