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

114
backend/database.py Normal file
View File

@@ -0,0 +1,114 @@
"""SQLAlchemy engine setup, migration runner, and session helpers."""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import Connection, Engine
logger = logging.getLogger(__name__)
_engine: Engine | None = None
_thread_local = threading.local()
def get_engine() -> Engine:
global _engine
if _engine is not None:
return _engine
from config import settings
db_path = Path(settings.db_path).resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)
_engine = create_engine(
f"sqlite:///{db_path}",
connect_args={"check_same_thread": False},
echo=False,
)
# Apply pragmas on every new connection
@event.listens_for(_engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
return _engine
def get_db():
"""FastAPI dependency. Yields a SQLAlchemy Connection, closes after request."""
engine = get_engine()
with engine.connect() as conn:
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def get_thread_db() -> Connection:
"""
Return a thread-local DB connection for background threads.
Each thread gets its own connection (SQLite requires this).
Call conn.close() in thread teardown.
"""
if not hasattr(_thread_local, "conn") or _thread_local.conn is None:
_thread_local.conn = get_engine().connect()
return _thread_local.conn
def run_migrations(engine: Engine) -> None:
"""Apply all pending SQL migration files in order."""
migrations_dir = Path(__file__).parent / "core" / "migrations"
migration_files = sorted(migrations_dir.glob("*.sql"))
with engine.connect() as conn:
# Ensure tracking table exists
conn.execute(text("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""))
conn.commit()
applied = {
row[0] for row in conn.execute(
text("SELECT version FROM schema_migrations")
)
}
for mfile in migration_files:
# Extract version number from filename: 001_initial.sql -> 1
version_str = mfile.name.split("_")[0]
try:
version = int(version_str)
except ValueError:
logger.warning("Skipping migration with non-numeric prefix: %s", mfile.name)
continue
if version in applied:
continue
logger.info("Applying migration: %s", mfile.name)
sql = mfile.read_text(encoding="utf-8")
# Execute each statement separately (SQLite doesn't support executescript in transactions)
for statement in sql.split(";"):
statement = statement.strip()
if statement:
conn.execute(text(statement))
conn.execute(
text("INSERT INTO schema_migrations (version) VALUES (:v)"),
{"v": version},
)
conn.commit()
logger.info("Migration %d applied.", version)