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:
123
backend/core/threads/base_thread.py
Normal file
123
backend/core/threads/base_thread.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
BaseServerThread — base class for all per-server background threads.
|
||||
|
||||
Rules every subclass MUST follow:
|
||||
- Call super().__init__(server_id, name) in __init__
|
||||
- Implement _run_loop() — called repeatedly until _stop_event is set
|
||||
- Do NOT override run() directly
|
||||
- Use self._db for all database operations — it is a thread-local connection
|
||||
- Call self._close_db() in your finally block if you open additional connections
|
||||
- Exceptions raised from _run_loop() are caught, logged, and the loop continues
|
||||
unless the exception is a fatal error — set self._fatal_error = True to stop
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from database import get_thread_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXCEPTION_BACKOFF_BASE = 2.0
|
||||
_EXCEPTION_BACKOFF_MAX = 60.0
|
||||
_EXCEPTION_BACKOFF_MULTIPLIER = 2.0
|
||||
|
||||
|
||||
class BaseServerThread(ABC, threading.Thread):
|
||||
"""
|
||||
Abstract base for all per-server background threads.
|
||||
|
||||
Subclasses implement _run_loop(). This base class handles:
|
||||
- Stop event signaling
|
||||
- Thread-local DB connection lifecycle
|
||||
- Exception backoff to prevent tight crash loops
|
||||
- Structured logging with server_id context
|
||||
"""
|
||||
|
||||
def __init__(self, server_id: int, name: str) -> None:
|
||||
super().__init__(name=f"{name}-server-{server_id}", daemon=True)
|
||||
self.server_id = server_id
|
||||
self._stop_event = threading.Event()
|
||||
self._fatal_error = False
|
||||
self._db = None
|
||||
self._exception_count = 0
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the thread to stop. Does not block."""
|
||||
self._stop_event.set()
|
||||
|
||||
def stop_and_join(self, timeout: float = 5.0) -> None:
|
||||
"""Signal stop and wait for the thread to exit."""
|
||||
self._stop_event.set()
|
||||
self.join(timeout=timeout)
|
||||
|
||||
@property
|
||||
def is_stopping(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
# ── Thread entry point ──
|
||||
|
||||
def run(self) -> None:
|
||||
logger.info("[%s] Starting", self.name)
|
||||
backoff = _EXCEPTION_BACKOFF_BASE
|
||||
|
||||
try:
|
||||
self._db = get_thread_db()
|
||||
self._on_start()
|
||||
|
||||
while not self._stop_event.is_set() and not self._fatal_error:
|
||||
try:
|
||||
self._run_loop()
|
||||
backoff = _EXCEPTION_BACKOFF_BASE
|
||||
self._exception_count = 0
|
||||
except Exception as exc:
|
||||
self._exception_count += 1
|
||||
logger.error(
|
||||
"[%s] Unhandled exception in _run_loop (count=%d): %s",
|
||||
self.name, self._exception_count, exc, exc_info=True,
|
||||
)
|
||||
if self._fatal_error:
|
||||
break
|
||||
self._stop_event.wait(timeout=backoff)
|
||||
backoff = min(backoff * _EXCEPTION_BACKOFF_MULTIPLIER, _EXCEPTION_BACKOFF_MAX)
|
||||
|
||||
except Exception as exc:
|
||||
logger.critical("[%s] Fatal error in thread setup: %s", self.name, exc, exc_info=True)
|
||||
finally:
|
||||
self._on_stop()
|
||||
self._close_db()
|
||||
logger.info("[%s] Stopped", self.name)
|
||||
|
||||
# ── Hooks for subclasses ──
|
||||
|
||||
def _on_start(self) -> None:
|
||||
"""Called once before the loop starts. Override for setup."""
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
"""Called once after the loop ends. Override for cleanup."""
|
||||
|
||||
@abstractmethod
|
||||
def _run_loop(self) -> None:
|
||||
"""
|
||||
Implement the thread's work here.
|
||||
Called repeatedly until stop() is called or _fatal_error is set.
|
||||
Should block for a short period (sleep or wait) to avoid busy-looping.
|
||||
"""
|
||||
|
||||
# ── Internal helpers ──
|
||||
|
||||
def _close_db(self) -> None:
|
||||
if self._db is not None:
|
||||
try:
|
||||
self._db.close()
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Error closing DB connection: %s", self.name, exc)
|
||||
self._db = None
|
||||
|
||||
def _sleep(self, seconds: float) -> None:
|
||||
"""Interruptible sleep — wakes up early if stop() is called."""
|
||||
self._stop_event.wait(timeout=seconds)
|
||||
Reference in New Issue
Block a user