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
37 KiB
Languard Servers Manager -- Database Schema
Engine Configuration
| Setting | Value |
|---|---|
| Engine | SQLite via SQLAlchemy Core (sync) |
| File | languard.db (configurable via LANGUARD_DB_PATH) |
| Journal mode | WAL (PRAGMA journal_mode=WAL) |
| Foreign keys | ON (PRAGMA foreign_keys=ON) |
| Busy timeout | 5000ms (PRAGMA busy_timeout=5000) |
| Query style | Raw SQL through sqlalchemy.text() -- no ORM |
| Concurrent access | WAL allows concurrent reads during writes; background threads use thread-local connections via get_thread_db() |
| Connection model | API requests use get_db() (connect-commit/rollback per request); background threads use get_thread_db() (thread-local, manually closed) |
Why WAL Mode
SQLite in WAL mode permits simultaneous readers while a writer holds an exclusive lock. This is essential for the server manager architecture where a metrics collector thread, log tail thread, and API request handler may all access the database concurrently. The 5-second busy timeout prevents immediate "database is locked" failures under contention; if exceeded, the caller retries with exponential backoff.
Design Philosophy
The schema uses a hybrid approach: core tables are fully normalized and game-agnostic, while game-specific configuration is stored as JSON blobs in a generic game_configs table. Adapter-provided Pydantic models validate the JSON at the application layer.
Why this works for Languard:
- Config is always read and written as a complete section; no one queries individual keys across servers.
- Each adapter section maps to a Pydantic model, so validation is enforced at the application boundary.
- The JSON is opaque to the core -- meaningful only to the adapter that owns it.
- Adding a new game requires zero database migration -- just a new adapter.
- Core queries across all games (player counts, server status, logs) work naturally through normalized columns.
Schema
Table: users
Web UI authentication accounts.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT,
CHECK (role IN ('admin', 'viewer'))
);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
username |
TEXT | NOT NULL, UNIQUE | Login username |
password_hash |
TEXT | NOT NULL | bcrypt hash of the user's password |
role |
TEXT | NOT NULL, DEFAULT 'viewer' |
Authorization role. Allowed values: admin, viewer |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Account creation timestamp (ISO 8601) |
last_login |
TEXT | nullable | Most recent successful login timestamp |
Notes:
- Only two roles exist:
admin(full access) andviewer(read-only). TheCHECKconstraint enforces this at the database level. password_hashstores bcrypt hashes, never plaintext.- No indexes beyond the implicit UNIQUE index on
username.
Table: servers
One row per managed server instance. Game-agnostic; the game_type column determines which adapter handles the server.
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
game_type TEXT NOT NULL DEFAULT 'arma3',
status TEXT NOT NULL DEFAULT 'stopped',
pid INTEGER,
exe_path TEXT NOT NULL,
started_at TEXT,
stopped_at TEXT,
game_port INTEGER NOT NULL,
rcon_port INTEGER,
auto_restart INTEGER NOT NULL DEFAULT 0,
max_restarts INTEGER NOT NULL DEFAULT 3,
restart_window_seconds INTEGER NOT NULL DEFAULT 300,
restart_count INTEGER NOT NULL DEFAULT 0,
last_restart_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (status IN ('stopped','starting','running','stopping','crashed','error')),
CHECK (game_port BETWEEN 1024 AND 65535),
CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535))
);
CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status);
CREATE INDEX IF NOT EXISTS idx_servers_game_type ON servers(game_type);
CREATE INDEX IF NOT EXISTS idx_servers_game_port ON servers(game_port);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
name |
TEXT | NOT NULL | Display name in the UI |
description |
TEXT | nullable | Optional free-text description |
game_type |
TEXT | NOT NULL, DEFAULT 'arma3' |
Adapter lookup key. Determines which game adapter handles this server |
status |
TEXT | NOT NULL, DEFAULT 'stopped' |
Current server state. Allowed: stopped, starting, running, stopping, crashed, error |
pid |
INTEGER | nullable | OS process ID when the server is running. NULL when stopped |
exe_path |
TEXT | NOT NULL | Absolute path to the server executable |
started_at |
TEXT | nullable | ISO datetime when the server last entered running status |
stopped_at |
TEXT | nullable | ISO datetime when the server last left running status |
game_port |
INTEGER | NOT NULL, 1024-65535 | Primary game port (the port players connect to) |
rcon_port |
INTEGER | nullable, 1024-65535 | Remote console port. NULL if the game has no remote admin protocol |
auto_restart |
INTEGER | NOT NULL, DEFAULT 0 |
1 = restart automatically on crash, 0 = no auto-restart |
max_restarts |
INTEGER | NOT NULL, DEFAULT 3 |
Maximum crash restarts allowed within restart_window_seconds |
restart_window_seconds |
INTEGER | NOT NULL, DEFAULT 300 |
Rolling window (in seconds) for counting consecutive restarts |
restart_count |
INTEGER | NOT NULL, DEFAULT 0 |
Current number of restarts within the window. Reset when the window expires |
last_restart_at |
TEXT | nullable | ISO datetime of the most recent auto-restart |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Server creation timestamp |
updated_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Last modification timestamp |
Indexes:
| Index | Columns | Purpose |
|---|---|---|
idx_servers_status |
status |
Filter servers by status (e.g., all running servers) |
idx_servers_game_type |
game_type |
Filter servers by game type |
idx_servers_game_port |
game_port |
Prevent port collisions; fast lookup by port |
Auto-restart behavior: When a server crashes and auto_restart = 1, the system increments restart_count. If restart_count exceeds max_restarts within the last restart_window_seconds, auto-restart is disabled and a max_restarts_exceeded event is logged. The count resets when the server stays up for longer than restart_window_seconds.
Table: game_configs
Stores all game-specific configuration as JSON blobs, keyed by section. Replaces what would otherwise be multiple per-game config tables.
CREATE TABLE IF NOT EXISTS game_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
game_type TEXT NOT NULL,
section TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
config_version INTEGER NOT NULL DEFAULT 1,
schema_version TEXT NOT NULL DEFAULT '1.0.0',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(server_id, section)
);
CREATE INDEX IF NOT EXISTS idx_game_configs_server ON game_configs(server_id);
CREATE INDEX IF NOT EXISTS idx_game_configs_type_section ON game_configs(game_type, section);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Owning server. Deleting the server removes all its config sections |
game_type |
TEXT | NOT NULL | Redundant with servers.game_type, stored here for indexing without a JOIN. Must match the parent server's game_type |
section |
TEXT | NOT NULL | Config section name (e.g., server, basic, profile, launch, rcon for Arma 3) |
config_json |
TEXT | NOT NULL, DEFAULT '{}' |
JSON blob validated by the adapter's Pydantic model. Sensitive fields are Fernet-encrypted before serialization |
config_version |
INTEGER | NOT NULL, DEFAULT 1 |
Optimistic locking version counter. Incremented on every write |
schema_version |
TEXT | NOT NULL, DEFAULT '1.0.0' |
Adapter schema version at the time of last write. Used for automatic config migration |
updated_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Last write timestamp |
Unique constraint: (server_id, section) -- each server can have at most one config section of each type.
Indexes:
| Index | Columns | Purpose |
|---|---|---|
idx_game_configs_server |
server_id |
Load all config for a server |
idx_game_configs_type_section |
game_type, section |
Look up all configs of a given game type and section (e.g., all Arma 3 rcon sections) |
Fernet Encryption of Sensitive Fields
Sensitive fields within config_json (passwords, RCON passwords, admin passwords) are encrypted at the application layer before JSON serialization and storage. The core.utils.crypto module handles this:
- Algorithm: Fernet (AES-256-CBC with HMAC-SHA256 for authentication)
- Key source:
LANGUARD_ENCRYPTION_KEYenvironment variable (Fernet base64 key) - Format: Encrypted values are stored as
"encrypted:<base64-token>"within the JSON blob - Transparency:
ConfigRepository._encrypt_sensitive()and_decrypt_sensitive()handle encryption/decryption around JSON serialization. The adapter layer sees only plaintext.
The adapter declares which fields are sensitive via ConfigGenerator.get_sensitive_fields(section) -> list[str]. For Arma 3:
- Section
server:password,password_admin,server_command_password - Section
rcon:rcon_password
On write, ConfigRepository.upsert_section() calls _encrypt_sensitive() which replaces declared fields with "encrypted:..." tokens. On read, ConfigRepository.get_section() calls _decrypt_sensitive() which restores plaintext. The is_encrypted() check (value.startswith("encrypted:")) ensures that already-encrypted values are not double-encrypted, and plaintext values pass through unchanged for backward compatibility.
Optimistic Locking
The config_version column prevents lost updates when two admins edit simultaneously:
- Client reads config section, receives
_meta.config_version(e.g.,3) - Client sends PUT with
config_version: 3in the request body - Server checks: if
expected_config_versionis provided and differs from the stored version, raiseValueErrorwith"CONFIG_VERSION_CONFLICT:<current_version>" - On conflict: return 409 Conflict with the current config so the client can merge
- On success: increment
config_version, write new JSON, return the new version
This is implemented in ConfigRepository.upsert_section().
Config Schema Migration
When the adapter is updated and get_config_version() returns a newer version than game_configs.schema_version, the core automatically migrates:
- On read, detect
stored_schema_version != adapter.get_config_version() - Call
adapter.migrate_config(stored_schema_version, config_json)-- returns migrated dict - Update the row:
SET config_json = ?, schema_version = ? WHERE id = ? - On
ConfigMigrationError: keep original config, log warning, server runs with the old schema
Migration is per-section -- each section can have a different stored version.
Arma 3 Config Sections
Section server -- maps to server.cfg parameters:
{
"hostname": "My Arma 3 Server",
"password": "encrypted:...",
"password_admin": "encrypted:...",
"server_command_password": "encrypted:...",
"max_players": 40,
"kick_duplicate": 1,
"persistent": 1,
"vote_threshold": 0.33,
"vote_mission_players": 1,
"vote_timeout": 60,
"role_timeout": 90,
"briefing_timeout": 60,
"debriefing_timeout": 45,
"lobby_idle_timeout": 300,
"disable_von": 0,
"von_codec": 1,
"von_codec_quality": 20,
"max_ping": 250,
"max_packet_loss": 50,
"max_desync": 200,
"disconnect_timeout": 15,
"kick_on_ping": 1,
"kick_on_packet_loss": 1,
"kick_on_desync": 1,
"kick_on_timeout": 1,
"battleye": 1,
"verify_signatures": 2,
"allowed_file_patching": 0,
"forced_difficulty": "Regular",
"timestamp_format": "short",
"auto_select_mission": 0,
"random_mission_order": 0,
"missions_to_restart": 0,
"missions_to_shutdown": 0,
"log_file": "server_console.log",
"skip_lobby": 0,
"drawing_in_map": 1,
"upnp": 0,
"loopback": 0,
"statistics_enabled": 1,
"force_rotor_lib": 0,
"required_build": 0,
"steam_protocol_max_data_size": 1024,
"motd_lines": ["Welcome!", "Have fun"],
"motd_interval": 5.0,
"on_user_connected": "",
"on_user_disconnected": "",
"on_unsigned_data": "kick (_this select 0)",
"on_hacked_data": "kick (_this select 0)",
"double_id_detected": "",
"headless_clients": [],
"local_clients": [],
"admin_uids": [],
"allowed_load_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"],
"allowed_preprocess_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"],
"allowed_html_extensions": ["htm","html","xml","txt"]
}
Section basic -- maps to basic.cfg parameters:
{
"min_bandwidth": 800000,
"max_bandwidth": 25000000,
"max_msg_send": 384,
"max_size_guaranteed": 512,
"max_size_non_guaranteed": 256,
"min_error_to_send": 0.003,
"max_custom_file_size": 100000
}
Section profile -- maps to server.Arma3Profile difficulty:
{
"reduced_damage": 0,
"group_indicators": 0,
"friendly_tags": 0,
"enemy_tags": 0,
"detected_mines": 0,
"commands": 1,
"waypoints": 1,
"tactical_ping": 0,
"weapon_info": 2,
"stance_indicator": 2,
"stamina_bar": 0,
"weapon_crosshair": 0,
"vision_aid": 0,
"third_person_view": 0,
"camera_shake": 1,
"score_table": 1,
"death_messages": 1,
"von_id": 1,
"map_content_friendly": 0,
"map_content_enemy": 0,
"map_content_mines": 0,
"auto_report": 0,
"multiple_saves": 0,
"ai_level_preset": 3,
"skill_ai": 0.5,
"precision_ai": 0.5
}
Section launch -- maps to CLI launch parameters:
{
"world": "empty",
"extra_params": "",
"limit_fps": 50,
"auto_init": 0,
"load_mission_to_memory": 0,
"bandwidth_alg": null,
"enable_ht": 0,
"huge_pages": 0,
"cpu_count": null,
"ex_threads": 7,
"max_mem": null,
"no_logs": 0,
"netlog": 0
}
Section rcon -- BattlEye RCon settings:
{
"rcon_password": "encrypted:...",
"max_ping": 200,
"enabled": 1
}
Table: mods
Registered mods. Scoped by game_type to keep the mod catalog per-game.
CREATE TABLE IF NOT EXISTS mods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_type TEXT NOT NULL,
name TEXT NOT NULL,
folder_path TEXT NOT NULL,
workshop_id TEXT,
description TEXT,
game_data TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (game_type, folder_path)
);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
game_type |
TEXT | NOT NULL | Game this mod belongs to (e.g., arma3). Prevents mod name collisions across games |
name |
TEXT | NOT NULL | Human-readable mod name |
folder_path |
TEXT | NOT NULL | Relative or absolute path to the mod folder (e.g., @ace) |
workshop_id |
TEXT | nullable | Steam Workshop ID, if the mod was installed from Steam |
description |
TEXT | nullable | Free-text description of the mod |
game_data |
TEXT | DEFAULT '{}' |
JSON blob for game-specific mod metadata. For Arma 3 this is typically {} |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Registration timestamp |
Unique constraint: (game_type, folder_path) -- a mod folder path is unique within a game type.
No separate index beyond the UNIQUE constraint -- lookups are by game_type or by id (via server_mods).
Table: server_mods
Junction table for the many-to-many relationship between servers and mods.
CREATE TABLE IF NOT EXISTS server_mods (
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
mod_id INTEGER NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
is_server_mod INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
game_data TEXT DEFAULT '{}',
PRIMARY KEY (server_id, mod_id)
);
CREATE INDEX IF NOT EXISTS idx_server_mods_server ON server_mods(server_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Owning server |
mod_id |
INTEGER | NOT NULL, FK -> mods(id) CASCADE DELETE |
Referenced mod |
is_server_mod |
INTEGER | NOT NULL, DEFAULT 0 |
1 = server-side only mod (not broadcast to clients); 0 = regular mod loaded by all |
sort_order |
INTEGER | NOT NULL, DEFAULT 0 |
Load order position. Lower values load first |
game_data |
TEXT | DEFAULT '{}' |
JSON blob for per-server mod overrides |
Composite primary key: (server_id, mod_id) -- each mod can only be added once to a given server.
Cascade deletes: Removing a server deletes its server_mods rows. Removing a mod deletes all server_mods rows referencing it.
Table: missions
Mission/scenario files tracked per server.
CREATE TABLE IF NOT EXISTS missions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mission_name TEXT NOT NULL,
terrain TEXT,
file_size INTEGER,
game_data TEXT DEFAULT '{}',
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, filename)
);
CREATE INDEX IF NOT EXISTS idx_missions_server ON missions(server_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Owning server |
filename |
TEXT | NOT NULL | Mission file name (e.g., MyMission.Altis.pbo) |
mission_name |
TEXT | NOT NULL | Parsed mission display name (extracted by the adapter) |
terrain |
TEXT | nullable | Map/terrain name (e.g., Altis). NULL for games that do not use the mission/terrain naming convention |
file_size |
INTEGER | nullable | File size in bytes |
game_data |
TEXT | DEFAULT '{}' |
JSON for game-specific mission metadata |
uploaded_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Upload timestamp |
Unique constraint: (server_id, filename) -- each filename is unique within a server's mission pool.
Table: mission_rotation
Ordered mission/scenario cycle for a server. Defines which missions are played and in what order.
CREATE TABLE IF NOT EXISTS mission_rotation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
difficulty TEXT,
params_json TEXT NOT NULL DEFAULT '{}',
game_data TEXT DEFAULT '{}',
UNIQUE (server_id, sort_order)
);
CREATE INDEX IF NOT EXISTS idx_mission_rotation_server ON mission_rotation(server_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Owning server |
mission_id |
INTEGER | NOT NULL, FK -> missions(id) CASCADE DELETE |
Referenced mission |
sort_order |
INTEGER | NOT NULL, DEFAULT 0 |
Position in the rotation. Lower values come first |
difficulty |
TEXT | nullable | Game-specific difficulty setting. NULL for games without difficulty levels |
params_json |
TEXT | NOT NULL, DEFAULT '{}' |
Mission parameters as JSON |
game_data |
TEXT | DEFAULT '{}' |
Adapter-specific rotation metadata |
Unique constraint: (server_id, sort_order) -- prevents two missions from occupying the same position in the rotation.
Cascade deletes: Removing a server deletes all its rotation entries. Removing a mission deletes all rotation entries referencing it.
Table: players
Currently connected players. This is live state, refreshed by the adapter's remote admin poller.
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
slot_id TEXT NOT NULL,
name TEXT NOT NULL,
guid TEXT,
ip TEXT,
ping INTEGER,
game_data TEXT DEFAULT '{}',
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, slot_id)
);
CREATE INDEX IF NOT EXISTS idx_players_server ON players(server_id);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Server the player is connected to |
slot_id |
TEXT | NOT NULL | Game-specific slot identifier (e.g., player number for Arma 3, Steam ID for other games) |
name |
TEXT | NOT NULL | In-game display name |
guid |
TEXT | nullable | Game-specific identifier (BattlEye GUID, Steam ID, etc.) |
ip |
TEXT | nullable | Player IP address |
ping |
INTEGER | nullable | Current ping in milliseconds |
game_data |
TEXT | DEFAULT '{}' |
JSON for game-specific player metadata (e.g., {"verified": true, "steam_uid": "..."} for Arma 3) |
joined_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Connection timestamp |
updated_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Last refresh timestamp |
Unique constraint: (server_id, slot_id) -- each slot on a server can only hold one player at a time.
Lifecycle: When a server stops, all rows for that server_id are deleted (clearing the live player list). Disconnection events create entries in player_history.
Table: player_history
Historical record of player sessions. Rows are inserted when a player disconnects.
CREATE TABLE IF NOT EXISTS player_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
guid TEXT,
ip TEXT,
game_data TEXT DEFAULT '{}',
joined_at TEXT NOT NULL,
left_at TEXT NOT NULL DEFAULT (datetime('now')),
session_duration_seconds INTEGER
);
CREATE INDEX IF NOT EXISTS idx_player_history_server ON player_history(server_id);
CREATE INDEX IF NOT EXISTS idx_player_history_guid ON player_history(guid);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Server the player was connected to |
name |
TEXT | NOT NULL | Player name at time of session |
guid |
TEXT | nullable | Game-specific identifier. Indexed for looking up a player's connection history |
ip |
TEXT | nullable | Player IP address at time of session |
game_data |
TEXT | DEFAULT '{}' |
JSON for game-specific historical data |
joined_at |
TEXT | NOT NULL | Session start timestamp |
left_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Session end timestamp |
session_duration_seconds |
INTEGER | nullable | Calculated session length in seconds. NULL if the duration could not be determined |
Indexes:
| Index | Columns | Purpose |
|---|---|---|
idx_player_history_server |
server_id |
Query history for a specific server |
idx_player_history_guid |
guid |
Look up all sessions for a specific player across servers |
Note: The config.py defines player_history_retention_days: int = 90 but no automated cleanup job is currently registered for this table. A future scheduler job should delete rows where left_at < datetime('now', '-90 days').
Table: bans
Ban records. Core concept is game-agnostic; ban file synchronization is handled by the adapter's BanManager.
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
guid TEXT,
name TEXT,
reason TEXT,
banned_by TEXT,
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
game_data TEXT DEFAULT '{}',
CHECK (is_active IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id);
CREATE INDEX IF NOT EXISTS idx_bans_guid ON bans(guid);
CREATE INDEX IF NOT EXISTS idx_bans_active ON bans(is_active);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Server the ban applies to |
guid |
TEXT | nullable | Game-specific identifier of the banned player. Indexed for fast lookups |
name |
TEXT | nullable | Player name at time of ban |
reason |
TEXT | nullable | Ban reason entered by the admin |
banned_by |
TEXT | nullable | Username of the admin who issued the ban |
banned_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Timestamp when the ban was created |
expires_at |
TEXT | nullable | Expiration timestamp. NULL means permanent ban |
is_active |
INTEGER | NOT NULL, DEFAULT 1, CHECK (0, 1) |
1 = active ban, 0 = lifted/expired ban |
game_data |
TEXT | DEFAULT '{}' |
JSON for game-specific ban data (e.g., {"steam_uid": "...", "ip": "..."}) |
Indexes:
| Index | Columns | Purpose |
|---|---|---|
idx_bans_server |
server_id |
Look up all bans for a server |
idx_bans_guid |
guid |
Fast lookup by player identifier |
idx_bans_active |
is_active |
Filter active vs. inactive bans |
Note: Bans are not physically deleted when lifted; is_active is set to 0 to preserve the audit trail.
Table: logs
Parsed log lines from server log files. Rolling retention (7 days default).
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (level IN ('info', 'warning', 'error'))
);
CREATE INDEX IF NOT EXISTS idx_logs_server_ts ON logs(server_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(created_at);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Server that produced the log |
timestamp |
TEXT | NOT NULL | Original log timestamp (from the log file, not insertion time) |
level |
TEXT | NOT NULL, DEFAULT 'info' |
Severity. Allowed: info, warning, error |
message |
TEXT | NOT NULL | Log message content |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Database insertion timestamp |
Indexes:
| Index | Columns | Purpose |
|---|---|---|
idx_logs_server_ts |
server_id, timestamp |
Time-range queries for a specific server |
idx_logs_level |
level |
Filter logs by severity |
idx_logs_created |
created_at |
Retention cleanup: delete rows older than N days |
Retention: 7 days. Automated cleanup runs daily at 03:00 via APScheduler (core.jobs.cleanup_jobs). Deletes rows where created_at < datetime('now', '-7 days').
Table: metrics
Time-series CPU, RAM, and player count snapshots.
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
cpu_percent REAL,
ram_mb REAL,
player_count INTEGER
);
CREATE INDEX IF NOT EXISTS idx_metrics_server_ts ON metrics(server_id, timestamp);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Server being measured |
timestamp |
TEXT | NOT NULL, DEFAULT datetime('now') |
Measurement timestamp |
cpu_percent |
REAL | nullable | CPU usage percentage. NULL if unavailable |
ram_mb |
REAL | nullable | RAM usage in megabytes. NULL if unavailable |
player_count |
INTEGER | nullable | Number of connected players at this point in time |
Index:
| Index | Columns | Purpose |
|---|---|---|
idx_metrics_server_ts |
server_id, timestamp |
Time-range queries for a specific server's metrics |
Retention: 1 day. Automated cleanup runs every 6 hours via APScheduler. Deletes rows where timestamp < datetime('now', '-1 day').
Table: server_events
Audit trail of all significant events across the system.
CREATE TABLE IF NOT EXISTS server_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor TEXT,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_server ON server_events(server_id, created_at);
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PK, AUTOINCREMENT | Row identifier |
server_id |
INTEGER | NOT NULL, FK -> servers(id) CASCADE DELETE |
Server the event relates to |
event_type |
TEXT | NOT NULL | Event category. Core types: started, stopped, crashed, restarted, config_updated, player_kicked, player_banned, admin_login, auto_restarted, max_restarts_exceeded. Adapters may define additional event types |
actor |
TEXT | nullable | Username or 'system' for automated actions |
detail |
TEXT | nullable | JSON string with event-specific data |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Event timestamp |
Index:
| Index | Columns | Purpose |
|---|---|---|
idx_events_server |
server_id, created_at |
Time-ordered event queries for a specific server |
Retention: 30 days. Automated cleanup runs weekly on Sundays at 04:00 via APScheduler. Deletes rows where created_at < datetime('now', '-30 days').
Relationships Diagram
users (1) ────────────────────────────────────── (ref) server_events.actor
servers (1) ──┬── (many) game_configs [JSON sections replace per-game config tables]
├── (many) server_mods ──── (many) mods [scoped by game_type]
├── (many) missions
├── (many) mission_rotation ──> missions
├── (many) players [live state, cleared on server stop]
├── (many) player_history [historical sessions]
├── (many) bans
├── (many) logs
├── (many) metrics
└── (many) server_events
Foreign key cascade: ON DELETE CASCADE on all server_id foreign keys.
Deleting a server removes all associated data.
Data Access Layer
The application uses raw SQL through SQLAlchemy's text() construct. There is no ORM. All queries are written explicitly in repository classes that inherit from BaseRepository.
BaseRepository
core.dal.base_repository.BaseRepository provides common query helpers:
| Method | Description |
|---|---|
_execute(query, params) |
Execute a parameterized SQL statement |
_fetchone(query, params) |
Execute and return a single row as dict or None |
_fetchall(query, params) |
Execute and return all rows as list[dict] |
_lastrowid(query, params) |
Execute and return the last inserted row ID |
All methods wrap queries in sqlalchemy.text() with named parameter binding (:param_name style).
Repository Classes
| Repository | File | Tables |
|---|---|---|
ConfigRepository |
core/dal/config_repository.py |
game_configs |
BanRepository |
core/dal/ban_repository.py |
bans |
PlayerRepository |
core/dal/player_repository.py |
players, player_history |
LogRepository |
core/dal/log_repository.py |
logs |
MetricsRepository |
core/dal/metrics_repository.py |
metrics |
EventRepository |
core/dal/event_repository.py |
server_events |
Migration Strategy
Migrations are plain .sql files in backend/core/migrations/, applied in order at application startup.
Naming Convention
Files are named {NNN}_{description}.sql where NNN is a zero-padded version number:
001_initial_schema.sql
002_add_game_type.sql
003_add_player_history_indexes.sql
Tracking Table
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
The migration runner in database.py:run_migrations():
- Creates the
schema_migrationstable if it does not exist. - Reads all
*.sqlfiles frombackend/core/migrations/, sorted by filename. - Extracts the version number from the filename prefix (e.g.,
001-> version1). - Skips any version already recorded in
schema_migrations. - Executes each statement in the file separately (split on
;), since SQLite does not supportexecutescriptinside transactions. - Records the version in
schema_migrationsand commits.
Adapter-Specific Migrations
If an adapter requires tables beyond game_configs, adapter-specific migrations go in adapters/<game_type>/migrations/ and are applied by the adapter's initialization logic.
Retention Policies
Automated cleanup is managed by APScheduler jobs registered in core/jobs/cleanup_jobs.py:
| Table | Retention | Cleanup Schedule | Cleanup Column |
|---|---|---|---|
logs |
7 days | Daily at 03:00 | created_at |
metrics |
1 day | Every 6 hours | timestamp |
server_events |
30 days | Weekly (Sunday 04:00) | created_at |
Player history has a configurable retention of 90 days (LANGUARD_PLAYER_HISTORY_RETENTION_DAYS) but no cleanup job is currently registered.
Cleanup queries follow the pattern:
DELETE FROM {table} WHERE {column} < datetime('now', '-{N} days');
game_data JSON Columns
Several tables include a game_data column (TEXT, default '{}') for adapter-specific metadata that does not fit the common schema. This is the extensibility mechanism that allows new game adapters to store game-specific data without altering the database schema.
| Table | Column | Example (Arma 3) |
|---|---|---|
players.game_data |
Player metadata | {"verified": true, "steam_uid": "76561198012345678"} |
missions.game_data |
Mission metadata | {"terrain": "Altis"} |
mods.game_data |
Mod metadata | {} (empty for Arma 3) |
server_mods.game_data |
Per-server mod overrides | {} |
mission_rotation.game_data |
Rotation metadata | {} |
bans.game_data |
Ban metadata | {"steam_uid": "76561198012345678", "ip": "192.168.1.100"} |
player_history.game_data |
Historical metadata | {} |
Each adapter's capability protocols optionally provide a Pydantic schema for their game_data fields. If an adapter does not define a schema, the field accepts any valid JSON object (no validation).
Maintenance Queries
Clear disconnected players on server stop
DELETE FROM players WHERE server_id = :server_id;
Find active bans for a player by GUID
SELECT * FROM bans
WHERE server_id = :server_id
AND guid = :guid
AND is_active = 1
AND (expires_at IS NULL OR expires_at > datetime('now'));
Get current player count for a server
SELECT COUNT(*) FROM players WHERE server_id = :server_id;
Vacuum (run weekly during low traffic)
VACUUM;