550 lines
18 KiB
Python
550 lines
18 KiB
Python
"""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
|