Merge remote-tracking branch 'origin/codex/migrate-storage-to-postgres-phase-1'
This commit is contained in:
113
ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md
Normal file
113
ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
id: TASK-144
|
||||||
|
title: Home region cleanup and RCON freshness diagnostics
|
||||||
|
status: done
|
||||||
|
type: integration
|
||||||
|
team: Backend Senior
|
||||||
|
supporting_teams: [Frontend Senior]
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-144 - Home region cleanup and RCON freshness diagnostics
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Hide placeholder region values on the home page server cards and improve RCON historical freshness diagnostics so stale recent matches are explainable from worker/runner logs.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The home page currently renders `Region pendiente` when a server region is missing. Recent RCON materialized matches can also appear stale without clear logs showing whether AdminLog events were seen, inserted, duplicated or materialized.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Inspect home server-card rendering and RCON runner/worker/ingestion/materialization paths.
|
||||||
|
2. Hide placeholder/missing region quick facts while preserving map data.
|
||||||
|
3. Determine whether historical-runner refreshes RCON AdminLog materialization or whether rcon-historical-worker owns it.
|
||||||
|
4. Add minimal logging/summary output for AdminLog ingestion/materialization and latest materialized match freshness.
|
||||||
|
5. Validate frontend syntax, backend compile, pipeline scripts, Compose services, logs, manual commands and rendered pages.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `ai/orchestrator/backend-senior.md`
|
||||||
|
- `ai/orchestrator/frontend-senior.md`
|
||||||
|
- `frontend/assets/js/main.js`
|
||||||
|
- `backend/app/rcon_historical_worker.py`
|
||||||
|
- `backend/app/rcon_admin_log_ingestion.py`
|
||||||
|
- `backend/app/rcon_admin_log_materialization.py`
|
||||||
|
- `backend/app/historical_runner.py`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
|
||||||
|
- `frontend/assets/js/main.js`
|
||||||
|
- `backend/app/rcon_historical_worker.py`
|
||||||
|
- `backend/app/rcon_admin_log_ingestion.py`
|
||||||
|
- `backend/app/rcon_admin_log_materialization.py`
|
||||||
|
- `ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not expose secrets.
|
||||||
|
- Do not reintroduce Comunidad Hispana #03.
|
||||||
|
- Do not change recent-card visual design.
|
||||||
|
- Do not commit runtime DB files.
|
||||||
|
- Preserve manual RCON commands.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `node --check frontend/assets/js/main.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker`
|
||||||
|
- `docker compose --profile advanced ps`
|
||||||
|
- `docker compose logs --tail=150 historical-runner`
|
||||||
|
- `docker compose logs --tail=150 rcon-historical-worker`
|
||||||
|
- `docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 360`
|
||||||
|
- `docker compose exec backend python -m app.rcon_admin_log_materialization`
|
||||||
|
- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content`
|
||||||
|
- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content`
|
||||||
|
- Browser verification on `index.html?nocache=region`
|
||||||
|
- Browser verification on `historico.html?nocache=freshness`
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implemented. Home server cards now omit the Region quick fact when the region is missing or a placeholder such as `Region pendiente`, while preserving the Mapa quick fact. RCON capture now runs AdminLog materialization after AdminLog ingestion and emits freshness diagnostics in the worker result, including event counters, materialized match counters and latest materialized/AdminLog match-end timestamps per configured server.
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
|
||||||
|
- `historical-runner` already calls the RCON capture path when historical data source is RCON, but the capture path previously ingested AdminLog entries without materializing them into recent matches.
|
||||||
|
- `rcon-historical-worker` owns the 10-minute capture loop and now materializes after ingestion, so recent matches can advance without requiring a manual materialization command.
|
||||||
|
- The live diagnostics show new recent data after the previously stale `17:38` item. Recent matches now include `comunidad-hispana-01:1779299747:1779305147:carentanwarfare` closed at `2026-05-20T19:26:46.519Z` and `comunidad-hispana-02:1779296626:1779301626:carentanwarfare` closed at `2026-05-20T18:41:59.219Z`.
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `node --check frontend/assets/js/main.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker`
|
||||||
|
- `docker compose --profile advanced ps`
|
||||||
|
- `docker compose logs --tail=150 historical-runner`
|
||||||
|
- `docker compose logs --tail=150 rcon-historical-worker`
|
||||||
|
- `docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 360`
|
||||||
|
- `docker compose exec backend python -m app.rcon_admin_log_materialization`
|
||||||
|
- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content`
|
||||||
|
- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content`
|
||||||
|
- Browser verification on `http://localhost:8080/index.html?nocache=region`
|
||||||
|
- Browser verification on `http://localhost:8080/historico.html?nocache=freshness`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The RCON pipeline script reports existing SQLite `ResourceWarning` messages from its test harness, but the unittest suites return `OK` and the script reports validation passed.
|
||||||
|
- The integration script exits successfully, but the local runtime DB emits an existing `database disk image is malformed` traceback after the pass message. Runtime DB files were not modified for commit.
|
||||||
|
- Browser plugin runtime tools were not exposed by tool discovery in this session, so rendered validation used local Chrome/Selenium fallback.
|
||||||
141
ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md
Normal file
141
ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
id: TASK-POSTGRES-RCON-PHASE-1
|
||||||
|
title: Migrate RCON historical persistence phase 1
|
||||||
|
status: done
|
||||||
|
type: backend
|
||||||
|
team: Backend Senior
|
||||||
|
supporting_teams:
|
||||||
|
- Arquitecto de Base de Datos
|
||||||
|
- Frontend Senior
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-POSTGRES-RCON-PHASE-1 - Migrate RCON historical persistence phase 1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Move the lock-prone RCON historical pipeline to PostgreSQL in Docker while
|
||||||
|
keeping local SQLite fallback and fixing the requested match-detail and server
|
||||||
|
card frontend regressions.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
AdminLog ingestion and materialization currently share SQLite files with
|
||||||
|
multiple services and materialization can fail with `database is locked`.
|
||||||
|
PostgreSQL must become authoritative for RCON capture, AdminLog events,
|
||||||
|
materialized matches, player stats and the detail/recent read model in the
|
||||||
|
advanced Docker flow. Scoreboard historical persistence may remain on SQLite in
|
||||||
|
this phase when it is used only as fallback or correlation source.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Inspect the storage, read-model and frontend files named in the request.
|
||||||
|
2. Add a deterministic PostgreSQL schema and route the phase-1 RCON storage
|
||||||
|
domains through it when `HLL_BACKEND_DATABASE_URL` is set.
|
||||||
|
3. Wire Compose diagnostics and required frontend fixes without expanding Elo,
|
||||||
|
backend scope outside RCON persistence, or Comunidad Hispana #03 targets.
|
||||||
|
4. Validate the requested commands, document what remains SQLite-backed, and
|
||||||
|
complete the task record.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `backend/app/config.py`
|
||||||
|
- `backend/app/rcon_admin_log_storage.py`
|
||||||
|
- `backend/app/rcon_admin_log_materialization.py`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
|
||||||
|
- `backend/app/config.py`
|
||||||
|
- `backend/app/postgres_rcon_storage.py`
|
||||||
|
- `backend/app/rcon_admin_log_storage.py`
|
||||||
|
- `backend/app/rcon_admin_log_materialization.py`
|
||||||
|
- `backend/app/rcon_historical_storage.py`
|
||||||
|
- `backend/app/rcon_scoreboard_correlation.py`
|
||||||
|
- `backend/app/storage_diagnostics.py`
|
||||||
|
- `backend/README.md`
|
||||||
|
- `backend/requirements.txt`
|
||||||
|
- `backend/.env.example`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- `frontend/assets/js/historico.js`
|
||||||
|
- `frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `frontend/assets/js/main.js`
|
||||||
|
- `frontend/assets/css/historico-scoreboard-detail.css`
|
||||||
|
- `docs/decisions.md`
|
||||||
|
- `scripts/run-historical-ui-regression-tests.ps1`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- PostgreSQL is the Docker default for migrated RCON domains.
|
||||||
|
- SQLite remains only fallback or source material for domains not migrated in
|
||||||
|
this phase.
|
||||||
|
- Do not add SQLite lock workarounds as the main solution.
|
||||||
|
- Keep external scoreboard URLs on the trusted scoreboard allowlist.
|
||||||
|
- Do not modify unrelated files or add runtime database artifacts.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `python -m app.storage_diagnostics`
|
||||||
|
- `node --check frontend/assets/js/main.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- Requested Docker Compose and endpoint checks when local Docker/RCON
|
||||||
|
credentials permit them.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implemented phase-1 PostgreSQL delegation for RCON capture samples/windows,
|
||||||
|
AdminLog events/profile snapshots, materialized RCON matches/player stats, and
|
||||||
|
safe scoreboard candidate caching. Docker Compose now configures PostgreSQL as
|
||||||
|
the RCON storage backend for `backend`, `historical-runner` and
|
||||||
|
`rcon-historical-worker`; local code paths with explicit SQLite paths or no
|
||||||
|
database URL retain SQLite fallback behavior.
|
||||||
|
|
||||||
|
Frontend changes remove hover-driven player panel expansion in the internal
|
||||||
|
match detail table, keep the scoreboard link on the internal detail action area
|
||||||
|
only, and suppress pending-style region placeholders on server cards.
|
||||||
|
|
||||||
|
Validation completed:
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `python -m app.storage_diagnostics` from `backend/`
|
||||||
|
- `node --check frontend/assets/js/main.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- `docker compose --profile advanced config --quiet`
|
||||||
|
- local fallback HTTP smoke for `/health`,
|
||||||
|
`/api/historical/recent-matches`, and the requested known match detail URL
|
||||||
|
- direct known-match detail payload check confirmed safe URL
|
||||||
|
`https://scoreboard.comunidadhll.es:5443/games/1562094`
|
||||||
|
|
||||||
|
`scripts/run-rcon-data-pipeline-tests.ps1` completed parser/storage and
|
||||||
|
unittest work, then exited nonzero when its optional Docker smoke step could
|
||||||
|
not reach the Docker Desktop Linux engine. The requested `docker compose
|
||||||
|
--profile advanced up ...` and `docker compose --profile advanced ps` checks
|
||||||
|
failed for the same local engine-unavailable reason, so container diagnostics,
|
||||||
|
container RCON commands and Docker-backed HTTP probes remain to rerun when
|
||||||
|
Docker is available. The Browser plugin is listed but its required JavaScript
|
||||||
|
control tool was not exposed in this session, so rendered interaction QA remains
|
||||||
|
manual or follow-up Browser verification.
|
||||||
|
|
||||||
|
Phase boundary documented in `docs/decisions.md` and diagnostics: live server
|
||||||
|
snapshot cache, public-scoreboard `historical_*` data/rankings, historical
|
||||||
|
snapshot files, player-event ledger and paused Elo/MMR storage remain SQLite or
|
||||||
|
file-backed in this phase.
|
||||||
|
|
||||||
|
## Change Budget
|
||||||
|
|
||||||
|
This phase intentionally exceeds the default change budget because PostgreSQL
|
||||||
|
wiring crosses schema initialization, Compose runtime, RCON storage domains,
|
||||||
|
diagnostics, docs, and the requested frontend regressions. Keep the migration
|
||||||
|
surface limited to the RCON path and leave remaining SQLite domains documented.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
HLL_BACKEND_HOST=0.0.0.0
|
HLL_BACKEND_HOST=0.0.0.0
|
||||||
HLL_BACKEND_PORT=8000
|
HLL_BACKEND_PORT=8000
|
||||||
HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3
|
HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3
|
||||||
|
HLL_BACKEND_DATABASE_URL=postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam
|
||||||
HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080
|
HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080
|
||||||
HLL_BACKEND_REFRESH_INTERVAL_SECONDS=120
|
HLL_BACKEND_REFRESH_INTERVAL_SECONDS=120
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE=rcon
|
HLL_BACKEND_LIVE_DATA_SOURCE=rcon
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ Variables especialmente relevantes para Docker y Compose:
|
|||||||
|
|
||||||
- `HLL_BACKEND_HOST`
|
- `HLL_BACKEND_HOST`
|
||||||
- `HLL_BACKEND_PORT`
|
- `HLL_BACKEND_PORT`
|
||||||
|
- `HLL_BACKEND_DATABASE_URL`
|
||||||
- `HLL_BACKEND_STORAGE_PATH`
|
- `HLL_BACKEND_STORAGE_PATH`
|
||||||
- `HLL_BACKEND_ALLOWED_ORIGINS`
|
- `HLL_BACKEND_ALLOWED_ORIGINS`
|
||||||
- `HLL_BACKEND_LIVE_DATA_SOURCE`
|
- `HLL_BACKEND_LIVE_DATA_SOURCE`
|
||||||
@@ -132,6 +133,21 @@ Dentro del contenedor arranca por defecto con:
|
|||||||
- `HLL_BACKEND_PORT=8000`
|
- `HLL_BACKEND_PORT=8000`
|
||||||
- `HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3`
|
- `HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3`
|
||||||
|
|
||||||
|
Compose configura ademas `HLL_BACKEND_DATABASE_URL` para que PostgreSQL sea el
|
||||||
|
almacenamiento autoritativo de la fase 1 RCON: muestras/ventanas de captura,
|
||||||
|
AdminLog, snapshots de perfil y partidas/estadisticas materializadas. Sin esa
|
||||||
|
variable, la ejecucion local mantiene fallback SQLite para esos dominios.
|
||||||
|
|
||||||
|
Diagnostico rapido del backend activo:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.storage_diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
La salida lista el backend RCON activo, counts de las tablas migradas, la ultima
|
||||||
|
partida materializada por servidor y que superficies siguen temporalmente en
|
||||||
|
SQLite en esta fase.
|
||||||
|
|
||||||
Build local:
|
Build local:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ def get_storage_path() -> Path:
|
|||||||
return Path(configured_path) if configured_path else default_path
|
return Path(configured_path) if configured_path else default_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_url() -> str | None:
|
||||||
|
"""Return the optional PostgreSQL URL for migrated backend storage domains."""
|
||||||
|
configured_url = os.getenv("HLL_BACKEND_DATABASE_URL")
|
||||||
|
if configured_url is None:
|
||||||
|
return None
|
||||||
|
normalized_url = configured_url.strip()
|
||||||
|
return normalized_url or None
|
||||||
|
|
||||||
|
|
||||||
|
def use_postgres_rcon_storage(*, explicit_sqlite_path: Path | None = None) -> bool:
|
||||||
|
"""Return whether phase-1 RCON storage should use PostgreSQL."""
|
||||||
|
return explicit_sqlite_path is None and get_database_url() is not None
|
||||||
|
|
||||||
|
|
||||||
def get_sqlite_writer_timeout_seconds() -> float:
|
def get_sqlite_writer_timeout_seconds() -> float:
|
||||||
"""Return the SQLite connection timeout shared by writer-capable storage layers."""
|
"""Return the SQLite connection timeout shared by writer-capable storage layers."""
|
||||||
configured_value = os.getenv(
|
configured_value = os.getenv(
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def run_periodic_historical_refresh(
|
|||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
run_number=completed_runs,
|
run_number=completed_runs,
|
||||||
)
|
)
|
||||||
print(json.dumps({"run": completed_runs, **payload}, indent=2))
|
print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True)
|
||||||
|
|
||||||
if max_runs is not None and completed_runs >= max_runs:
|
if max_runs is not None and completed_runs >= max_runs:
|
||||||
break
|
break
|
||||||
@@ -276,6 +276,10 @@ def _rcon_capture_has_new_useful_data(rcon_capture_result: dict[str, Any]) -> bo
|
|||||||
totals = rcon_capture_result.get("totals")
|
totals = rcon_capture_result.get("totals")
|
||||||
if isinstance(totals, dict) and int(totals.get("samples_inserted") or 0) > 0:
|
if isinstance(totals, dict) and int(totals.get("samples_inserted") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
|
if isinstance(totals, dict) and int(totals.get("admin_log_events_inserted") or 0) > 0:
|
||||||
|
return True
|
||||||
|
if isinstance(totals, dict) and int(totals.get("materialized_matches_inserted") or 0) > 0:
|
||||||
|
return True
|
||||||
targets = rcon_capture_result.get("targets")
|
targets = rcon_capture_result.get("targets")
|
||||||
if not isinstance(targets, list):
|
if not isinstance(targets, list):
|
||||||
return False
|
return False
|
||||||
|
|||||||
986
backend/app/postgres_rcon_storage.py
Normal file
986
backend/app/postgres_rcon_storage.py
Normal file
@@ -0,0 +1,986 @@
|
|||||||
|
"""PostgreSQL persistence for the phase-1 RCON historical pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import get_database_url
|
||||||
|
from .normalizers import normalize_map_name
|
||||||
|
from .rcon_client import load_rcon_targets
|
||||||
|
|
||||||
|
|
||||||
|
COMPETITIVE_WINDOW_GAP_SECONDS = 1800
|
||||||
|
COMPETITIVE_MODE_PARTIAL = "partial"
|
||||||
|
COMPETITIVE_MODE_APPROXIMATE = "approximate"
|
||||||
|
COMPETITIVE_MODE_EXACT = "exact"
|
||||||
|
|
||||||
|
|
||||||
|
RCON_SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_historical_targets (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
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 TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_historical_capture_runs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
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 TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_historical_samples (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id),
|
||||||
|
capture_run_id BIGINT REFERENCES rcon_historical_capture_runs(id),
|
||||||
|
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 TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(target_id, captured_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_historical_checkpoints (
|
||||||
|
target_id BIGINT PRIMARY KEY REFERENCES rcon_historical_targets(id),
|
||||||
|
last_successful_capture_at TEXT,
|
||||||
|
last_sample_at TEXT,
|
||||||
|
last_run_id BIGINT REFERENCES rcon_historical_capture_runs(id),
|
||||||
|
last_run_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_historical_competitive_windows (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id),
|
||||||
|
session_key TEXT NOT NULL UNIQUE,
|
||||||
|
source_kind TEXT NOT NULL,
|
||||||
|
map_name TEXT,
|
||||||
|
map_pretty_name TEXT,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
sample_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_players INTEGER NOT NULL DEFAULT 0,
|
||||||
|
peak_players INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_players INTEGER,
|
||||||
|
max_players INTEGER,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
confidence_mode TEXT NOT NULL,
|
||||||
|
capabilities_json TEXT NOT NULL,
|
||||||
|
latest_payload_json TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_admin_log_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
external_server_id TEXT,
|
||||||
|
event_timestamp TEXT,
|
||||||
|
server_time BIGINT,
|
||||||
|
relative_time TEXT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
raw_message TEXT NOT NULL,
|
||||||
|
canonical_message TEXT NOT NULL,
|
||||||
|
parsed_payload_json TEXT NOT NULL,
|
||||||
|
raw_entry_json TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE NULLS NOT DISTINCT(target_key, server_time, canonical_message)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
external_server_id TEXT,
|
||||||
|
player_id TEXT NOT NULL,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
source_server_time BIGINT NOT NULL,
|
||||||
|
event_timestamp TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
sessions INTEGER,
|
||||||
|
matches_played INTEGER,
|
||||||
|
play_time TEXT,
|
||||||
|
total_kills INTEGER,
|
||||||
|
total_deaths INTEGER,
|
||||||
|
teamkills_done INTEGER,
|
||||||
|
teamkills_received INTEGER,
|
||||||
|
kd_ratio DOUBLE PRECISION,
|
||||||
|
favorite_weapons_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
victims_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
nemesis_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
averages_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
sanctions_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
raw_content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(target_key, player_id, source_server_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_materialized_matches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
external_server_id TEXT,
|
||||||
|
match_key TEXT NOT NULL,
|
||||||
|
map_name TEXT,
|
||||||
|
map_pretty_name TEXT,
|
||||||
|
game_mode TEXT,
|
||||||
|
started_server_time BIGINT,
|
||||||
|
ended_server_time BIGINT,
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
allied_score INTEGER,
|
||||||
|
axis_score INTEGER,
|
||||||
|
winner TEXT,
|
||||||
|
confidence_mode TEXT NOT NULL,
|
||||||
|
source_basis TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(target_key, match_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_match_player_stats (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
match_key TEXT NOT NULL,
|
||||||
|
player_id TEXT NOT NULL,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
team TEXT,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deaths INTEGER NOT NULL DEFAULT 0,
|
||||||
|
teamkills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deaths_by_teamkill INTEGER NOT NULL DEFAULT 0,
|
||||||
|
weapons_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
death_by_weapons_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
most_killed_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
death_by_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
first_seen_server_time BIGINT,
|
||||||
|
last_seen_server_time BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(target_key, match_key, player_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rcon_scoreboard_match_candidates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
server_slug TEXT NOT NULL,
|
||||||
|
external_match_id TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
map_name TEXT,
|
||||||
|
map_pretty_name TEXT,
|
||||||
|
allied_score INTEGER,
|
||||||
|
axis_score INTEGER,
|
||||||
|
player_count INTEGER,
|
||||||
|
match_url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(server_slug, external_match_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time
|
||||||
|
ON rcon_historical_samples(target_id, captured_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_historical_windows_target_time
|
||||||
|
ON rcon_historical_competitive_windows(target_id, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_target_time
|
||||||
|
ON rcon_admin_log_events(target_key, server_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type
|
||||||
|
ON rcon_admin_log_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player
|
||||||
|
ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent
|
||||||
|
ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match
|
||||||
|
ON rcon_match_player_stats(target_key, match_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rcon_scoreboard_candidates_server_end
|
||||||
|
ON rcon_scoreboard_match_candidates(server_slug, ended_at DESC, started_at DESC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_postgres_rcon_storage() -> None:
|
||||||
|
"""Create deterministic PostgreSQL schema for migrated RCON domains."""
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(RCON_SCHEMA_SQL)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect_postgres():
|
||||||
|
"""Yield one PostgreSQL connection with dict-shaped rows."""
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
except ImportError as error: # pragma: no cover - dependency is environment-specific
|
||||||
|
raise RuntimeError("psycopg is required when HLL_BACKEND_DATABASE_URL is set.") from error
|
||||||
|
|
||||||
|
database_url = get_database_url()
|
||||||
|
if not database_url:
|
||||||
|
raise RuntimeError("HLL_BACKEND_DATABASE_URL is required for PostgreSQL RCON storage.")
|
||||||
|
with psycopg.connect(database_url, row_factory=dict_row) as connection:
|
||||||
|
yield connection
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresCompatConnection:
|
||||||
|
"""Small DB-API shim for RCON SQL shared with SQLite functions."""
|
||||||
|
|
||||||
|
def __init__(self, connection: Any):
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: Iterable[object] | None = None):
|
||||||
|
normalized = sql.replace("server_time IS ?", "server_time IS NOT DISTINCT FROM ?")
|
||||||
|
normalized = normalized.replace("?", "%s")
|
||||||
|
return self.connection.execute(normalized, tuple(params or ()))
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect_postgres_compat():
|
||||||
|
"""Yield a query shim that accepts the phase-1 SQLite-style placeholders."""
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
yield PostgresCompatConnection(connection)
|
||||||
|
|
||||||
|
|
||||||
|
def start_capture_run(*, mode: str, target_scope: str) -> int:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rcon_historical_capture_runs (mode, status, target_scope, started_at)
|
||||||
|
VALUES (%s, 'running', %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(mode, target_scope, _utc_now_iso()),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_capture_run(
|
||||||
|
run_id: int,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
targets_seen: int,
|
||||||
|
samples_inserted: int,
|
||||||
|
duplicate_samples: int,
|
||||||
|
failed_targets: int,
|
||||||
|
notes: str | None,
|
||||||
|
) -> None:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rcon_historical_capture_runs
|
||||||
|
SET status = %s,
|
||||||
|
completed_at = %s,
|
||||||
|
targets_seen = %s,
|
||||||
|
samples_inserted = %s,
|
||||||
|
duplicate_samples = %s,
|
||||||
|
failed_targets = %s,
|
||||||
|
notes = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
_utc_now_iso(),
|
||||||
|
targets_seen,
|
||||||
|
samples_inserted,
|
||||||
|
duplicate_samples,
|
||||||
|
failed_targets,
|
||||||
|
notes,
|
||||||
|
run_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_sample(
|
||||||
|
*,
|
||||||
|
run_id: int,
|
||||||
|
captured_at: str,
|
||||||
|
target: Mapping[str, object],
|
||||||
|
normalized_payload: Mapping[str, object],
|
||||||
|
raw_payload: Mapping[str, object] | None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
target_id = _upsert_target(connection, target=target)
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT 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 (%s, %s, %s, 'rcon-live-sample', %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT(target_id, captured_at) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
inserted = int(row is not None)
|
||||||
|
_upsert_checkpoint_success(
|
||||||
|
connection,
|
||||||
|
target_id=target_id,
|
||||||
|
run_id=run_id,
|
||||||
|
captured_at=captured_at,
|
||||||
|
)
|
||||||
|
if inserted:
|
||||||
|
_upsert_competitive_window(
|
||||||
|
connection,
|
||||||
|
target_id=target_id,
|
||||||
|
captured_at=captured_at,
|
||||||
|
normalized_payload=normalized_payload,
|
||||||
|
)
|
||||||
|
return {"samples_inserted": inserted, "duplicate_samples": 0 if inserted else 1}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_capture_failure(
|
||||||
|
*,
|
||||||
|
run_id: int,
|
||||||
|
target: Mapping[str, object],
|
||||||
|
error_message: str,
|
||||||
|
) -> None:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() 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 (%s, %s, 'failed', %s, %s)
|
||||||
|
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_target_statuses() -> list[dict[str, object]]:
|
||||||
|
rows = _fetchall(
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**dict(row),
|
||||||
|
"sample_count": int(row["sample_count"] or 0),
|
||||||
|
"last_sample_at": row["latest_sample_at"] or row["last_sample_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_recent_samples(*, target_key: str | None, limit: int) -> list[dict[str, object]]:
|
||||||
|
where_clause, params = _target_where_clause(target_key)
|
||||||
|
rows = _fetchall(
|
||||||
|
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 %s
|
||||||
|
""",
|
||||||
|
[*params, limit],
|
||||||
|
)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_competitive_windows(*, target_key: str | None, limit: int) -> list[dict[str, object]]:
|
||||||
|
where_clause, params = _target_where_clause(target_key)
|
||||||
|
rows = _fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT targets.target_key, targets.external_server_id, targets.display_name,
|
||||||
|
targets.region, windows.session_key, windows.map_name,
|
||||||
|
windows.map_pretty_name, windows.first_seen_at, windows.last_seen_at,
|
||||||
|
windows.sample_count, windows.total_players, windows.peak_players,
|
||||||
|
windows.last_players, windows.max_players, windows.status,
|
||||||
|
windows.confidence_mode, windows.capabilities_json,
|
||||||
|
windows.latest_payload_json
|
||||||
|
FROM rcon_historical_competitive_windows AS windows
|
||||||
|
INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY windows.last_seen_at DESC, targets.display_name ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
[*params, limit],
|
||||||
|
)
|
||||||
|
return [_serialize_window(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def count_samples_since(since: str | None) -> int:
|
||||||
|
if not since:
|
||||||
|
return 0
|
||||||
|
row = _fetchone(
|
||||||
|
"SELECT COUNT(*) AS sample_count FROM rcon_historical_samples WHERE captured_at > %s",
|
||||||
|
(since,),
|
||||||
|
)
|
||||||
|
return int(row["sample_count"] or 0) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def list_competitive_summary_rows(*, target_key: str | None) -> list[dict[str, object]]:
|
||||||
|
where_clause, params = _target_where_clause(target_key)
|
||||||
|
rows = _fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT targets.target_key, targets.external_server_id, targets.display_name,
|
||||||
|
targets.region, checkpoints.last_successful_capture_at,
|
||||||
|
checkpoints.last_run_status, checkpoints.last_error,
|
||||||
|
checkpoints.last_error_at, COUNT(windows.id) AS window_count,
|
||||||
|
COALESCE(SUM(windows.sample_count), 0) AS sample_count,
|
||||||
|
MIN(windows.first_seen_at) AS first_seen_at,
|
||||||
|
MAX(windows.last_seen_at) AS last_seen_at,
|
||||||
|
COALESCE(MAX(windows.peak_players), 0) AS peak_players
|
||||||
|
FROM rcon_historical_targets AS targets
|
||||||
|
LEFT JOIN rcon_historical_checkpoints AS checkpoints ON checkpoints.target_id = targets.id
|
||||||
|
LEFT JOIN rcon_historical_competitive_windows AS windows ON windows.target_id = targets.id
|
||||||
|
{where_clause}
|
||||||
|
GROUP BY targets.id, checkpoints.target_id
|
||||||
|
ORDER BY targets.display_name ASC, targets.target_key ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**dict(row),
|
||||||
|
"window_count": int(row["window_count"] or 0),
|
||||||
|
"sample_count": int(row["sample_count"] or 0),
|
||||||
|
"peak_players": int(row["peak_players"] or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_competitive_window(
|
||||||
|
*,
|
||||||
|
server_key: str,
|
||||||
|
ended_at: str | None,
|
||||||
|
map_name: str | None,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if not ended_at:
|
||||||
|
return None
|
||||||
|
aliases = _expand_target_key_aliases(server_key)
|
||||||
|
candidates = _fetchall(
|
||||||
|
"""
|
||||||
|
SELECT windows.session_key, windows.first_seen_at, windows.last_seen_at,
|
||||||
|
windows.map_name, windows.map_pretty_name, windows.sample_count,
|
||||||
|
windows.total_players, windows.peak_players, windows.confidence_mode,
|
||||||
|
windows.capabilities_json, windows.latest_payload_json
|
||||||
|
FROM rcon_historical_competitive_windows AS windows
|
||||||
|
INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id
|
||||||
|
WHERE targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s)
|
||||||
|
ORDER BY windows.last_seen_at DESC
|
||||||
|
LIMIT 12
|
||||||
|
""",
|
||||||
|
(aliases, aliases),
|
||||||
|
)
|
||||||
|
ended_point = _parse_timestamp(ended_at)
|
||||||
|
if ended_point is None:
|
||||||
|
return None
|
||||||
|
normalized_map_name = normalize_map_name(map_name)
|
||||||
|
best_row: dict[str, object] | None = None
|
||||||
|
best_distance: float | None = None
|
||||||
|
for row in candidates:
|
||||||
|
row_map = normalize_map_name(row["map_pretty_name"] or row["map_name"])
|
||||||
|
if normalized_map_name and row_map and normalized_map_name != row_map:
|
||||||
|
continue
|
||||||
|
row_last = _parse_timestamp(row["last_seen_at"])
|
||||||
|
if row_last is None:
|
||||||
|
continue
|
||||||
|
distance = abs((row_last - ended_point).total_seconds())
|
||||||
|
if best_distance is None or distance < best_distance:
|
||||||
|
best_row = dict(row)
|
||||||
|
best_distance = distance
|
||||||
|
if best_row is None or best_distance is None or best_distance > 21600:
|
||||||
|
return None
|
||||||
|
sample_count = int(best_row["sample_count"] or 0)
|
||||||
|
return {
|
||||||
|
"session_key": best_row["session_key"],
|
||||||
|
"first_seen_at": best_row["first_seen_at"],
|
||||||
|
"last_seen_at": best_row["last_seen_at"],
|
||||||
|
"duration_seconds": _calculate_duration_seconds(
|
||||||
|
best_row["first_seen_at"],
|
||||||
|
best_row["last_seen_at"],
|
||||||
|
),
|
||||||
|
"map_name": best_row["map_name"],
|
||||||
|
"map_pretty_name": best_row["map_pretty_name"] or best_row["map_name"],
|
||||||
|
"sample_count": sample_count,
|
||||||
|
"average_players": (
|
||||||
|
round((int(best_row["total_players"] or 0) / sample_count), 2)
|
||||||
|
if sample_count > 0
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"peak_players": int(best_row["peak_players"] or 0),
|
||||||
|
"confidence_mode": best_row["confidence_mode"],
|
||||||
|
"capabilities": _deserialize_json_object(best_row["capabilities_json"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_competitive_window_by_session(
|
||||||
|
*,
|
||||||
|
server_key: str,
|
||||||
|
session_key: str,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
normalized_session_key = str(session_key or "").strip()
|
||||||
|
if not normalized_session_key:
|
||||||
|
return None
|
||||||
|
aliases = _expand_target_key_aliases(server_key)
|
||||||
|
row = _fetchone(
|
||||||
|
"""
|
||||||
|
SELECT targets.target_key, targets.external_server_id, targets.display_name,
|
||||||
|
targets.region, windows.session_key, windows.map_name,
|
||||||
|
windows.map_pretty_name, windows.first_seen_at, windows.last_seen_at,
|
||||||
|
windows.sample_count, windows.total_players, windows.peak_players,
|
||||||
|
windows.confidence_mode, windows.capabilities_json,
|
||||||
|
windows.latest_payload_json
|
||||||
|
FROM rcon_historical_competitive_windows AS windows
|
||||||
|
INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id
|
||||||
|
WHERE windows.session_key = %s
|
||||||
|
AND (targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s))
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(normalized_session_key, aliases, aliases),
|
||||||
|
)
|
||||||
|
return _serialize_window(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_scoreboard_candidates(*, server_slug: str, limit: int) -> list[dict[str, object]]:
|
||||||
|
rows = _fetchall(
|
||||||
|
"""
|
||||||
|
SELECT external_match_id, started_at, ended_at, map_name, map_pretty_name,
|
||||||
|
allied_score, axis_score, player_count, match_url
|
||||||
|
FROM rcon_scoreboard_match_candidates
|
||||||
|
WHERE server_slug = %s
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(server_slug, limit),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_scoreboard_candidates(
|
||||||
|
*,
|
||||||
|
server_slug: str,
|
||||||
|
candidates: Iterable[Mapping[str, object]],
|
||||||
|
) -> int:
|
||||||
|
"""Cache trusted scoreboard correlation candidates in PostgreSQL."""
|
||||||
|
rows = [candidate for candidate in candidates if candidate.get("match_url")]
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
inserted_or_updated = 0
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
for candidate in rows:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rcon_scoreboard_match_candidates (
|
||||||
|
server_slug, external_match_id, started_at, ended_at, map_name,
|
||||||
|
map_pretty_name, allied_score, axis_score, player_count, match_url
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT(server_slug, external_match_id) DO UPDATE SET
|
||||||
|
started_at = EXCLUDED.started_at,
|
||||||
|
ended_at = EXCLUDED.ended_at,
|
||||||
|
map_name = EXCLUDED.map_name,
|
||||||
|
map_pretty_name = EXCLUDED.map_pretty_name,
|
||||||
|
allied_score = EXCLUDED.allied_score,
|
||||||
|
axis_score = EXCLUDED.axis_score,
|
||||||
|
player_count = EXCLUDED.player_count,
|
||||||
|
match_url = EXCLUDED.match_url,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
server_slug,
|
||||||
|
str(candidate.get("external_match_id") or ""),
|
||||||
|
candidate.get("started_at"),
|
||||||
|
candidate.get("ended_at"),
|
||||||
|
candidate.get("map_name"),
|
||||||
|
candidate.get("map_pretty_name"),
|
||||||
|
candidate.get("allied_score"),
|
||||||
|
candidate.get("axis_score"),
|
||||||
|
candidate.get("player_count"),
|
||||||
|
candidate["match_url"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted_or_updated += 1
|
||||||
|
return inserted_or_updated
|
||||||
|
|
||||||
|
|
||||||
|
def count_migrated_tables() -> dict[str, int]:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
table_names = (
|
||||||
|
"rcon_admin_log_events",
|
||||||
|
"rcon_player_profile_snapshots",
|
||||||
|
"rcon_materialized_matches",
|
||||||
|
"rcon_match_player_stats",
|
||||||
|
"rcon_historical_targets",
|
||||||
|
"rcon_historical_samples",
|
||||||
|
"rcon_historical_competitive_windows",
|
||||||
|
"rcon_scoreboard_match_candidates",
|
||||||
|
)
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
return {
|
||||||
|
table_name: int(
|
||||||
|
connection.execute(f"SELECT COUNT(*) AS count FROM {table_name}").fetchone()[
|
||||||
|
"count"
|
||||||
|
]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
for table_name in table_names
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetchall(sql: str, params: Iterable[object] = ()) -> list[dict[str, object]]:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
return [dict(row) for row in connection.execute(sql, tuple(params)).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetchone(sql: str, params: Iterable[object] = ()) -> dict[str, object] | None:
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
with connect_postgres() as connection:
|
||||||
|
row = connection.execute(sql, tuple(params)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_target(connection: Any, *, target: Mapping[str, object]) -> int:
|
||||||
|
target_key = str(target.get("target_key") or "").strip()
|
||||||
|
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 target_key or not host or port <= 0:
|
||||||
|
raise ValueError("Prospective RCON targets require target_key, host and port.")
|
||||||
|
row = 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 (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
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
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_checkpoint_success(
|
||||||
|
connection: Any,
|
||||||
|
*,
|
||||||
|
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 (%s, %s, %s, %s, '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 _upsert_competitive_window(
|
||||||
|
connection: Any,
|
||||||
|
*,
|
||||||
|
target_id: int,
|
||||||
|
captured_at: str,
|
||||||
|
normalized_payload: Mapping[str, object],
|
||||||
|
) -> None:
|
||||||
|
current_map_raw = str(normalized_payload.get("current_map") or "").strip()
|
||||||
|
if not current_map_raw:
|
||||||
|
return
|
||||||
|
map_pretty_name = normalize_map_name(current_map_raw) or current_map_raw
|
||||||
|
players = int(normalized_payload.get("players") or 0)
|
||||||
|
max_players = normalized_payload.get("max_players")
|
||||||
|
status = str(normalized_payload.get("status") or "unknown")
|
||||||
|
latest_window = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM rcon_historical_competitive_windows
|
||||||
|
WHERE target_id = %s
|
||||||
|
ORDER BY last_seen_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(target_id,),
|
||||||
|
).fetchone()
|
||||||
|
if latest_window and _should_extend_competitive_window(
|
||||||
|
latest_window=dict(latest_window),
|
||||||
|
captured_at=captured_at,
|
||||||
|
current_map=current_map_raw,
|
||||||
|
):
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rcon_historical_competitive_windows
|
||||||
|
SET map_name = %s,
|
||||||
|
map_pretty_name = %s,
|
||||||
|
last_seen_at = %s,
|
||||||
|
sample_count = sample_count + 1,
|
||||||
|
total_players = total_players + %s,
|
||||||
|
peak_players = GREATEST(peak_players, %s),
|
||||||
|
last_players = %s,
|
||||||
|
max_players = %s,
|
||||||
|
status = %s,
|
||||||
|
confidence_mode = %s,
|
||||||
|
capabilities_json = %s,
|
||||||
|
latest_payload_json = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
current_map_raw,
|
||||||
|
map_pretty_name,
|
||||||
|
captured_at,
|
||||||
|
players,
|
||||||
|
players,
|
||||||
|
players,
|
||||||
|
max_players,
|
||||||
|
status,
|
||||||
|
COMPETITIVE_MODE_APPROXIMATE,
|
||||||
|
json.dumps(_build_competitive_capabilities(), separators=(",", ":")),
|
||||||
|
json.dumps(dict(normalized_payload), separators=(",", ":")),
|
||||||
|
latest_window["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rcon_historical_competitive_windows (
|
||||||
|
target_id, session_key, source_kind, map_name, map_pretty_name,
|
||||||
|
first_seen_at, last_seen_at, sample_count, total_players,
|
||||||
|
peak_players, last_players, max_players, status, confidence_mode,
|
||||||
|
capabilities_json, latest_payload_json
|
||||||
|
) VALUES (%s, %s, 'rcon-historical-samples', %s, %s, %s, %s, 1,
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
target_id,
|
||||||
|
f"{target_id}:{captured_at}",
|
||||||
|
current_map_raw,
|
||||||
|
map_pretty_name,
|
||||||
|
captured_at,
|
||||||
|
captured_at,
|
||||||
|
players,
|
||||||
|
players,
|
||||||
|
players,
|
||||||
|
max_players,
|
||||||
|
status,
|
||||||
|
COMPETITIVE_MODE_APPROXIMATE,
|
||||||
|
json.dumps(_build_competitive_capabilities(), separators=(",", ":")),
|
||||||
|
json.dumps(dict(normalized_payload), separators=(",", ":")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_where_clause(target_key: str | None) -> tuple[str, list[object]]:
|
||||||
|
if not target_key:
|
||||||
|
return "", []
|
||||||
|
aliases = _expand_target_key_aliases(target_key)
|
||||||
|
return "WHERE targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s)", [
|
||||||
|
aliases,
|
||||||
|
aliases,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_target_key_aliases(target_key: str) -> list[str]:
|
||||||
|
normalized_target_key = str(target_key or "").strip()
|
||||||
|
aliases = {normalized_target_key}
|
||||||
|
try:
|
||||||
|
configured_targets = load_rcon_targets()
|
||||||
|
except Exception:
|
||||||
|
configured_targets = ()
|
||||||
|
for target in configured_targets:
|
||||||
|
external_server_id = str(target.external_server_id or "").strip()
|
||||||
|
legacy_target_key = f"rcon:{target.host}:{target.port}"
|
||||||
|
if external_server_id and external_server_id == normalized_target_key:
|
||||||
|
aliases.update((legacy_target_key, external_server_id))
|
||||||
|
elif legacy_target_key == normalized_target_key:
|
||||||
|
aliases.add(legacy_target_key)
|
||||||
|
if external_server_id:
|
||||||
|
aliases.add(external_server_id)
|
||||||
|
return sorted(alias for alias in aliases if alias)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_window(row: Mapping[str, object]) -> dict[str, object]:
|
||||||
|
sample_count = int(row["sample_count"] or 0)
|
||||||
|
return {
|
||||||
|
"target_key": row["target_key"],
|
||||||
|
"external_server_id": row["external_server_id"],
|
||||||
|
"display_name": row["display_name"],
|
||||||
|
"region": row["region"],
|
||||||
|
"session_key": row["session_key"],
|
||||||
|
"map_name": row["map_name"],
|
||||||
|
"map_pretty_name": row["map_pretty_name"] or row["map_name"],
|
||||||
|
"first_seen_at": row["first_seen_at"],
|
||||||
|
"last_seen_at": row["last_seen_at"],
|
||||||
|
"duration_seconds": _calculate_duration_seconds(
|
||||||
|
row["first_seen_at"],
|
||||||
|
row["last_seen_at"],
|
||||||
|
),
|
||||||
|
"sample_count": sample_count,
|
||||||
|
"average_players": (
|
||||||
|
round((int(row["total_players"] or 0) / sample_count), 2)
|
||||||
|
if sample_count > 0
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"peak_players": int(row["peak_players"] or 0),
|
||||||
|
"last_players": row.get("last_players"),
|
||||||
|
"max_players": row.get("max_players"),
|
||||||
|
"status": row.get("status"),
|
||||||
|
"confidence_mode": row["confidence_mode"],
|
||||||
|
"capabilities": _deserialize_json_object(row["capabilities_json"]),
|
||||||
|
"latest_payload": _deserialize_json_object(row["latest_payload_json"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _should_extend_competitive_window(
|
||||||
|
*,
|
||||||
|
latest_window: Mapping[str, object],
|
||||||
|
captured_at: str,
|
||||||
|
current_map: str,
|
||||||
|
) -> bool:
|
||||||
|
if normalize_map_name(latest_window.get("map_name")) != normalize_map_name(current_map):
|
||||||
|
return False
|
||||||
|
latest_seen = _parse_timestamp(latest_window.get("last_seen_at"))
|
||||||
|
captured_point = _parse_timestamp(captured_at)
|
||||||
|
if latest_seen is None or captured_point is None:
|
||||||
|
return False
|
||||||
|
return (captured_point - latest_seen).total_seconds() <= COMPETITIVE_WINDOW_GAP_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def _build_competitive_capabilities() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"recent_matches": COMPETITIVE_MODE_APPROXIMATE,
|
||||||
|
"server_summary": COMPETITIVE_MODE_EXACT,
|
||||||
|
"competitive_quality": COMPETITIVE_MODE_PARTIAL,
|
||||||
|
"result": "session-score",
|
||||||
|
"gamestate": "session",
|
||||||
|
"player_stats": "unavailable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_json_object(raw_value: object) -> dict[str, object]:
|
||||||
|
if isinstance(raw_value, str) and raw_value.strip():
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None:
|
||||||
|
first_point = _parse_timestamp(first_seen_at)
|
||||||
|
last_point = _parse_timestamp(last_seen_at)
|
||||||
|
if first_point is None or last_point is None:
|
||||||
|
return None
|
||||||
|
return max(0, int((last_point - first_point).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(raw_value: object) -> datetime | None:
|
||||||
|
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromisoformat(raw_value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||||
|
return timestamp.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
@@ -10,7 +10,7 @@ from collections.abc import Iterable
|
|||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path
|
from .config import get_storage_path, use_postgres_rcon_storage
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
from .rcon_admin_log_storage import initialize_rcon_admin_log_storage
|
from .rcon_admin_log_storage import initialize_rcon_admin_log_storage
|
||||||
from .rcon_historical_storage import list_rcon_historical_competitive_windows
|
from .rcon_historical_storage import list_rcon_historical_competitive_windows
|
||||||
@@ -23,6 +23,12 @@ SESSION_RESULT_SOURCE = "rcon-session"
|
|||||||
|
|
||||||
def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path:
|
||||||
"""Create SQLite structures used by the materialized RCON match pipeline."""
|
"""Create SQLite structures used by the materialized RCON match pipeline."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import initialize_postgres_rcon_storage
|
||||||
|
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
return get_storage_path()
|
||||||
|
|
||||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||||
with closing(connect_sqlite_writer(resolved_path)) as connection:
|
with closing(connect_sqlite_writer(resolved_path)) as connection:
|
||||||
with connection:
|
with connection:
|
||||||
@@ -85,6 +91,46 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
|
|||||||
def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, object]:
|
def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, object]:
|
||||||
"""Materialize matches and player stats from stored AdminLog events."""
|
"""Materialize matches and player stats from stored AdminLog events."""
|
||||||
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
with connect_postgres_compat() as connection:
|
||||||
|
payload = _materialize_rcon_admin_log_with_connection(
|
||||||
|
connection,
|
||||||
|
session_window_db_path=None,
|
||||||
|
caught_errors=(Exception,),
|
||||||
|
)
|
||||||
|
freshness = summarize_rcon_materialization_status()
|
||||||
|
return {
|
||||||
|
**payload,
|
||||||
|
"latest_materialized_matches": freshness["latest_materialized_matches"],
|
||||||
|
"latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"],
|
||||||
|
"match_end_status": freshness["match_end_status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with closing(connect_sqlite_writer(resolved_path)) as connection:
|
||||||
|
with connection:
|
||||||
|
payload = _materialize_rcon_admin_log_with_connection(
|
||||||
|
connection,
|
||||||
|
session_window_db_path=resolved_path,
|
||||||
|
caught_errors=(sqlite3.Error,),
|
||||||
|
)
|
||||||
|
|
||||||
|
freshness = summarize_rcon_materialization_status(db_path=resolved_path)
|
||||||
|
return {
|
||||||
|
**payload,
|
||||||
|
"latest_materialized_matches": freshness["latest_materialized_matches"],
|
||||||
|
"latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"],
|
||||||
|
"match_end_status": freshness["match_end_status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_rcon_admin_log_with_connection(
|
||||||
|
connection: object,
|
||||||
|
*,
|
||||||
|
session_window_db_path: Path | None,
|
||||||
|
caught_errors: tuple[type[BaseException], ...],
|
||||||
|
) -> dict[str, object]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
matches_seen = 0
|
matches_seen = 0
|
||||||
matches_materialized = 0
|
matches_materialized = 0
|
||||||
@@ -93,8 +139,6 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje
|
|||||||
player_stats_materialized = 0
|
player_stats_materialized = 0
|
||||||
player_stats_updated = 0
|
player_stats_updated = 0
|
||||||
|
|
||||||
with closing(connect_sqlite_writer(resolved_path)) as connection:
|
|
||||||
with connection:
|
|
||||||
try:
|
try:
|
||||||
match_rows = _derive_admin_log_matches(connection)
|
match_rows = _derive_admin_log_matches(connection)
|
||||||
matches_seen = len(match_rows)
|
matches_seen = len(match_rows)
|
||||||
@@ -102,7 +146,10 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje
|
|||||||
outcome = _upsert_match(connection, row)
|
outcome = _upsert_match(connection, row)
|
||||||
matches_materialized += int(outcome == "inserted")
|
matches_materialized += int(outcome == "inserted")
|
||||||
matches_updated += int(outcome == "updated")
|
matches_updated += int(outcome == "updated")
|
||||||
session_rows = _derive_session_fallback_matches(connection, db_path=resolved_path)
|
session_rows = _derive_session_fallback_matches(
|
||||||
|
connection,
|
||||||
|
db_path=session_window_db_path,
|
||||||
|
)
|
||||||
matches_seen += len(session_rows)
|
matches_seen += len(session_rows)
|
||||||
for row in session_rows:
|
for row in session_rows:
|
||||||
outcome = _upsert_match(connection, row)
|
outcome = _upsert_match(connection, row)
|
||||||
@@ -123,9 +170,8 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje
|
|||||||
for stat in stats:
|
for stat in stats:
|
||||||
_insert_player_stat(connection, stat)
|
_insert_player_stat(connection, stat)
|
||||||
player_stats_materialized += 1
|
player_stats_materialized += 1
|
||||||
except sqlite3.Error as error:
|
except caught_errors as error:
|
||||||
errors.append(str(error))
|
errors.append(str(error))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"matches_seen": matches_seen,
|
"matches_seen": matches_seen,
|
||||||
"matches_materialized": matches_materialized,
|
"matches_materialized": matches_materialized,
|
||||||
@@ -156,7 +202,13 @@ def list_materialized_rcon_matches(
|
|||||||
params.append(MATCH_RESULT_SOURCE)
|
params.append(MATCH_RESULT_SOURCE)
|
||||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
with closing(connect_sqlite_readonly(resolved_path)) as connection:
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
connection_scope = connect_postgres_compat()
|
||||||
|
else:
|
||||||
|
connection_scope = closing(connect_sqlite_readonly(resolved_path))
|
||||||
|
with connection_scope as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
@@ -193,7 +245,13 @@ def get_materialized_rcon_match_detail(
|
|||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
"""Return one materialized match with player stats."""
|
"""Return one materialized match with player stats."""
|
||||||
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
||||||
with closing(connect_sqlite_readonly(resolved_path)) as connection:
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
connection_scope = connect_postgres_compat()
|
||||||
|
else:
|
||||||
|
connection_scope = closing(connect_sqlite_readonly(resolved_path))
|
||||||
|
with connection_scope as connection:
|
||||||
match = connection.execute(
|
match = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -245,7 +303,13 @@ def get_materialized_rcon_match_detail(
|
|||||||
def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dict[str, object]:
|
def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dict[str, object]:
|
||||||
"""Return a small diagnostic summary for stored RCON materialization state."""
|
"""Return a small diagnostic summary for stored RCON materialization state."""
|
||||||
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
||||||
with closing(connect_sqlite_readonly(resolved_path)) as connection:
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
connection_scope = connect_postgres_compat()
|
||||||
|
else:
|
||||||
|
connection_scope = closing(connect_sqlite_readonly(resolved_path))
|
||||||
|
with connection_scope as connection:
|
||||||
match_count = connection.execute(
|
match_count = connection.execute(
|
||||||
"SELECT COUNT(*) AS count FROM rcon_materialized_matches"
|
"SELECT COUNT(*) AS count FROM rcon_materialized_matches"
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
@@ -256,7 +320,7 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM rcon_match_player_stats
|
FROM rcon_match_player_stats
|
||||||
GROUP BY target_key, match_key
|
GROUP BY target_key, match_key
|
||||||
)
|
) AS stats_matches
|
||||||
"""
|
"""
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
ranges = connection.execute(
|
ranges = connection.execute(
|
||||||
@@ -275,11 +339,61 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic
|
|||||||
ORDER BY target_key ASC, event_count DESC
|
ORDER BY target_key ASC, event_count DESC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
latest_matches = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
target_key,
|
||||||
|
external_server_id,
|
||||||
|
match_key,
|
||||||
|
map_pretty_name,
|
||||||
|
COALESCE(ended_at, started_at) AS closed_at,
|
||||||
|
ended_at,
|
||||||
|
ended_server_time,
|
||||||
|
source_basis,
|
||||||
|
updated_at
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY target_key
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) DESC,
|
||||||
|
COALESCE(ended_server_time, started_server_time) DESC,
|
||||||
|
updated_at DESC
|
||||||
|
) AS row_number
|
||||||
|
FROM rcon_materialized_matches
|
||||||
|
WHERE source_basis = ?
|
||||||
|
) AS ranked_matches
|
||||||
|
WHERE row_number = 1
|
||||||
|
ORDER BY target_key ASC
|
||||||
|
""",
|
||||||
|
(MATCH_RESULT_SOURCE,),
|
||||||
|
).fetchall()
|
||||||
|
latest_match_end_events = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
target_key,
|
||||||
|
external_server_id,
|
||||||
|
MAX(event_timestamp) AS latest_event_timestamp,
|
||||||
|
MAX(server_time) AS latest_server_time,
|
||||||
|
COUNT(*) AS match_end_events
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
WHERE event_type = 'match_end'
|
||||||
|
GROUP BY target_key, external_server_id
|
||||||
|
ORDER BY target_key ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
"materialized_matches": int(match_count or 0),
|
"materialized_matches": int(match_count or 0),
|
||||||
"matches_with_player_stats": int(stats_match_count or 0),
|
"matches_with_player_stats": int(stats_match_count or 0),
|
||||||
"server_time_ranges": [dict(row) for row in ranges],
|
"server_time_ranges": [dict(row) for row in ranges],
|
||||||
"event_counts": [dict(row) for row in event_counts],
|
"event_counts": [dict(row) for row in event_counts],
|
||||||
|
"latest_materialized_matches": [dict(row) for row in latest_matches],
|
||||||
|
"latest_admin_log_match_end_events": [dict(row) for row in latest_match_end_events],
|
||||||
|
"match_end_status": (
|
||||||
|
"admin-log-match-end-events-available"
|
||||||
|
if latest_match_end_events
|
||||||
|
else "no-admin-log-match-end-events-stored"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -312,7 +426,7 @@ def _derive_admin_log_matches(connection: sqlite3.Connection) -> list[dict[str,
|
|||||||
def _derive_session_fallback_matches(
|
def _derive_session_fallback_matches(
|
||||||
connection: sqlite3.Connection,
|
connection: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
db_path: Path,
|
db_path: Path | None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
rows: list[dict[str, object]] = []
|
rows: list[dict[str, object]] = []
|
||||||
existing = {
|
existing = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import sqlite3
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path
|
from .config import get_storage_path, use_postgres_rcon_storage
|
||||||
from .rcon_admin_log_parser import parse_rcon_admin_log_entry
|
from .rcon_admin_log_parser import parse_rcon_admin_log_entry
|
||||||
from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
|
from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
|
||||||
from .rcon_historical_storage import initialize_rcon_historical_storage
|
from .rcon_historical_storage import initialize_rcon_historical_storage
|
||||||
@@ -17,6 +17,12 @@ from .sqlite_utils import connect_sqlite_writer
|
|||||||
|
|
||||||
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
||||||
"""Create SQLite structures for parsed RCON AdminLog events."""
|
"""Create SQLite structures for parsed RCON AdminLog events."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import initialize_postgres_rcon_storage
|
||||||
|
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
return get_storage_path()
|
||||||
|
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
||||||
|
|
||||||
with connect_sqlite_writer(resolved_path) as connection:
|
with connect_sqlite_writer(resolved_path) as connection:
|
||||||
@@ -90,6 +96,9 @@ def persist_rcon_admin_log_entries(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Persist raw and parsed AdminLog entries idempotently."""
|
"""Persist raw and parsed AdminLog entries idempotently."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
return _persist_rcon_admin_log_entries_postgres(target=target, entries=entries)
|
||||||
|
|
||||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||||
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
|
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
|
||||||
if not target_key:
|
if not target_key:
|
||||||
@@ -278,6 +287,25 @@ def _ensure_canonical_message_column(connection: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dict[str, object]]:
|
def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dict[str, object]]:
|
||||||
"""Return event counts grouped by target and event type."""
|
"""Return event counts grouped by target and event type."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
with connect_postgres_compat() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
target_key,
|
||||||
|
event_type,
|
||||||
|
COUNT(*) AS event_count,
|
||||||
|
MIN(server_time) AS first_server_time,
|
||||||
|
MAX(server_time) AS last_server_time
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
GROUP BY target_key, event_type
|
||||||
|
ORDER BY target_key ASC, event_count DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
resolved_path = db_path or get_storage_path()
|
resolved_path = db_path or get_storage_path()
|
||||||
initialize_rcon_admin_log_storage(db_path=resolved_path)
|
initialize_rcon_admin_log_storage(db_path=resolved_path)
|
||||||
|
|
||||||
@@ -310,6 +338,29 @@ def get_latest_rcon_player_profile_summaries(
|
|||||||
requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()]
|
requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()]
|
||||||
if not target_key or not requested_ids:
|
if not target_key or not requested_ids:
|
||||||
return {}
|
return {}
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in requested_ids)
|
||||||
|
with connect_postgres_compat() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT snapshots.*
|
||||||
|
FROM rcon_player_profile_snapshots AS snapshots
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT player_id, MAX(source_server_time) AS latest_source_server_time
|
||||||
|
FROM rcon_player_profile_snapshots
|
||||||
|
WHERE target_key = ?
|
||||||
|
AND player_id IN ({placeholders})
|
||||||
|
GROUP BY player_id
|
||||||
|
) AS latest
|
||||||
|
ON latest.player_id = snapshots.player_id
|
||||||
|
AND latest.latest_source_server_time = snapshots.source_server_time
|
||||||
|
WHERE snapshots.target_key = ?
|
||||||
|
""",
|
||||||
|
[target_key, *requested_ids, target_key],
|
||||||
|
).fetchall()
|
||||||
|
return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows}
|
||||||
|
|
||||||
resolved_path = db_path or get_storage_path()
|
resolved_path = db_path or get_storage_path()
|
||||||
initialize_rcon_admin_log_storage(db_path=resolved_path)
|
initialize_rcon_admin_log_storage(db_path=resolved_path)
|
||||||
@@ -369,3 +420,68 @@ def _json_mapping(raw_value: object) -> dict[str, object]:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return {}
|
return {}
|
||||||
return parsed if isinstance(parsed, dict) else {}
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_rcon_admin_log_entries_postgres(
|
||||||
|
*,
|
||||||
|
target: Mapping[str, object],
|
||||||
|
entries: list[dict[str, object]],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
|
||||||
|
if not target_key:
|
||||||
|
raise ValueError("target must include target_key or external_server_id")
|
||||||
|
|
||||||
|
external_server_id = target.get("external_server_id")
|
||||||
|
inserted = 0
|
||||||
|
duplicates = 0
|
||||||
|
with connect_postgres_compat() as connection:
|
||||||
|
for entry in entries:
|
||||||
|
parsed = parse_rcon_admin_log_entry(entry)
|
||||||
|
raw_message = str(parsed.get("raw_message") or "")
|
||||||
|
canonical_message = _canonicalize_admin_log_message(raw_message)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rcon_admin_log_events (
|
||||||
|
target_key,
|
||||||
|
external_server_id,
|
||||||
|
event_timestamp,
|
||||||
|
server_time,
|
||||||
|
relative_time,
|
||||||
|
event_type,
|
||||||
|
raw_message,
|
||||||
|
canonical_message,
|
||||||
|
parsed_payload_json,
|
||||||
|
raw_entry_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
target_key,
|
||||||
|
external_server_id,
|
||||||
|
parsed.get("timestamp"),
|
||||||
|
parsed.get("server_time"),
|
||||||
|
parsed.get("relative_time"),
|
||||||
|
parsed.get("event_type") or "unknown",
|
||||||
|
raw_message,
|
||||||
|
canonical_message,
|
||||||
|
json.dumps(parsed, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(entry, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if int(cursor.rowcount or 0):
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
duplicates += 1
|
||||||
|
_persist_profile_snapshot_if_present(
|
||||||
|
connection,
|
||||||
|
target_key=target_key,
|
||||||
|
external_server_id=external_server_id,
|
||||||
|
parsed=parsed,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"events_seen": len(entries),
|
||||||
|
"events_inserted": inserted,
|
||||||
|
"duplicate_events": duplicates,
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from collections.abc import Mapping
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path
|
from .config import get_storage_path, use_postgres_rcon_storage
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
from .rcon_client import load_rcon_targets
|
from .rcon_client import load_rcon_targets
|
||||||
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
|
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
|
||||||
@@ -22,6 +22,12 @@ COMPETITIVE_MODE_EXACT = "exact"
|
|||||||
|
|
||||||
def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path:
|
||||||
"""Create the SQLite structures used by prospective RCON capture."""
|
"""Create the SQLite structures used by prospective RCON capture."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import initialize_postgres_rcon_storage
|
||||||
|
|
||||||
|
initialize_postgres_rcon_storage()
|
||||||
|
return get_storage_path()
|
||||||
|
|
||||||
resolved_path = db_path or get_storage_path()
|
resolved_path = db_path or get_storage_path()
|
||||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -131,6 +137,11 @@ def start_rcon_historical_capture_run(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Create one run row for prospective RCON capture."""
|
"""Create one run row for prospective RCON capture."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import start_capture_run
|
||||||
|
|
||||||
|
return start_capture_run(mode=mode, target_scope=target_scope)
|
||||||
|
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
||||||
with _connect(resolved_path) as connection:
|
with _connect(resolved_path) as connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
@@ -159,6 +170,20 @@ def finalize_rcon_historical_capture_run(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Finalize one prospective RCON capture run."""
|
"""Finalize one prospective RCON capture run."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import finalize_capture_run
|
||||||
|
|
||||||
|
finalize_capture_run(
|
||||||
|
run_id,
|
||||||
|
status=status,
|
||||||
|
targets_seen=targets_seen,
|
||||||
|
samples_inserted=samples_inserted,
|
||||||
|
duplicate_samples=duplicate_samples,
|
||||||
|
failed_targets=failed_targets,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
||||||
with _connect(resolved_path) as connection:
|
with _connect(resolved_path) as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
@@ -196,6 +221,17 @@ def persist_rcon_historical_sample(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Persist one prospective RCON sample and refresh its checkpoint."""
|
"""Persist one prospective RCON sample and refresh its checkpoint."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import persist_sample
|
||||||
|
|
||||||
|
return persist_sample(
|
||||||
|
run_id=run_id,
|
||||||
|
captured_at=captured_at,
|
||||||
|
target=target,
|
||||||
|
normalized_payload=normalized_payload,
|
||||||
|
raw_payload=raw_payload,
|
||||||
|
)
|
||||||
|
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
||||||
with _connect(resolved_path) as connection:
|
with _connect(resolved_path) as connection:
|
||||||
target_id = _upsert_target(connection, target=target)
|
target_id = _upsert_target(connection, target=target)
|
||||||
@@ -254,6 +290,12 @@ def mark_rcon_historical_capture_failure(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist failure metadata for one target inside a capture run."""
|
"""Persist failure metadata for one target inside a capture run."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import mark_capture_failure
|
||||||
|
|
||||||
|
mark_capture_failure(run_id=run_id, target=target, error_message=error_message)
|
||||||
|
return
|
||||||
|
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
||||||
with _connect(resolved_path) as connection:
|
with _connect(resolved_path) as connection:
|
||||||
target_id = _upsert_target(connection, target=target)
|
target_id = _upsert_target(connection, target=target)
|
||||||
@@ -282,6 +324,11 @@ def list_rcon_historical_target_statuses(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return per-target coverage and freshness for prospective RCON capture."""
|
"""Return per-target coverage and freshness for prospective RCON capture."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import list_target_statuses
|
||||||
|
|
||||||
|
return list_target_statuses()
|
||||||
|
|
||||||
resolved_path = _resolve_db_path(db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
try:
|
try:
|
||||||
with _connect_readonly(resolved_path) as connection:
|
with _connect_readonly(resolved_path) as connection:
|
||||||
@@ -353,6 +400,11 @@ def list_recent_rcon_historical_samples(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return recent prospective RCON samples for one or all configured targets."""
|
"""Return recent prospective RCON samples for one or all configured targets."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import list_recent_samples
|
||||||
|
|
||||||
|
return list_recent_samples(target_key=target_key, limit=limit)
|
||||||
|
|
||||||
resolved_path = _resolve_db_path(db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: list[object] = [limit]
|
params: list[object] = [limit]
|
||||||
@@ -413,6 +465,11 @@ def list_rcon_historical_competitive_windows(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return recent RCON-backed competitive windows derived from persisted samples."""
|
"""Return recent RCON-backed competitive windows derived from persisted samples."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import list_competitive_windows
|
||||||
|
|
||||||
|
return list_competitive_windows(target_key=target_key, limit=limit)
|
||||||
|
|
||||||
resolved_path = _resolve_db_path(db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: list[object] = [limit]
|
params: list[object] = [limit]
|
||||||
@@ -498,6 +555,11 @@ def count_rcon_historical_samples_since(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Return how many RCON samples were captured after one timestamp."""
|
"""Return how many RCON samples were captured after one timestamp."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import count_samples_since
|
||||||
|
|
||||||
|
return count_samples_since(since)
|
||||||
|
|
||||||
if not since:
|
if not since:
|
||||||
return 0
|
return 0
|
||||||
resolved_path = _resolve_db_path(db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
@@ -522,6 +584,11 @@ def list_rcon_historical_competitive_summary_rows(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return RCON-backed per-target summary rows over competitive windows."""
|
"""Return RCON-backed per-target summary rows over competitive windows."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import list_competitive_summary_rows
|
||||||
|
|
||||||
|
return list_competitive_summary_rows(target_key=target_key)
|
||||||
|
|
||||||
resolved_path = _resolve_db_path(db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: list[object] = []
|
params: list[object] = []
|
||||||
@@ -593,6 +660,15 @@ def find_rcon_historical_competitive_window(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
"""Return the closest competitive window for one server/match if coverage exists."""
|
"""Return the closest competitive window for one server/match if coverage exists."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import find_competitive_window
|
||||||
|
|
||||||
|
return find_competitive_window(
|
||||||
|
server_key=server_key,
|
||||||
|
ended_at=ended_at,
|
||||||
|
map_name=map_name,
|
||||||
|
)
|
||||||
|
|
||||||
if not ended_at:
|
if not ended_at:
|
||||||
return None
|
return None
|
||||||
resolved_path = _resolve_db_path(db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
@@ -672,6 +748,14 @@ def get_rcon_historical_competitive_window_by_session(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
"""Return one persisted competitive RCON window by its synthetic session key."""
|
"""Return one persisted competitive RCON window by its synthetic session key."""
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import get_competitive_window_by_session
|
||||||
|
|
||||||
|
return get_competitive_window_by_session(
|
||||||
|
server_key=server_key,
|
||||||
|
session_key=session_key,
|
||||||
|
)
|
||||||
|
|
||||||
normalized_session_key = str(session_key or "").strip()
|
normalized_session_key = str(session_key or "").strip()
|
||||||
if not normalized_session_key:
|
if not normalized_session_key:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .config import (
|
|||||||
get_rcon_request_timeout_seconds,
|
get_rcon_request_timeout_seconds,
|
||||||
)
|
)
|
||||||
from .rcon_admin_log_ingestion import ingest_rcon_admin_logs
|
from .rcon_admin_log_ingestion import ingest_rcon_admin_logs
|
||||||
|
from .rcon_admin_log_materialization import materialize_rcon_admin_log
|
||||||
from .rcon_client import (
|
from .rcon_client import (
|
||||||
RconQueryError,
|
RconQueryError,
|
||||||
build_rcon_target_key,
|
build_rcon_target_key,
|
||||||
@@ -44,6 +45,8 @@ class RconHistoricalCaptureStats:
|
|||||||
admin_log_events_inserted: int = 0
|
admin_log_events_inserted: int = 0
|
||||||
admin_log_duplicate_events: int = 0
|
admin_log_duplicate_events: int = 0
|
||||||
admin_log_failed_targets: int = 0
|
admin_log_failed_targets: int = 0
|
||||||
|
materialized_matches_inserted: int = 0
|
||||||
|
materialized_matches_updated: int = 0
|
||||||
|
|
||||||
|
|
||||||
def run_rcon_historical_capture(
|
def run_rcon_historical_capture(
|
||||||
@@ -66,6 +69,7 @@ def run_rcon_historical_capture_unlocked(
|
|||||||
"""Capture one prospective RCON sample assuming the shared writer lock is already held."""
|
"""Capture one prospective RCON sample assuming the shared writer lock is already held."""
|
||||||
initialize_rcon_historical_storage()
|
initialize_rcon_historical_storage()
|
||||||
selected_targets = _select_targets(target_key)
|
selected_targets = _select_targets(target_key)
|
||||||
|
selected_target_keys = {build_rcon_target_key(target) for target in selected_targets}
|
||||||
admin_log_lookback_minutes = get_rcon_admin_log_lookback_minutes()
|
admin_log_lookback_minutes = get_rcon_admin_log_lookback_minutes()
|
||||||
captured_at = utc_now().isoformat().replace("+00:00", "Z")
|
captured_at = utc_now().isoformat().replace("+00:00", "Z")
|
||||||
target_scope = target_key or "all-configured-rcon-targets"
|
target_scope = target_key or "all-configured-rcon-targets"
|
||||||
@@ -127,6 +131,14 @@ def run_rcon_historical_capture_unlocked(
|
|||||||
result=admin_log_result,
|
result=admin_log_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
materialization_result = materialize_rcon_admin_log()
|
||||||
|
stats.materialized_matches_inserted = int(
|
||||||
|
materialization_result.get("matches_materialized") or 0
|
||||||
|
)
|
||||||
|
stats.materialized_matches_updated = int(
|
||||||
|
materialization_result.get("matches_updated") or 0
|
||||||
|
)
|
||||||
|
|
||||||
status = "success" if not errors else ("partial" if items else "failed")
|
status = "success" if not errors else ("partial" if items else "failed")
|
||||||
finalize_rcon_historical_capture_run(
|
finalize_rcon_historical_capture_run(
|
||||||
run_id,
|
run_id,
|
||||||
@@ -158,7 +170,12 @@ def run_rcon_historical_capture_unlocked(
|
|||||||
"targets": items,
|
"targets": items,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"admin_log_errors": admin_log_errors,
|
"admin_log_errors": admin_log_errors,
|
||||||
"storage_status": list_rcon_historical_target_statuses(),
|
"materialization_result": materialization_result,
|
||||||
|
"storage_status": [
|
||||||
|
status
|
||||||
|
for status in list_rcon_historical_target_statuses()
|
||||||
|
if status.get("target_key") in selected_target_keys
|
||||||
|
],
|
||||||
"totals": {
|
"totals": {
|
||||||
"targets_seen": stats.targets_seen,
|
"targets_seen": stats.targets_seen,
|
||||||
"samples_inserted": stats.samples_inserted,
|
"samples_inserted": stats.samples_inserted,
|
||||||
@@ -168,6 +185,8 @@ def run_rcon_historical_capture_unlocked(
|
|||||||
"admin_log_events_inserted": stats.admin_log_events_inserted,
|
"admin_log_events_inserted": stats.admin_log_events_inserted,
|
||||||
"admin_log_duplicate_events": stats.admin_log_duplicate_events,
|
"admin_log_duplicate_events": stats.admin_log_duplicate_events,
|
||||||
"admin_log_failed_targets": stats.admin_log_failed_targets,
|
"admin_log_failed_targets": stats.admin_log_failed_targets,
|
||||||
|
"materialized_matches_inserted": stats.materialized_matches_inserted,
|
||||||
|
"materialized_matches_updated": stats.materialized_matches_updated,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +223,7 @@ def run_periodic_rcon_historical_capture(
|
|||||||
retry_delay_seconds=retry_delay_seconds,
|
retry_delay_seconds=retry_delay_seconds,
|
||||||
target_key=target_key,
|
target_key=target_key,
|
||||||
)
|
)
|
||||||
print(json.dumps({"run": completed_runs, **payload}, indent=2))
|
print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True)
|
||||||
if max_runs is not None and completed_runs >= max_runs:
|
if max_runs is not None and completed_runs >= max_runs:
|
||||||
break
|
break
|
||||||
time.sleep(interval_seconds)
|
time.sleep(interval_seconds)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path
|
from .config import get_storage_path, use_postgres_rcon_storage
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
from .scoreboard_origins import resolve_trusted_scoreboard_match_url
|
from .scoreboard_origins import resolve_trusted_scoreboard_match_url
|
||||||
from .sqlite_utils import connect_sqlite_readonly
|
from .sqlite_utils import connect_sqlite_readonly
|
||||||
@@ -76,6 +76,16 @@ def _list_persisted_scoreboard_candidates(
|
|||||||
server_slug: str,
|
server_slug: str,
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
|
if use_postgres_rcon_storage():
|
||||||
|
from .postgres_rcon_storage import list_scoreboard_candidates
|
||||||
|
|
||||||
|
postgres_candidates = list_scoreboard_candidates(
|
||||||
|
server_slug=server_slug,
|
||||||
|
limit=MAX_CANDIDATES,
|
||||||
|
)
|
||||||
|
if postgres_candidates:
|
||||||
|
return postgres_candidates
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with connect_sqlite_readonly(db_path) as connection:
|
with connect_sqlite_readonly(db_path) as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
@@ -128,6 +138,10 @@ def _list_persisted_scoreboard_candidates(
|
|||||||
"match_url": match_url,
|
"match_url": match_url,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if items and use_postgres_rcon_storage():
|
||||||
|
from .postgres_rcon_storage import upsert_scoreboard_candidates
|
||||||
|
|
||||||
|
upsert_scoreboard_candidates(server_slug=server_slug, candidates=items)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
89
backend/app/storage_diagnostics.py
Normal file
89
backend/app/storage_diagnostics.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Report active phase-1 RCON storage backend and migrated table counts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
from .config import get_database_url, get_storage_path, use_postgres_rcon_storage
|
||||||
|
from .rcon_admin_log_materialization import summarize_rcon_materialization_status
|
||||||
|
from .rcon_admin_log_storage import initialize_rcon_admin_log_storage
|
||||||
|
from .sqlite_utils import connect_sqlite_readonly
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATED_TABLES = (
|
||||||
|
"rcon_admin_log_events",
|
||||||
|
"rcon_player_profile_snapshots",
|
||||||
|
"rcon_materialized_matches",
|
||||||
|
"rcon_match_player_stats",
|
||||||
|
"rcon_historical_targets",
|
||||||
|
"rcon_historical_samples",
|
||||||
|
"rcon_historical_competitive_windows",
|
||||||
|
"rcon_scoreboard_match_candidates",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_storage_diagnostics() -> dict[str, object]:
|
||||||
|
"""Return one JSON-safe diagnostic payload for the migrated domains."""
|
||||||
|
if use_postgres_rcon_storage():
|
||||||
|
from .postgres_rcon_storage import count_migrated_tables
|
||||||
|
|
||||||
|
counts = count_migrated_tables()
|
||||||
|
backend = "postgresql"
|
||||||
|
else:
|
||||||
|
counts = _count_sqlite_tables()
|
||||||
|
backend = "sqlite-fallback"
|
||||||
|
materialization = summarize_rcon_materialization_status()
|
||||||
|
return {
|
||||||
|
"active_storage_backend": backend,
|
||||||
|
"database_url_configured": bool(get_database_url()),
|
||||||
|
"sqlite_fallback_path": str(get_storage_path()),
|
||||||
|
"migrated_domains": [
|
||||||
|
"rcon-admin-log-events",
|
||||||
|
"rcon-player-profile-snapshots",
|
||||||
|
"rcon-historical-capture-samples-and-windows",
|
||||||
|
"rcon-materialized-matches",
|
||||||
|
"rcon-materialized-player-stats",
|
||||||
|
"rcon-safe-scoreboard-candidates",
|
||||||
|
],
|
||||||
|
"table_counts": counts,
|
||||||
|
"latest_materialized_matches": materialization["latest_materialized_matches"],
|
||||||
|
"latest_admin_log_match_end_events": materialization[
|
||||||
|
"latest_admin_log_match_end_events"
|
||||||
|
],
|
||||||
|
"match_end_status": materialization["match_end_status"],
|
||||||
|
"sqlite_remaining": [
|
||||||
|
"live server snapshots and /api/servers cache",
|
||||||
|
"public-scoreboard historical_* tables and scoreboard fallback data",
|
||||||
|
"historical snapshots, player-event ledger and Elo/MMR tables",
|
||||||
|
],
|
||||||
|
"scoreboard_correlation": (
|
||||||
|
"PostgreSQL safe candidates are preferred when present; phase-1 still falls "
|
||||||
|
"back to trusted persisted historical_* scoreboard rows on SQLite."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_sqlite_tables() -> dict[str, int]:
|
||||||
|
resolved_path = initialize_rcon_admin_log_storage()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
with closing(connect_sqlite_readonly(resolved_path)) as connection:
|
||||||
|
for table_name in MIGRATED_TABLES:
|
||||||
|
try:
|
||||||
|
row = connection.execute(
|
||||||
|
f"SELECT COUNT(*) AS count FROM {table_name}"
|
||||||
|
).fetchone()
|
||||||
|
except sqlite3.Error:
|
||||||
|
counts[table_name] = 0
|
||||||
|
else:
|
||||||
|
counts[table_name] = int(row["count"] or 0)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1 +1,2 @@
|
|||||||
# El bootstrap actual usa solo la libreria estandar de Python.
|
# PostgreSQL is used by the phase-1 RCON historical storage migration.
|
||||||
|
psycopg[binary]>=3.2,<4
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: hll-vietnam-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: hll_vietnam
|
||||||
|
POSTGRES_USER: hll_vietnam
|
||||||
|
POSTGRES_PASSWORD: hll_vietnam_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U hll_vietnam -d hll_vietnam"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -6,6 +24,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env.example
|
- ./backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
|
HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam}
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
|
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
|
||||||
@@ -13,6 +32,9 @@ services:
|
|||||||
${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]}
|
${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/data:/app/data
|
- ./backend/data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -27,13 +49,17 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env.example
|
- ./backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
|
HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam}
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
|
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
|
||||||
HLL_BACKEND_RCON_TARGETS: >-
|
HLL_BACKEND_RCON_TARGETS: >-
|
||||||
${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]}
|
${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/data:/app/data
|
- ./backend/data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -48,6 +74,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env.example
|
- ./backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
|
HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam}
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
|
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
|
||||||
@@ -57,7 +84,10 @@ services:
|
|||||||
HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2}
|
HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2}
|
||||||
HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15}
|
HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/data:/app/data
|
- ./backend/data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -71,3 +101,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
|||||||
@@ -177,3 +177,31 @@ son confiables `comunidad-hispana-01`, con origen
|
|||||||
defecto nuevos. Los datos historicos ya persistidos no se eliminan, pero las
|
defecto nuevos. Los datos historicos ya persistidos no se eliminan, pero las
|
||||||
URLs publicas de partidas solo se aceptan si el `raw_payload_ref` usa HTTP(S),
|
URLs publicas de partidas solo se aceptan si el `raw_payload_ref` usa HTTP(S),
|
||||||
apunta al origen confiable del servidor activo y mantiene una ruta `/games/`.
|
apunta al origen confiable del servidor activo y mantiene una ruta `/games/`.
|
||||||
|
|
||||||
|
## Decision 017: PostgreSQL phase 1 for RCON historical persistence
|
||||||
|
|
||||||
|
La primera migracion de persistencia a PostgreSQL cubre el camino que sufria
|
||||||
|
contencion SQLite entre `backend`, `historical-runner` y
|
||||||
|
`rcon-historical-worker`:
|
||||||
|
|
||||||
|
- captura prospectiva RCON, muestras y ventanas competitivas
|
||||||
|
- eventos AdminLog deduplicados y snapshots de perfil derivados
|
||||||
|
- partidas RCON materializadas y estadisticas por jugador
|
||||||
|
- candidatos confiables de URL de scoreboard que puedan poblarse para
|
||||||
|
correlacion de detalle
|
||||||
|
|
||||||
|
Docker Compose configura `HLL_BACKEND_DATABASE_URL` y usa PostgreSQL como
|
||||||
|
backend autoritativo para esas tablas. La ejecucion local sin esa variable sigue
|
||||||
|
usando SQLite como fallback temporal para preservar comandos y tests locales.
|
||||||
|
|
||||||
|
Quedan SQLite-backed en esta fase porque no forman parte del lock-prone writer
|
||||||
|
path migrado y siguen cubriendo fallback publico o caches locales:
|
||||||
|
|
||||||
|
- snapshots live y cache de `/api/servers`
|
||||||
|
- tablas `historical_*` de scoreboard publico, rankings y correlacion legacy
|
||||||
|
- snapshots historicos precalculados, ledger player-event y Elo/MMR pausado
|
||||||
|
|
||||||
|
La correlacion de URL publica en detalle usa primero candidatos PostgreSQL
|
||||||
|
confiables cuando existan y puede seguir leyendo filas `historical_*`
|
||||||
|
persistidas en SQLite durante la transicion. El diagnostico operativo se expone
|
||||||
|
con `python -m app.storage_diagnostics`.
|
||||||
|
|||||||
@@ -39,18 +39,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.historical-player-row {
|
.historical-player-row {
|
||||||
cursor: pointer;
|
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.historical-player-row:hover td,
|
.historical-player-row:hover td,
|
||||||
.historical-player-row:focus td,
|
|
||||||
.historical-player-row:focus-within td,
|
.historical-player-row:focus-within td,
|
||||||
.historical-player-row.is-expanded td {
|
.historical-player-row.is-expanded td {
|
||||||
background: rgba(210, 182, 118, 0.06);
|
background: rgba(210, 182, 118, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.historical-player-row:focus-visible td,
|
|
||||||
.historical-player-row__details-button:focus-visible {
|
.historical-player-row__details-button:focus-visible {
|
||||||
outline: 2px solid rgba(210, 182, 118, 0.78);
|
outline: 2px solid rgba(210, 182, 118, 0.78);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
@@ -99,11 +96,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.historical-player-row:hover + .historical-player-detail-row,
|
|
||||||
.historical-player-row:focus + .historical-player-detail-row,
|
|
||||||
.historical-player-row:focus-within + .historical-player-detail-row,
|
|
||||||
.historical-player-detail-row:hover,
|
|
||||||
.historical-player-detail-row:focus-within,
|
|
||||||
.historical-player-detail-row.is-open {
|
.historical-player-detail-row.is-open {
|
||||||
display: table-row;
|
display: table-row;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,9 +230,6 @@ function renderPlayerRows(player, item, index) {
|
|||||||
<tr
|
<tr
|
||||||
class="historical-player-row historical-player-row--${team.key}"
|
class="historical-player-row historical-player-row--${team.key}"
|
||||||
id="${escapeHtml(rowId)}"
|
id="${escapeHtml(rowId)}"
|
||||||
tabindex="0"
|
|
||||||
aria-controls="${escapeHtml(panelId)}"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
@@ -270,31 +267,35 @@ function renderPlayerRows(player, item, index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindPlayerDetailRows(playersBody) {
|
function bindPlayerDetailRows(playersBody) {
|
||||||
playersBody.querySelectorAll(".historical-player-row").forEach((row) => {
|
const playerRows = [...playersBody.querySelectorAll(".historical-player-row")];
|
||||||
|
const collapseRow = (row) => {
|
||||||
|
const button = row.querySelector(".historical-player-row__details-button");
|
||||||
|
const detailRow = row.nextElementSibling;
|
||||||
|
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.classList.remove("is-expanded");
|
||||||
|
detailRow.classList.remove("is-open");
|
||||||
|
button.setAttribute("aria-expanded", "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
playerRows.forEach((row) => {
|
||||||
const button = row.querySelector(".historical-player-row__details-button");
|
const button = row.querySelector(".historical-player-row__details-button");
|
||||||
const detailRow = row.nextElementSibling;
|
const detailRow = row.nextElementSibling;
|
||||||
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
|
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const setExpanded = (expanded) => {
|
const setExpanded = (expanded) => {
|
||||||
|
if (expanded) {
|
||||||
|
playerRows.filter((candidate) => candidate !== row).forEach(collapseRow);
|
||||||
|
}
|
||||||
row.classList.toggle("is-expanded", expanded);
|
row.classList.toggle("is-expanded", expanded);
|
||||||
detailRow.classList.toggle("is-open", expanded);
|
detailRow.classList.toggle("is-open", expanded);
|
||||||
row.setAttribute("aria-expanded", String(expanded));
|
|
||||||
button.setAttribute("aria-expanded", String(expanded));
|
button.setAttribute("aria-expanded", String(expanded));
|
||||||
};
|
};
|
||||||
const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open"));
|
const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open"));
|
||||||
|
|
||||||
row.addEventListener("click", (event) => {
|
button.addEventListener("click", () => {
|
||||||
if (event.target.closest("a")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toggleExpanded();
|
|
||||||
});
|
|
||||||
row.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
toggleExpanded();
|
toggleExpanded();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,16 +74,11 @@
|
|||||||
const serverName = item?.server?.name || "Servidor no disponible";
|
const serverName = item?.server?.name || "Servidor no disponible";
|
||||||
const closedAt = item?.closed_at || item?.ended_at || item?.started_at;
|
const closedAt = item?.closed_at || item?.ended_at || item?.started_at;
|
||||||
const detailUrl = buildDynamicInternalMatchDetailUrl(item);
|
const detailUrl = buildDynamicInternalMatchDetailUrl(item);
|
||||||
const externalUrl = normalizeDynamicExternalMatchUrl(item?.match_url);
|
|
||||||
|
|
||||||
const actionLinks = [
|
const actionLinks = [
|
||||||
`<span class="historical-match-card__result">${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}</span>`,
|
`<span class="historical-match-card__result">${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}</span>`,
|
||||||
detailUrl
|
detailUrl
|
||||||
? `<a class="historical-match-card__link" href="${escapeDynamicHtml(detailUrl)}">Ver detalles</a>`
|
? `<a class="historical-match-card__link" href="${escapeDynamicHtml(detailUrl)}">Ver detalles</a>`
|
||||||
: "",
|
: "",
|
||||||
externalUrl
|
|
||||||
? `<a class="historical-match-card__link historical-match-card__link--scoreboard" href="${escapeDynamicHtml(externalUrl)}" target="_blank" rel="noopener noreferrer">Ver partida</a>`
|
|
||||||
: "",
|
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|||||||
@@ -802,7 +802,6 @@ function hydrateMvpComparison(
|
|||||||
|
|
||||||
function renderRecentMatchCard(item) {
|
function renderRecentMatchCard(item) {
|
||||||
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
||||||
const matchUrl = normalizeExternalMatchUrl(item.match_url);
|
|
||||||
const detailUrl = buildInternalMatchDetailUrl(item);
|
const detailUrl = buildInternalMatchDetailUrl(item);
|
||||||
const actionLinks = [
|
const actionLinks = [
|
||||||
`<span class="historical-match-card__result">${escapeHtml(formatMatchResult(item.result))}</span>`,
|
`<span class="historical-match-card__result">${escapeHtml(formatMatchResult(item.result))}</span>`,
|
||||||
@@ -816,18 +815,6 @@ function renderRecentMatchCard(item) {
|
|||||||
</a>
|
</a>
|
||||||
`
|
`
|
||||||
: "",
|
: "",
|
||||||
matchUrl
|
|
||||||
? `
|
|
||||||
<a
|
|
||||||
class="historical-match-card__link historical-match-card__link--scoreboard"
|
|
||||||
href="${escapeHtml(matchUrl)}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Ver partida
|
|
||||||
</a>
|
|
||||||
`
|
|
||||||
: "",
|
|
||||||
].join("");
|
].join("");
|
||||||
return `
|
return `
|
||||||
<article class="historical-match-card historical-match-card--clean">
|
<article class="historical-match-card historical-match-card--clean">
|
||||||
|
|||||||
@@ -266,16 +266,19 @@ function renderServerStatsCard(server) {
|
|||||||
server.status === "online" ? "server-state--online" : "server-state--offline";
|
server.status === "online" ? "server-state--online" : "server-state--offline";
|
||||||
const isRealSnapshot = isRealLiveSnapshot(server);
|
const isRealSnapshot = isRealLiveSnapshot(server);
|
||||||
const currentMap = server.current_map || "Sin mapa disponible";
|
const currentMap = server.current_map || "Sin mapa disponible";
|
||||||
const region = server.region || "Region pendiente";
|
const region = normalizeServerRegion(server.region);
|
||||||
const players = Number.isFinite(server.players) ? server.players : 0;
|
const players = Number.isFinite(server.players) ? server.players : 0;
|
||||||
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
|
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
|
||||||
const actionMarkup = renderServerAction(server);
|
const actionMarkup = renderServerAction(server);
|
||||||
const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference";
|
const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference";
|
||||||
const eyebrowLabel = isRealSnapshot ? "Servidor de comunidad" : "Referencia actual";
|
const eyebrowLabel = isRealSnapshot ? "Servidor de comunidad" : "Referencia actual";
|
||||||
const quickFacts = renderQuickFacts([
|
const quickFactItems = [
|
||||||
{ label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" },
|
{ label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" },
|
||||||
{ label: "Region", value: region },
|
];
|
||||||
]);
|
if (region) {
|
||||||
|
quickFactItems.push({ label: "Region", value: region });
|
||||||
|
}
|
||||||
|
const quickFacts = renderQuickFacts(quickFactItems);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="server-card server-card--stats ${cardVariantClass}">
|
<article class="server-card server-card--stats ${cardVariantClass}">
|
||||||
@@ -299,6 +302,31 @@ function renderServerSections(latestItems) {
|
|||||||
return latestItems.map((server) => renderServerStatsCard(server)).join("");
|
return latestItems.map((server) => renderServerStatsCard(server)).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeServerRegion(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalizedValue = trimmedValue
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
const placeholderValues = new Set([
|
||||||
|
"region pendiente",
|
||||||
|
"region pending",
|
||||||
|
"pending",
|
||||||
|
"unknown",
|
||||||
|
"desconocida",
|
||||||
|
"no disponible",
|
||||||
|
"por confirmar",
|
||||||
|
"n/a",
|
||||||
|
]);
|
||||||
|
return placeholderValues.has(normalizedValue) ? "" : trimmedValue;
|
||||||
|
}
|
||||||
|
|
||||||
function renderServerAction(server) {
|
function renderServerAction(server) {
|
||||||
const historyState = getServerHistoryState(server);
|
const historyState = getServerHistoryState(server);
|
||||||
if (!historyState.available) {
|
if (!historyState.available) {
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ Assert-NotContains $historicoHtml "Elo/MMR" `
|
|||||||
Assert-NotContains $visibleHistoricalText "snapshot" `
|
Assert-NotContains $visibleHistoricalText "snapshot" `
|
||||||
"Public snapshot wording was reintroduced in visible historical text."
|
"Public snapshot wording was reintroduced in visible historical text."
|
||||||
|
|
||||||
Assert-Contains $historicoJs "Ver partida" `
|
Assert-NotContains $historicoJs "Ver partida" `
|
||||||
"Recent match cards no longer include the external match action label."
|
"Recent match cards must not expose the external scoreboard action label."
|
||||||
Assert-Contains $historicoJs "Ver detalles" `
|
Assert-Contains $historicoJs "Ver detalles" `
|
||||||
"Recent match cards no longer include the internal detail fallback label."
|
"Recent match cards no longer include the internal detail fallback label."
|
||||||
Assert-NotContains $historicoJs "item.match_url || item.source_url" `
|
Assert-NotContains $historicoJs "item.match_url || item.source_url" `
|
||||||
|
|||||||
Reference in New Issue
Block a user