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
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" }

View File

@@ -186,7 +186,7 @@ Implements all 7 capabilities:
- **ConfigGenerator**: Defines `server`, `rcon`, `mission` sections using Pydantic models. Writes `server.cfg` using atomic write pattern (`.tmp` then `os.replace()`). Builds launch args from config + mod list.
- **ProcessConfig**: Allows `arma3server_x64.exe` and `arma3server.exe`. Derives 4 ports from `game_port` (game, steam_query, von, steam_auth). Default game port 2302, default RCon port game+4.
- **LogParser**: Parses Arma 3 `.rpt` log files. Resolves log path from server config or defaults to `server_dir/server/*.rpt`.
- **LogParser**: Parses Arma 3 `.rpt` log files. Resolves log path using `Path(server["exe_path"]).parent / "server"` — Arma 3 writes .rpt files next to its executable, not in the languard server data directory.
- **RemoteAdmin**: Implements BattlEye RCon protocol via `Arma3RemoteAdminFactory`. Supports login, command sending, player listing, kick, ban, say-all, and shutdown.
- **MissionManager**: Handles `.pbo` mission files and mission rotation config.
- **ModManager**: Builds `-mod=` and `-serverMod=` CLI arguments from mod list.
@@ -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.
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
- `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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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