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:
114
backend/database.py
Normal file
114
backend/database.py
Normal 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)
|
||||
Reference in New Issue
Block a user