From d45345a09428622f9d3c57fd974e4af1f26f609e Mon Sep 17 00:00:00 2001 From: "Tran G. (Revernomad) Khoa" Date: Mon, 20 Apr 2026 10:54:56 +0700 Subject: [PATCH] feat: fix mods tab, add client/server split, and scaffold server dirs Mods tab bug fixes: - mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use mods/ subdir instead of server root, migrate old string-list format to dict format on read - service: replace dead server_mods SQL JOIN with get_enabled_mods() call through the mod_manager capability; pass is_server_mod to build_mod_args - mods_router: accept list[EnabledModEntry] objects (name + is_server_mod) instead of bare strings Client/server mod split: - Mods now stored as list[{"name": str, "is_server_mod": bool}]; old string-list format auto-migrated on read - is_server_mod=true routes to -serverMod= arg; false to -mod= arg - ModList UI: amber Client/Server badge in selected pane; toggle button in split-pane selector Directory scaffold: - process_config: adds "mods" to dir layout; provides get_dir_readme() with per-directory README.txt content - file_utils: ensure_server_dirs() gains readme_provider kwarg; writes README.txt idempotently if absent - service.create_server: passes readme_provider via hasattr probe - main.py startup: backfills all existing servers with correct subdirs and README files (idempotent) Docs: API.md and FRONTEND.md updated for new mod schema and types Test __init__.py files added for pytest discovery --- API.md | 44 ++++++++++++----- FRONTEND.md | 5 +- backend/adapters/arma3/mod_manager.py | 54 ++++++++++++--------- backend/adapters/arma3/process_config.py | 48 +++++++++++++++++- backend/core/servers/mods_router.py | 18 +++++-- backend/core/servers/service.py | 27 ++++++----- backend/core/utils/file_utils.py | 18 +++++-- backend/main.py | 21 +++++++- backend/tests/__init__.py | 0 backend/tests/adapters/__init__.py | 0 backend/tests/adapters/arma3/__init__.py | 0 frontend/src/components/servers/ModList.tsx | 41 +++++++++++++--- 12 files changed, 209 insertions(+), 67 deletions(-) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/adapters/__init__.py create mode 100644 backend/tests/adapters/arma3/__init__.py diff --git a/API.md b/API.md index 30328e0..c28d496 100644 --- a/API.md +++ b/API.md @@ -1374,18 +1374,30 @@ List all available mods and which are currently enabled for this server. "mods": [ { "name": "@CBA_A3", - "folder_path": "C:/Arma3Server/@CBA_A3", - "enabled": true + "path": "D:/Arma3Server/1/mods/@CBA_A3", + "size_bytes": 12345678, + "enabled": true, + "is_server_mod": false, + "display_name": "Community Base Addons A3", + "workshop_id": "450814997" }, { "name": "@ACRE2", - "folder_path": "C:/Arma3Server/@ACRE2", - "enabled": true + "path": "D:/Arma3Server/1/mods/@ACRE2", + "size_bytes": 9876543, + "enabled": true, + "is_server_mod": true, + "display_name": "ACRE2", + "workshop_id": "751965892" }, { "name": "@USAF", - "folder_path": "C:/Arma3Server/@USAF", - "enabled": false + "path": "D:/Arma3Server/1/mods/@USAF", + "size_bytes": 55000000, + "enabled": false, + "is_server_mod": false, + "display_name": null, + "workshop_id": null } ] }, @@ -1393,6 +1405,8 @@ List all available mods and which are currently enabled for this server. } ``` +Mod folders are scanned from `{server_data_dir}/{server_id}/mods/@*`. `display_name` is parsed from `mod.cpp`; `workshop_id` from `meta.cpp`. `is_server_mod: true` means the mod is passed via `-serverMod=` instead of `-mod=`. + --- ### PUT /servers/{server_id}/mods/enabled @@ -1405,13 +1419,18 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send ```json { - "mods": ["@CBA_A3", "@ACRE2"] + "mods": [ + { "name": "@CBA_A3", "is_server_mod": false }, + { "name": "@ACRE2", "is_server_mod": true } + ] } ``` -| Field | Type | Required | Description | -|--------|---------------|----------|------------------------------------| -| `mods` | array[string] | Yes | Complete list of mod names to enable | +| Field | Type | Required | Description | +|---------------------|---------|----------|-------------| +| `mods` | array | Yes | Complete list of mod entries to enable | +| `mods[].name` | string | Yes | Mod folder name (must start with `@`) | +| `mods[].is_server_mod` | bool | No | `true` → `-serverMod=`, `false` (default) → `-mod=` | **Response 200:** @@ -1420,7 +1439,10 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send "success": true, "data": { "message": "Enabled mods updated. Restart the server for changes to take effect.", - "enabled_mods": ["@CBA_A3", "@ACRE2"] + "enabled_mods": [ + { "name": "@CBA_A3", "is_server_mod": false }, + { "name": "@ACRE2", "is_server_mod": true } + ] }, "error": null } diff --git a/FRONTEND.md b/FRONTEND.md index a8c54ff..0ece8f5 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -189,7 +189,7 @@ All server data flows through TanStack Query hooks: | `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` | | `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config | | `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` | -| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` | Invalidates `["mods", id]` | +| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` body: `EnabledModEntry[]` | Invalidates `["mods", id]` | | `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation | | `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` | | `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans | @@ -219,7 +219,8 @@ All server data flows through TanStack Query hooks: **Key type notes**: - `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response) - `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename -- `Mod` type: `{ name, path, size_bytes, enabled, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp +- `Mod` type: `{ name, path, size_bytes, enabled, is_server_mod, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp; `is_server_mod` controls `-serverMod=` vs `-mod=` +- `EnabledModEntry` type: `{ name: string, is_server_mod: boolean }` — used as `useSetEnabledMods` mutation input - `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API) - There is no REST endpoint for logs — logs are only pushed via WebSocket events diff --git a/backend/adapters/arma3/mod_manager.py b/backend/adapters/arma3/mod_manager.py index 3539432..c5a3e47 100644 --- a/backend/adapters/arma3/mod_manager.py +++ b/backend/adapters/arma3/mod_manager.py @@ -47,24 +47,27 @@ class Arma3ModManager: def _server_dir(self) -> Path: return get_server_dir(self._server_id) + def _mods_dir(self) -> Path: + return get_server_dir(self._server_id) / "mods" + # ── File / DB operations ── def list_available_mods(self) -> list[dict]: """ - Scan the server directory for mod folders (directories starting with '@'). + Scan the server's mods/ subdirectory for mod folders (directories starting with '@'). Returns list of dicts: name: str — directory name (e.g. "@CBA_A3") path: str — absolute directory path size_bytes: int — total directory size (approximate, non-recursive) """ - server_dir = self._server_dir() - if not server_dir.exists(): + mods_dir = self._mods_dir() + if not mods_dir.exists(): return [] mods = [] try: - for entry in server_dir.iterdir(): + for entry in mods_dir.iterdir(): if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name): try: size = sum( @@ -87,54 +90,59 @@ class Arma3ModManager: mods.sort(key=lambda m: m["name"].lower()) return mods - def get_enabled_mods(self, config_repo) -> list[str]: + def get_enabled_mods(self, config_repo) -> list[dict]: """ - Get the list of enabled mod names from the database config. + Get the list of enabled mods from the database config. - Args: - config_repo: ConfigRepository instance. - - Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]). + Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...] + Handles migration from old string-list format automatically. """ mods_section = config_repo.get_section(self._server_id, "mods") if mods_section is None: return [] - enabled = mods_section.get("enabled_mods", []) - if isinstance(enabled, str): - enabled = [m.strip() for m in enabled.split(",") if m.strip()] - return enabled + raw = mods_section.get("enabled_mods", []) + result = [] + for item in raw: + if isinstance(item, str): + result.append({"name": item, "is_server_mod": False}) + elif isinstance(item, dict): + result.append({"name": item.get("name", ""), "is_server_mod": bool(item.get("is_server_mod", False))}) + return result - def set_enabled_mods(self, mod_names: list[str], config_repo) -> None: + def set_enabled_mods(self, mod_entries: list[dict], config_repo) -> None: """ Update the enabled mods list in the database config. Args: - mod_names: List of mod directory names to enable. + mod_entries: List of dicts with "name" (str) and "is_server_mod" (bool). config_repo: ConfigRepository instance. - Raises AdapterError if any mod name doesn't exist on disk. + Raises AdapterError if any mod name is invalid or not found on disk. """ available = {m["name"] for m in self.list_available_mods()} - for name in mod_names: + for entry in mod_entries: + name = entry.get("name", "") if not _MOD_DIR_PATTERN.match(name): raise AdapterError(f"Invalid mod name '{name}': must start with '@'") if name not in available: raise AdapterError( - f"Mod '{name}' not found in server directory. " + f"Mod '{name}' not found in mods directory. " f"Available: {sorted(available)}" ) mods_section = config_repo.get_section(self._server_id, "mods") or {} - current_version = mods_section.get("config_version", 0) + current_version = mods_section.get("_meta", {}).get("config_version") config_repo.upsert_section( server_id=self._server_id, + game_type="arma3", section="mods", - data={"enabled_mods": mod_names}, - expected_version=current_version, + config_data={"enabled_mods": mod_entries}, + schema_version="1.0.0", + expected_config_version=current_version, ) logger.info( "Updated enabled mods for server %d: %s", - self._server_id, mod_names, + self._server_id, [e["name"] for e in mod_entries], ) # ── CLI argument building ── diff --git a/backend/adapters/arma3/process_config.py b/backend/adapters/arma3/process_config.py index 6d7bf7a..f31e135 100644 --- a/backend/adapters/arma3/process_config.py +++ b/backend/adapters/arma3/process_config.py @@ -27,4 +27,50 @@ class Arma3ProcessConfig: def get_server_dir_layout(self) -> list[str]: """Subdirectories to create inside servers/{id}/.""" - return ["server", "battleye", "mpmissions"] \ No newline at end of file + return ["server", "battleye", "mpmissions", "mods"] + + _DIR_READMES: dict[str, str] = { + "server": ( + "Arma 3 Server — Log Directory\n" + "==============================\n\n" + "Arma 3 writes RPT log files here (e.g. arma3server_2024-01-01_12-00-00.rpt).\n" + "These are viewable in Languard's Logs tab.\n\n" + "Do NOT place files here manually." + ), + "battleye": ( + "BattlEye Anti-Cheat\n" + "===================\n\n" + "BattlEye configuration and GUID ban list files live here.\n" + "Managed automatically by Arma 3 and Languard.\n\n" + "Do NOT modify these files manually unless you know what you are doing." + ), + "mpmissions": ( + "Mission Files\n" + "=============\n\n" + "Place Arma 3 mission files (.pbo) here to make them available for the server.\n" + "Once placed here they will appear in Languard's Missions tab.\n\n" + "Example: Wasteland_A3.Altis.pbo" + ), + "mods": ( + "Mods\n" + "====\n\n" + "Place Arma 3 mod folders here. Each mod folder must start with '@'.\n\n" + "Example layout:\n" + " mods/\n" + " @CBA_A3/\n" + " addons/\n" + " @ACE/\n" + " addons/\n\n" + "After placing mods here:\n" + " 1. Go to the Mods tab in Languard.\n" + " 2. Select the mods you want to enable.\n" + " 3. Toggle 'Server-only' for mods that should use -serverMod= (e.g. task force radio server plugin).\n" + " 4. Click 'Apply Selection'.\n" + " 5. Restart the server for changes to take effect.\n\n" + "Mods with a mod.cpp file will display their friendly name in the UI.\n" + "Workshop mods with meta.cpp will show their Workshop ID." + ), + } + + def get_dir_readme(self, dir_name: str) -> str | None: + return self._DIR_READMES.get(dir_name) \ No newline at end of file diff --git a/backend/core/servers/mods_router.py b/backend/core/servers/mods_router.py index bf36bbe..b91a8c9 100644 --- a/backend/core/servers/mods_router.py +++ b/backend/core/servers/mods_router.py @@ -24,8 +24,13 @@ def _ok(data): return {"success": True, "data": data, "error": None} +class EnabledModEntry(BaseModel): + name: str + is_server_mod: bool = False + + class SetEnabledModsRequest(BaseModel): - mods: list[str] + mods: list[EnabledModEntry] def _get_mod_manager(server_id: int, game_type: str): @@ -52,12 +57,15 @@ def list_mods( config_repo = ConfigRepository(db) try: available = mgr.list_available_mods() - enabled = set(mgr.get_enabled_mods(config_repo)) + enabled_mods = mgr.get_enabled_mods(config_repo) except AdapterError as exc: raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) + enabled_map = {m["name"]: m for m in enabled_mods} for mod in available: - mod["enabled"] = mod["name"] in enabled + entry = enabled_map.get(mod["name"]) + mod["enabled"] = entry is not None + mod["is_server_mod"] = entry["is_server_mod"] if entry else False return _ok({ "server_id": server_id, @@ -83,7 +91,7 @@ def set_enabled_mods( config_repo = ConfigRepository(db) try: - mgr.set_enabled_mods(body.mods, config_repo) + mgr.set_enabled_mods([m.model_dump() for m in body.mods], config_repo) except AdapterError as exc: raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) except ValueError as exc: @@ -97,5 +105,5 @@ def set_enabled_mods( return _ok({ "message": "Enabled mods updated. Restart the server for changes to take effect.", - "enabled_mods": body.mods, + "enabled_mods": [m.model_dump() for m in body.mods], }) \ No newline at end of file diff --git a/backend/core/servers/service.py b/backend/core/servers/service.py index 016e7b4..898987d 100644 --- a/backend/core/servers/service.py +++ b/backend/core/servers/service.py @@ -126,9 +126,10 @@ class ServerService: max_restarts=max_restarts, ) - # Create directory layout + # Create directory layout with per-directory README files layout = process_config.get_server_dir_layout() - ensure_server_dirs(server_id, layout) + readme_fn = getattr(process_config, "get_dir_readme", None) + ensure_server_dirs(server_id, layout, readme_provider=readme_fn) # Seed default config sections config_gen = adapter.get_config_generator() @@ -242,17 +243,17 @@ class ServerService: # Get mod args if adapter supports mods mod_args: list[str] = [] if adapter.has_capability("mod_manager"): - from sqlalchemy import text - mods = self._db.execute( - text(""" - SELECT m.folder_path, sm.is_server_mod, sm.sort_order - FROM server_mods sm JOIN mods m ON m.id = sm.mod_id - WHERE sm.server_id = :sid ORDER BY sm.sort_order - """), - {"sid": server_id}, - ).fetchall() - mod_list = [dict(r._mapping) for r in mods] - mod_args = adapter.get_mod_manager().build_mod_args(mod_list) + mod_mgr = adapter.get_mod_manager(server_id) + enabled_mods = mod_mgr.get_enabled_mods(self._config_repo) + server_dir = get_server_dir(server_id) + mod_list = [ + { + "folder_path": str(server_dir / "mods" / m["name"]), + "game_data": {"is_server_mod": m.get("is_server_mod", False)}, + } + for m in enabled_mods + ] + mod_args = mod_mgr.build_mod_args(mod_list) # Write config files (atomic) server_dir = get_server_dir(server_id) diff --git a/backend/core/utils/file_utils.py b/backend/core/utils/file_utils.py index 61ea690..798bcec 100644 --- a/backend/core/utils/file_utils.py +++ b/backend/core/utils/file_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import re from pathlib import Path +from typing import Callable def get_server_dir(server_id: int) -> Path: @@ -12,16 +13,27 @@ def get_server_dir(server_id: int) -> Path: return base / str(server_id) -def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None: +def ensure_server_dirs( + server_id: int, + layout: list[str] | None = None, + readme_provider: Callable[[str], str | None] | None = None, +) -> None: """ Create servers/{id}/ and any subdirectories from adapter layout. - layout example: ["server", "battleye", "mpmissions"] + If readme_provider is given, writes README.txt into each subdir (skips if file already exists). """ server_dir = get_server_dir(server_id) server_dir.mkdir(parents=True, exist_ok=True) if layout: for subdir in layout: - (server_dir / subdir).mkdir(parents=True, exist_ok=True) + subdir_path = server_dir / subdir + subdir_path.mkdir(parents=True, exist_ok=True) + if readme_provider: + content = readme_provider(subdir) + if content: + readme_path = subdir_path / "README.txt" + if not readme_path.exists(): + readme_path.write_text(content, encoding="utf-8") def safe_delete_file(path: Path) -> bool: diff --git a/backend/main.py b/backend/main.py index 8581af9..f42c738 100644 --- a/backend/main.py +++ b/backend/main.py @@ -90,7 +90,24 @@ async def lifespan(app: FastAPI): except Exception as exc: logger.error("Failed to reattach threads for server %d: %s", server["id"], exc) - # 8. Seed default admin if no users exist + # 8. Backfill server directory scaffold for existing servers (idempotent) + from core.dal.server_repository import ServerRepository as _ServerRepo + from core.utils.file_utils import ensure_server_dirs as _ensure_dirs + from adapters.registry import GameAdapterRegistry as _Registry + with engine.connect() as db: + for server in _ServerRepo(db).get_all(): + try: + _adapter = _Registry.get(server["game_type"]) + _pc = _adapter.get_process_config() + _ensure_dirs( + server["id"], + _pc.get_server_dir_layout(), + readme_provider=getattr(_pc, "get_dir_readme", None), + ) + except Exception as exc: + logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc) + + # 9. Seed default admin if no users exist from core.auth.service import AuthService with engine.connect() as db: svc = AuthService(db) @@ -104,7 +121,7 @@ async def lifespan(app: FastAPI): logger.warning(" Change this password immediately!") logger.warning("=" * 60) - # 9. Register and start APScheduler cleanup jobs + # 10. Register and start APScheduler cleanup jobs from core.jobs.scheduler import start_scheduler, stop_scheduler from core.jobs.cleanup_jobs import register_cleanup_jobs register_cleanup_jobs() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/adapters/__init__.py b/backend/tests/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/adapters/arma3/__init__.py b/backend/tests/adapters/arma3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/servers/ModList.tsx b/frontend/src/components/servers/ModList.tsx index 6b99e5e..cf92770 100644 --- a/frontend/src/components/servers/ModList.tsx +++ b/frontend/src/components/servers/ModList.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Save } from "lucide-react"; +import { Save, Server } from "lucide-react"; import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail"; import type { Mod } from "@/hooks/useServerDetail"; @@ -38,14 +38,25 @@ export function ModList({ serverId }: ModListProps) { setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name))); }; + const toggleServerMod = (modName: string) => { + setSelected((prev) => + prev.map((m) => m.name === modName ? { ...m, is_server_mod: !m.is_server_mod } : m), + ); + }; + + const _selectedKey = (mods: Mod[]) => + mods.map((m) => `${m.name}:${m.is_server_mod}`).sort().join(","); + const hasChanges = modsData !== undefined && ( - selected.map((m) => m.name).sort().join(",") !== - (modsData.mods.filter((m) => m.enabled).map((m) => m.name).sort().join(",")) + _selectedKey(selected) !== + _selectedKey(modsData.mods.filter((m) => m.enabled)) ); const handleApply = async () => { try { - await setEnabledMods.mutateAsync(selected.map((m) => m.name)); + await setEnabledMods.mutateAsync( + selected.map((m) => ({ name: m.name, is_server_mod: m.is_server_mod })), + ); addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` }); } catch (err) { logger.error("ModList", "Failed to apply mods: %s", err); @@ -144,6 +155,7 @@ export function ModList({ serverId }: ModListProps) { mod={mod} actionLabel="←" onAction={isAdmin ? () => moveToAvailable(mod) : undefined} + onToggleServerMod={isAdmin ? () => toggleServerMod(mod.name) : undefined} selected /> )) @@ -159,17 +171,17 @@ function ModRow({ mod, actionLabel, onAction, + onToggleServerMod, selected = false, }: { mod: Mod; actionLabel: string; onAction?: () => void; + onToggleServerMod?: () => void; selected?: boolean; }) { return ( -
+

{mod.display_name ?? mod.name} @@ -186,6 +198,21 @@ function ModRow({ {formatSize(mod.size_bytes)}

+ {selected && onToggleServerMod && ( + + )} {onAction && (