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

View File

@@ -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 (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": {
"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": {
"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"},
},
}

View File

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

View File

@@ -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,