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

896 lines
37 KiB
Markdown

# 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.
```sql
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.
```sql
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.
```sql
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:
```json
{
"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:
```json
{
"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:
```json
{
"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:
```json
{
"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:
```json
{
"rcon_password": "encrypted:...",
"max_ping": 200,
"enabled": 1
}
```
---
### Table: `mods`
Registered mods. Scoped by `game_type` to keep the mod catalog per-game.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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.
```sql
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`.
```sql
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).
```sql
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.
```sql
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.
```sql
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
```sql
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:
```sql
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
```sql
DELETE FROM players WHERE server_id = :server_id;
```
### Find active bans for a player by GUID
```sql
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
```sql
SELECT COUNT(*) FROM players WHERE server_id = :server_id;
```
### Vacuum (run weekly during low traffic)
```sql
VACUUM;
```