This commit is contained in:
devRaGonSa
2026-06-05 16:57:25 +02:00
commit 0da8338ba8
310 changed files with 45849 additions and 0 deletions

549
backend/app/storage.py Normal file
View File

@@ -0,0 +1,549 @@
"""Local SQLite persistence for provisional server snapshots."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Mapping
from .config import get_storage_path, use_postgres_rcon_storage
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
DEFAULT_GAME_SOURCE = {
"slug": "current-hll",
"display_name": "Current Hell Let Loose",
"provider_kind": "development",
}
SUMMARY_SNAPSHOT_LIMIT = 6
def resolve_storage_path(*, db_path: Path | None = None) -> Path:
"""Resolve the SQLite path used by live snapshot persistence."""
return db_path or get_storage_path()
def initialize_storage(*, db_path: Path | None = None) -> Path:
"""Create the local database file and minimal schema when missing."""
resolved_path = resolve_storage_path(db_path=db_path)
resolved_path.parent.mkdir(parents=True, exist_ok=True)
with _connect(resolved_path) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS game_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
provider_kind TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_source_id INTEGER NOT NULL,
external_server_id TEXT,
server_name TEXT NOT NULL,
region TEXT,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (game_source_id, external_server_id),
FOREIGN KEY (game_source_id) REFERENCES game_sources(id)
);
CREATE TABLE IF NOT EXISTS server_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
captured_at TEXT NOT NULL,
status TEXT NOT NULL,
players INTEGER,
max_players INTEGER,
current_map TEXT,
source_name TEXT NOT NULL,
snapshot_origin TEXT,
source_ref TEXT,
raw_payload_ref TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id)
);
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time
ON server_snapshots(server_id, captured_at);
"""
)
_ensure_server_snapshot_columns(connection)
return resolved_path
def persist_snapshot_batch(
snapshots: Iterable[Mapping[str, object]],
*,
source_name: str,
captured_at: str,
game_source: Mapping[str, str] | None = None,
db_path: Path | None = None,
) -> dict[str, object]:
"""Persist a batch of normalized snapshots into local SQLite storage."""
source_definition = dict(DEFAULT_GAME_SOURCE)
if game_source is not None:
source_definition.update(game_source)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_display_storage import persist_server_snapshots
return persist_server_snapshots(
snapshots,
source_name=source_name,
captured_at=captured_at,
game_source=source_definition,
)
resolved_path = initialize_storage(db_path=db_path)
persisted = 0
with _connect(resolved_path) as connection:
game_source_id = _upsert_game_source(connection, source_definition)
for snapshot in snapshots:
server_id = _upsert_server(
connection,
game_source_id=game_source_id,
snapshot=snapshot,
captured_at=captured_at,
)
connection.execute(
"""
INSERT INTO server_snapshots (
server_id,
captured_at,
status,
players,
max_players,
current_map,
source_name,
snapshot_origin,
source_ref,
raw_payload_ref
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
server_id,
captured_at,
snapshot.get("status"),
snapshot.get("players"),
snapshot.get("max_players"),
snapshot.get("current_map"),
snapshot.get("source_name") or source_name,
snapshot.get("snapshot_origin"),
snapshot.get("source_ref"),
None,
),
)
persisted += 1
return {
"db_path": str(resolved_path),
"captured_at": captured_at,
"persisted_snapshots": persisted,
"game_source_slug": source_definition["slug"],
}
def list_latest_snapshots(*, db_path: Path | None = None) -> list[dict[str, object]]:
"""Return the latest persisted snapshot for each known server."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_display_storage import list_latest_server_snapshots
return list_latest_server_snapshots()
resolved_path = resolve_storage_path(db_path=db_path)
if not resolved_path.exists():
return []
with _connect_readonly(resolved_path) as connection:
rows = connection.execute(
"""
SELECT
servers.id AS server_id,
servers.external_server_id,
servers.server_name,
servers.region,
game_sources.slug AS context,
server_snapshots.source_name,
server_snapshots.snapshot_origin,
server_snapshots.source_ref,
server_snapshots.captured_at,
server_snapshots.status,
server_snapshots.players,
server_snapshots.max_players,
server_snapshots.current_map
FROM servers
INNER JOIN game_sources
ON game_sources.id = servers.game_source_id
INNER JOIN server_snapshots
ON server_snapshots.server_id = servers.id
INNER JOIN (
SELECT server_id, MAX(captured_at) AS latest_captured_at
FROM server_snapshots
GROUP BY server_id
) AS latest
ON latest.server_id = server_snapshots.server_id
AND latest.latest_captured_at = server_snapshots.captured_at
ORDER BY servers.server_name ASC
"""
).fetchall()
items = [_serialize_snapshot_row(row) for row in rows]
return _attach_history_summaries(connection, items)
def list_snapshot_history(
*,
db_path: Path | None = None,
limit: int = 20,
) -> list[dict[str, object]]:
"""Return recent persisted snapshots across all servers."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_display_storage import list_server_snapshot_history
return list_server_snapshot_history(limit=limit)
resolved_path = resolve_storage_path(db_path=db_path)
if not resolved_path.exists():
return []
with _connect_readonly(resolved_path) as connection:
rows = connection.execute(
"""
SELECT
servers.id AS server_id,
servers.external_server_id,
servers.server_name,
servers.region,
game_sources.slug AS context,
server_snapshots.source_name,
server_snapshots.snapshot_origin,
server_snapshots.source_ref,
server_snapshots.captured_at,
server_snapshots.status,
server_snapshots.players,
server_snapshots.max_players,
server_snapshots.current_map
FROM server_snapshots
INNER JOIN servers
ON servers.id = server_snapshots.server_id
INNER JOIN game_sources
ON game_sources.id = servers.game_source_id
ORDER BY server_snapshots.captured_at DESC, servers.server_name ASC
LIMIT ?
""",
(limit,),
).fetchall()
return [_serialize_snapshot_row(row) for row in rows]
def list_server_history(
server_id: str,
*,
db_path: Path | None = None,
limit: int = 20,
) -> list[dict[str, object]]:
"""Return recent history for one server by numeric id or external id."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_display_storage import list_server_snapshot_history
return list_server_snapshot_history(server_id=server_id, limit=limit)
resolved_path = resolve_storage_path(db_path=db_path)
if not resolved_path.exists():
return []
server_filter, server_value = _build_server_filter(server_id)
with _connect_readonly(resolved_path) as connection:
rows = connection.execute(
f"""
SELECT
servers.id AS server_id,
servers.external_server_id,
servers.server_name,
servers.region,
game_sources.slug AS context,
server_snapshots.source_name,
server_snapshots.snapshot_origin,
server_snapshots.source_ref,
server_snapshots.captured_at,
server_snapshots.status,
server_snapshots.players,
server_snapshots.max_players,
server_snapshots.current_map
FROM server_snapshots
INNER JOIN servers
ON servers.id = server_snapshots.server_id
INNER JOIN game_sources
ON game_sources.id = servers.game_source_id
WHERE {server_filter} = ?
ORDER BY server_snapshots.captured_at DESC
LIMIT ?
""",
(server_value, limit),
).fetchall()
return [_serialize_snapshot_row(row) for row in rows]
def _connect(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_writer(db_path)
def _connect_readonly(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_readonly(db_path)
def _upsert_game_source(
connection: sqlite3.Connection,
game_source: Mapping[str, str],
) -> int:
connection.execute(
"""
INSERT INTO game_sources (slug, display_name, provider_kind, is_active)
VALUES (?, ?, ?, 1)
ON CONFLICT(slug) DO UPDATE SET
display_name = excluded.display_name,
provider_kind = excluded.provider_kind,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""",
(
game_source["slug"],
game_source["display_name"],
game_source["provider_kind"],
),
)
row = connection.execute(
"SELECT id FROM game_sources WHERE slug = ?",
(game_source["slug"],),
).fetchone()
if row is None:
raise RuntimeError("Failed to resolve game source during snapshot persistence.")
return int(row["id"])
def _upsert_server(
connection: sqlite3.Connection,
*,
game_source_id: int,
snapshot: Mapping[str, object],
captured_at: str,
) -> int:
external_server_id = snapshot.get("external_server_id")
if not isinstance(external_server_id, str) or not external_server_id.strip():
external_server_id = _build_fallback_external_id(snapshot)
server_name = str(snapshot.get("server_name") or "Unknown server")
region = snapshot.get("region")
connection.execute(
"""
INSERT INTO servers (
game_source_id,
external_server_id,
server_name,
region,
first_seen_at,
last_seen_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(game_source_id, external_server_id) DO UPDATE SET
server_name = excluded.server_name,
region = excluded.region,
last_seen_at = excluded.last_seen_at,
updated_at = CURRENT_TIMESTAMP
""",
(
game_source_id,
external_server_id,
server_name,
region,
captured_at,
captured_at,
),
)
row = connection.execute(
"""
SELECT id
FROM servers
WHERE game_source_id = ? AND external_server_id = ?
""",
(game_source_id, external_server_id),
).fetchone()
if row is None:
raise RuntimeError("Failed to resolve server during snapshot persistence.")
return int(row["id"])
def _build_fallback_external_id(snapshot: Mapping[str, object]) -> str:
server_name = str(snapshot.get("server_name") or "unknown-server")
normalized = "".join(
character.lower() if character.isalnum() else "-"
for character in server_name
)
compact = "-".join(part for part in normalized.split("-") if part)
return compact or "unknown-server"
def _ensure_server_snapshot_columns(connection: sqlite3.Connection) -> None:
columns = {
str(row["name"])
for row in connection.execute("PRAGMA table_info(server_snapshots)").fetchall()
}
if "snapshot_origin" not in columns:
connection.execute("ALTER TABLE server_snapshots ADD COLUMN snapshot_origin TEXT")
if "source_ref" not in columns:
connection.execute("ALTER TABLE server_snapshots ADD COLUMN source_ref TEXT")
connection.execute(
"""
UPDATE server_snapshots
SET snapshot_origin = CASE
WHEN source_name = 'controlled-placeholder' THEN 'controlled-fallback'
WHEN source_name LIKE '%a2s%' THEN 'real-a2s'
ELSE 'unknown'
END
WHERE snapshot_origin IS NULL OR snapshot_origin = ''
"""
)
connection.execute(
"""
UPDATE server_snapshots
SET source_ref = source_name
WHERE source_ref IS NULL OR source_ref = ''
"""
)
_backfill_registered_a2s_source_refs(connection)
def _backfill_registered_a2s_source_refs(connection: sqlite3.Connection) -> None:
from .server_targets import load_a2s_targets
for target in load_a2s_targets():
if not target.external_server_id:
continue
connection.execute(
"""
UPDATE server_snapshots
SET source_ref = ?
WHERE snapshot_origin = 'real-a2s'
AND source_ref = source_name
AND server_id IN (
SELECT id
FROM servers
WHERE external_server_id = ?
)
""",
(
f"a2s://{target.host}:{target.query_port}",
target.external_server_id,
),
)
def _serialize_snapshot_row(row: sqlite3.Row) -> dict[str, object]:
return {
"server_id": row["server_id"],
"external_server_id": row["external_server_id"],
"server_name": row["server_name"],
"region": row["region"],
"context": row["context"],
"source_name": row["source_name"],
"snapshot_origin": row["snapshot_origin"],
"source_ref": row["source_ref"],
"captured_at": row["captured_at"],
"status": row["status"],
"players": row["players"],
"max_players": row["max_players"],
"current_map": row["current_map"],
}
def _attach_history_summaries(
connection: sqlite3.Connection,
items: list[dict[str, object]],
) -> list[dict[str, object]]:
enriched_items: list[dict[str, object]] = []
for item in items:
enriched = dict(item)
enriched["history_summary"] = _build_history_summary(
connection,
int(item["server_id"]),
)
enriched_items.append(enriched)
return enriched_items
def _build_history_summary(
connection: sqlite3.Connection,
server_id: int,
) -> dict[str, object]:
rows = connection.execute(
"""
SELECT
captured_at,
status,
players
FROM server_snapshots
WHERE server_id = ?
ORDER BY captured_at DESC
LIMIT ?
""",
(server_id, SUMMARY_SNAPSHOT_LIMIT),
).fetchall()
return _summarize_history_rows(rows)
def _summarize_history_rows(rows: list[sqlite3.Row]) -> dict[str, object]:
capture_count = len(rows)
player_values = [
int(row["players"])
for row in rows
if row["players"] is not None
]
online_rows = [row for row in rows if row["status"] == "online"]
latest_captured_at = str(rows[0]["captured_at"]) if rows else None
last_seen_online_at = str(online_rows[0]["captured_at"]) if online_rows else None
return {
"window_size": SUMMARY_SNAPSHOT_LIMIT,
"recent_capture_count": capture_count,
"recent_online_count": len(online_rows),
"recent_average_players": _round_average(player_values),
"recent_peak_players": max(player_values, default=None),
"last_seen_online_at": last_seen_online_at,
"minutes_since_last_capture": _minutes_since_timestamp(latest_captured_at),
}
def _round_average(values: list[int]) -> float | None:
if not values:
return None
return round(sum(values) / len(values), 1)
def _minutes_since_timestamp(timestamp: str | None) -> int | None:
if not timestamp:
return None
normalized = timestamp.replace("Z", "+00:00")
captured_at = datetime.fromisoformat(normalized)
if captured_at.tzinfo is None:
captured_at = captured_at.replace(tzinfo=timezone.utc)
delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
return max(0, int(delta.total_seconds() // 60))
def _build_server_filter(server_id: str) -> tuple[str, object]:
normalized = server_id.strip()
if normalized.isdigit():
return "servers.id", int(normalized)
return "servers.external_server_id", normalized