feat: implement full backend + frontend server detail, settings, and create server pages

Backend:
- Complete FastAPI backend with 42+ REST endpoints (auth, servers, config,
  players, bans, missions, mods, games, system)
- Game adapter architecture with Arma 3 as first-class adapter
- WebSocket real-time events for status, metrics, logs, players
- Background thread system (process monitor, metrics, log tail, RCon poller)
- Fernet encryption for sensitive config fields at rest
- JWT auth with admin/viewer roles, bcrypt password hashing
- SQLite with WAL mode, parameterized queries, migration system
- APScheduler cleanup jobs for logs, metrics, events

Frontend:
- Server Detail page with 7 tabs (overview, config, players, bans,
  missions, mods, logs)
- Settings page with password change and admin user management
- Create Server wizard (4-step; known bug: silent validation failure)
- New hooks: useServerDetail, useAuth, useGames
- New components: ServerHeader, ConfigEditor, PlayerTable, BanTable,
  MissionList, ModList, LogViewer, PasswordChange, UserManager
- WebSocket onEvent callback for real-time log accumulation
- 120 unit tests passing (Vitest + React Testing Library)

Docs:
- Added .gitignore, CLAUDE.md, README.md
- Updated FRONTEND.md, ARCHITECTURE.md with current implementation state
- Added .env.example for backend configuration

Known issues:
- Create Server form: "Next" buttons don't validate before advancing,
  causing silent submit failure when fields are invalid
- Config sub-tabs need UX redesign for non-technical users
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-17 11:58:34 +07:00
parent 620429c9b8
commit 6511353b55
119 changed files with 13752 additions and 5000 deletions

View File

@@ -0,0 +1,169 @@
"""
RemoteAdminPollerThread — polls the game server's remote admin interface
(e.g. BattlEye RCon for Arma3) to sync the player list.
Design notes:
- Uses the RemoteAdminClient protocol injected at construction time
- Reconnects automatically on disconnect with exponential backoff
- Persists current player list to players table via PlayerRepository
- Emits player_join / player_leave events via EventRepository
- Pushes player list updates to broadcast_queue if provided
Poll interval: 30 seconds.
Reconnect backoff: 5s -> 10s -> 20s -> 40s -> 60s (cap).
"""
from __future__ import annotations
import logging
import queue
from core.dal.event_repository import EventRepository
from core.dal.player_repository import PlayerRepository
from core.threads.base_thread import BaseServerThread
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 30.0
_RECONNECT_BACKOFF_BASE = 5.0
_RECONNECT_BACKOFF_MAX = 60.0
_RECONNECT_BACKOFF_MULT = 2.0
class RemoteAdminPollerThread(BaseServerThread):
"""
Polls the remote admin interface for a game server.
Args:
server_id: Database server ID.
remote_admin_client: Connected RemoteAdminClient instance.
broadcast_queue: Optional queue.Queue for player list pushes.
"""
def __init__(
self,
server_id: int,
remote_admin_client,
broadcast_queue=None,
) -> None:
super().__init__(server_id, "RemoteAdminPoller")
self._client = remote_admin_client
self._broadcast_queue = broadcast_queue
self._connected = False
self._reconnect_backoff = _RECONNECT_BACKOFF_BASE
self._known_players: dict[str, dict] = {} # player_uid -> player data
# ── Lifecycle ──
def _on_stop(self) -> None:
if self._connected and self._client is not None:
try:
self._client.disconnect()
except Exception as exc:
logger.debug("[%s] Error disconnecting remote admin on stop: %s", self.name, exc)
self._connected = False
# ── Main loop ──
def _run_loop(self) -> None:
if not self._connected:
self._attempt_connect()
return
self._stop_event.wait(timeout=_POLL_INTERVAL)
if self._stop_event.is_set():
return
try:
players = self._client.get_players()
self._reconnect_backoff = _RECONNECT_BACKOFF_BASE
self._sync_players(players)
except Exception as exc:
logger.warning("[%s] Poll failed: %s — will reconnect", self.name, exc)
self._connected = False
try:
if self._client is not None:
self._client.disconnect()
except Exception as exc:
logger.debug("[%s] Error disconnecting after poll failure: %s", self.name, exc)
# ── Connection management ──
def _attempt_connect(self) -> None:
try:
self._client.connect() if hasattr(self._client, "connect") else None
self._connected = True
self._reconnect_backoff = _RECONNECT_BACKOFF_BASE
logger.info("[%s] Connected to remote admin", self.name)
except Exception as exc:
logger.warning(
"[%s] Connection failed: %s — retrying in %.1fs",
self.name, exc, self._reconnect_backoff,
)
self._stop_event.wait(timeout=self._reconnect_backoff)
self._reconnect_backoff = min(
self._reconnect_backoff * _RECONNECT_BACKOFF_MULT,
_RECONNECT_BACKOFF_MAX,
)
# ── Player sync ──
def _sync_players(self, current_players: list[dict]) -> None:
"""
Diff current_players against self._known_players.
Insert join events for new players, leave events for departed ones.
Upsert all current players in the DB.
Each player dict must have at least: slot_id, name (other fields optional).
"""
if self._db is None:
return
player_repo = PlayerRepository(self._db)
event_repo = EventRepository(self._db)
# Build uid sets for diffing — use slot_id as key
current_slots = {str(p.get("slot_id", i)): p for i, p in enumerate(current_players)}
current_keys = set(current_slots.keys())
known_keys = set(self._known_players.keys())
joined = current_keys - known_keys
left = known_keys - current_keys
for slot_key, player in current_slots.items():
player_repo.upsert(server_id=self.server_id, player=player)
if slot_key in joined:
event_repo.insert(
server_id=self.server_id,
event_type="player_join",
detail={"name": player.get("name", ""), "slot": slot_key},
)
logger.debug("[%s] Player joined: %s (slot %s)", self.name, player.get("name"), slot_key)
for slot_key in left:
departed = self._known_players[slot_key]
event_repo.insert(
server_id=self.server_id,
event_type="player_leave",
detail={"name": departed.get("name", ""), "slot": slot_key},
)
logger.debug("[%s] Player left: %s (slot %s)", self.name, departed.get("name"), slot_key)
try:
self._db.commit()
except Exception as exc:
logger.error("[%s] DB commit failed during player sync: %s", self.name, exc)
self._db.rollback()
# Update known players
self._known_players = current_slots
if self._broadcast_queue is not None:
try:
self._broadcast_queue.put_nowait({
"type": "players",
"server_id": self.server_id,
"data": current_players,
})
except queue.Full:
logger.debug("[%s] Broadcast queue full, dropping players event", self.name)