454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""Separate storage and run tracking for prospective RCON historical capture."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from collections.abc import Mapping
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from .config import get_storage_path
|
|
|
|
|
|
def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
|
|
"""Create the SQLite structures used by prospective RCON capture."""
|
|
resolved_path = db_path or get_storage_path()
|
|
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS rcon_historical_targets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
target_key TEXT NOT NULL UNIQUE,
|
|
external_server_id TEXT,
|
|
display_name TEXT NOT NULL,
|
|
host TEXT NOT NULL,
|
|
port INTEGER NOT NULL,
|
|
region TEXT,
|
|
game_port INTEGER,
|
|
query_port INTEGER,
|
|
source_name TEXT NOT NULL,
|
|
last_configured_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS rcon_historical_capture_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
mode TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
target_scope TEXT,
|
|
started_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
targets_seen INTEGER NOT NULL DEFAULT 0,
|
|
samples_inserted INTEGER NOT NULL DEFAULT 0,
|
|
duplicate_samples INTEGER NOT NULL DEFAULT 0,
|
|
failed_targets INTEGER NOT NULL DEFAULT 0,
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS rcon_historical_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
target_id INTEGER NOT NULL,
|
|
capture_run_id INTEGER,
|
|
captured_at TEXT NOT NULL,
|
|
source_kind TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
players INTEGER,
|
|
max_players INTEGER,
|
|
current_map TEXT,
|
|
normalized_payload_json TEXT NOT NULL,
|
|
raw_payload_json TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(target_id, captured_at),
|
|
FOREIGN KEY (target_id) REFERENCES rcon_historical_targets(id),
|
|
FOREIGN KEY (capture_run_id) REFERENCES rcon_historical_capture_runs(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS rcon_historical_checkpoints (
|
|
target_id INTEGER PRIMARY KEY,
|
|
last_successful_capture_at TEXT,
|
|
last_sample_at TEXT,
|
|
last_run_id INTEGER,
|
|
last_run_status TEXT,
|
|
last_error TEXT,
|
|
last_error_at TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (target_id) REFERENCES rcon_historical_targets(id),
|
|
FOREIGN KEY (last_run_id) REFERENCES rcon_historical_capture_runs(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time
|
|
ON rcon_historical_samples(target_id, captured_at DESC);
|
|
"""
|
|
)
|
|
|
|
return resolved_path
|
|
|
|
|
|
def start_rcon_historical_capture_run(
|
|
*,
|
|
mode: str,
|
|
target_scope: str,
|
|
db_path: Path | None = None,
|
|
) -> int:
|
|
"""Create one run row for prospective RCON capture."""
|
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO rcon_historical_capture_runs (
|
|
mode,
|
|
status,
|
|
target_scope,
|
|
started_at
|
|
) VALUES (?, 'running', ?, ?)
|
|
""",
|
|
(mode, target_scope, _utc_now_iso()),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
|
|
def finalize_rcon_historical_capture_run(
|
|
run_id: int,
|
|
*,
|
|
status: str,
|
|
targets_seen: int,
|
|
samples_inserted: int,
|
|
duplicate_samples: int,
|
|
failed_targets: int,
|
|
notes: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> None:
|
|
"""Finalize one prospective RCON capture run."""
|
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE rcon_historical_capture_runs
|
|
SET status = ?,
|
|
completed_at = ?,
|
|
targets_seen = ?,
|
|
samples_inserted = ?,
|
|
duplicate_samples = ?,
|
|
failed_targets = ?,
|
|
notes = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
status,
|
|
_utc_now_iso(),
|
|
targets_seen,
|
|
samples_inserted,
|
|
duplicate_samples,
|
|
failed_targets,
|
|
notes,
|
|
run_id,
|
|
),
|
|
)
|
|
|
|
|
|
def persist_rcon_historical_sample(
|
|
*,
|
|
run_id: int,
|
|
captured_at: str,
|
|
target: Mapping[str, object],
|
|
normalized_payload: Mapping[str, object],
|
|
raw_payload: Mapping[str, object] | None,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, int]:
|
|
"""Persist one prospective RCON sample and refresh its checkpoint."""
|
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
target_id = _upsert_target(connection, target=target)
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO rcon_historical_samples (
|
|
target_id,
|
|
capture_run_id,
|
|
captured_at,
|
|
source_kind,
|
|
status,
|
|
players,
|
|
max_players,
|
|
current_map,
|
|
normalized_payload_json,
|
|
raw_payload_json
|
|
) VALUES (?, ?, ?, 'rcon-live-sample', ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
target_id,
|
|
run_id,
|
|
captured_at,
|
|
normalized_payload.get("status") or "unknown",
|
|
normalized_payload.get("players"),
|
|
normalized_payload.get("max_players"),
|
|
normalized_payload.get("current_map"),
|
|
json.dumps(dict(normalized_payload), separators=(",", ":")),
|
|
json.dumps(dict(raw_payload), separators=(",", ":")) if raw_payload else None,
|
|
),
|
|
)
|
|
inserted = int(cursor.rowcount or 0)
|
|
_upsert_checkpoint_success(
|
|
connection,
|
|
target_id=target_id,
|
|
run_id=run_id,
|
|
captured_at=captured_at,
|
|
)
|
|
return {
|
|
"samples_inserted": inserted,
|
|
"duplicate_samples": 0 if inserted else 1,
|
|
}
|
|
|
|
|
|
def mark_rcon_historical_capture_failure(
|
|
*,
|
|
run_id: int,
|
|
target: Mapping[str, object],
|
|
error_message: str,
|
|
db_path: Path | None = None,
|
|
) -> None:
|
|
"""Persist failure metadata for one target inside a capture run."""
|
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
target_id = _upsert_target(connection, target=target)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO rcon_historical_checkpoints (
|
|
target_id,
|
|
last_run_id,
|
|
last_run_status,
|
|
last_error,
|
|
last_error_at
|
|
) VALUES (?, ?, 'failed', ?, ?)
|
|
ON CONFLICT(target_id) DO UPDATE SET
|
|
last_run_id = excluded.last_run_id,
|
|
last_run_status = excluded.last_run_status,
|
|
last_error = excluded.last_error,
|
|
last_error_at = excluded.last_error_at,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(target_id, run_id, error_message, _utc_now_iso()),
|
|
)
|
|
|
|
|
|
def list_rcon_historical_target_statuses(
|
|
*,
|
|
db_path: Path | None = None,
|
|
) -> list[dict[str, object]]:
|
|
"""Return per-target coverage and freshness for prospective RCON capture."""
|
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT
|
|
targets.target_key,
|
|
targets.external_server_id,
|
|
targets.display_name,
|
|
targets.host,
|
|
targets.port,
|
|
targets.region,
|
|
targets.source_name,
|
|
checkpoints.last_successful_capture_at,
|
|
checkpoints.last_sample_at,
|
|
checkpoints.last_run_id,
|
|
checkpoints.last_run_status,
|
|
checkpoints.last_error,
|
|
checkpoints.last_error_at,
|
|
(
|
|
SELECT MIN(samples.captured_at)
|
|
FROM rcon_historical_samples AS samples
|
|
WHERE samples.target_id = targets.id
|
|
) AS first_sample_at,
|
|
(
|
|
SELECT MAX(samples.captured_at)
|
|
FROM rcon_historical_samples AS samples
|
|
WHERE samples.target_id = targets.id
|
|
) AS latest_sample_at,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM rcon_historical_samples AS samples
|
|
WHERE samples.target_id = targets.id
|
|
) AS sample_count
|
|
FROM rcon_historical_targets AS targets
|
|
LEFT JOIN rcon_historical_checkpoints AS checkpoints
|
|
ON checkpoints.target_id = targets.id
|
|
ORDER BY targets.display_name ASC, targets.target_key ASC
|
|
"""
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"target_key": row["target_key"],
|
|
"external_server_id": row["external_server_id"],
|
|
"display_name": row["display_name"],
|
|
"host": row["host"],
|
|
"port": row["port"],
|
|
"region": row["region"],
|
|
"source_name": row["source_name"],
|
|
"sample_count": int(row["sample_count"] or 0),
|
|
"first_sample_at": row["first_sample_at"],
|
|
"last_successful_capture_at": row["last_successful_capture_at"],
|
|
"last_sample_at": row["latest_sample_at"] or row["last_sample_at"],
|
|
"last_run_id": row["last_run_id"],
|
|
"last_run_status": row["last_run_status"],
|
|
"last_error": row["last_error"],
|
|
"last_error_at": row["last_error_at"],
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
def list_recent_rcon_historical_samples(
|
|
*,
|
|
target_key: str | None = None,
|
|
limit: int = 20,
|
|
db_path: Path | None = None,
|
|
) -> list[dict[str, object]]:
|
|
"""Return recent prospective RCON samples for one or all configured targets."""
|
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
|
where_clause = ""
|
|
params: list[object] = [limit]
|
|
if target_key:
|
|
where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?"
|
|
params = [target_key, target_key, limit]
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
targets.target_key,
|
|
targets.external_server_id,
|
|
targets.display_name,
|
|
targets.region,
|
|
samples.captured_at,
|
|
samples.status,
|
|
samples.players,
|
|
samples.max_players,
|
|
samples.current_map
|
|
FROM rcon_historical_samples AS samples
|
|
INNER JOIN rcon_historical_targets AS targets
|
|
ON targets.id = samples.target_id
|
|
{where_clause}
|
|
ORDER BY samples.captured_at DESC, targets.display_name ASC
|
|
LIMIT ?
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"target_key": row["target_key"],
|
|
"external_server_id": row["external_server_id"],
|
|
"display_name": row["display_name"],
|
|
"region": row["region"],
|
|
"captured_at": row["captured_at"],
|
|
"status": row["status"],
|
|
"players": row["players"],
|
|
"max_players": row["max_players"],
|
|
"current_map": row["current_map"],
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(db_path)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
return connection
|
|
|
|
|
|
def _upsert_target(connection: sqlite3.Connection, *, target: Mapping[str, object]) -> int:
|
|
target_key = str(target.get("target_key") or "").strip()
|
|
if not target_key:
|
|
raise ValueError("Prospective RCON targets require a non-empty target_key.")
|
|
display_name = str(target.get("name") or target.get("display_name") or target_key).strip()
|
|
host = str(target.get("host") or "").strip()
|
|
port = int(target.get("port") or 0)
|
|
if not host or port <= 0:
|
|
raise ValueError("Prospective RCON targets require host and port.")
|
|
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO rcon_historical_targets (
|
|
target_key,
|
|
external_server_id,
|
|
display_name,
|
|
host,
|
|
port,
|
|
region,
|
|
game_port,
|
|
query_port,
|
|
source_name,
|
|
last_configured_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(target_key) DO UPDATE SET
|
|
external_server_id = excluded.external_server_id,
|
|
display_name = excluded.display_name,
|
|
host = excluded.host,
|
|
port = excluded.port,
|
|
region = excluded.region,
|
|
game_port = excluded.game_port,
|
|
query_port = excluded.query_port,
|
|
source_name = excluded.source_name,
|
|
last_configured_at = excluded.last_configured_at,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
target_key,
|
|
target.get("external_server_id"),
|
|
display_name,
|
|
host,
|
|
port,
|
|
target.get("region"),
|
|
target.get("game_port"),
|
|
target.get("query_port"),
|
|
str(target.get("source_name") or "community-hispana-rcon"),
|
|
_utc_now_iso(),
|
|
),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT id FROM rcon_historical_targets WHERE target_key = ?",
|
|
(target_key,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise RuntimeError("Failed to resolve prospective RCON target id.")
|
|
return int(row["id"])
|
|
|
|
|
|
def _upsert_checkpoint_success(
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
target_id: int,
|
|
run_id: int,
|
|
captured_at: str,
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO rcon_historical_checkpoints (
|
|
target_id,
|
|
last_successful_capture_at,
|
|
last_sample_at,
|
|
last_run_id,
|
|
last_run_status,
|
|
last_error,
|
|
last_error_at
|
|
) VALUES (?, ?, ?, ?, 'success', NULL, NULL)
|
|
ON CONFLICT(target_id) DO UPDATE SET
|
|
last_successful_capture_at = excluded.last_successful_capture_at,
|
|
last_sample_at = excluded.last_sample_at,
|
|
last_run_id = excluded.last_run_id,
|
|
last_run_status = excluded.last_run_status,
|
|
last_error = NULL,
|
|
last_error_at = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(target_id, captured_at, captured_at, run_id),
|
|
)
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|