Files
languard-servers-manager/DATABASE.md
Tran G. (Revernomad) Khoa 6511353b55 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
2026-04-17 11:58:34 +07:00

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) and viewer (read-only). The CHECK constraint enforces this at the database level.
  • password_hash stores 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_KEY environment 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:

  1. Client reads config section, receives _meta.config_version (e.g., 3)
  2. Client sends PUT with config_version: 3 in the request body
  3. Server checks: if expected_config_version is provided and differs from the stored version, raise ValueError with "CONFIG_VERSION_CONFLICT:<current_version>"
  4. On conflict: return 409 Conflict with the current config so the client can merge
  5. 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:

  1. On read, detect stored_schema_version != adapter.get_config_version()
  2. Call adapter.migrate_config(stored_schema_version, config_json) -- returns migrated dict
  3. Update the row: SET config_json = ?, schema_version = ? WHERE id = ?
  4. 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():

  1. Creates the schema_migrations table if it does not exist.
  2. Reads all *.sql files from backend/core/migrations/, sorted by filename.
  3. Extracts the version number from the filename prefix (e.g., 001 -> version 1).
  4. Skips any version already recorded in schema_migrations.
  5. Executes each statement in the file separately (split on ;), since SQLite does not support executescript inside transactions.
  6. Records the version in schema_migrations and 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;