From 50bfadf4712f8c875c6bda275068277661cf1d59 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 26 Mar 2026 07:18:56 +0100 Subject: [PATCH] Promote rcon historical model to primary --- ...storical-competitive-read-model-primary.md | 54 ++ ...orical-aggregates-and-fallback-boundary.md | 51 ++ ...-loading-state-and-stale-lock-hardening.md | 45 ++ backend/README.md | 35 +- backend/app/data_sources.py | 12 +- backend/app/elo_mmr_engine.py | 58 +- backend/app/historical_snapshots.py | 30 +- backend/app/payloads.py | 55 +- backend/app/rcon_historical_read_model.py | 98 +++- backend/app/rcon_historical_storage.py | 550 ++++++++++++++++-- backend/app/writer_lock.py | 38 +- frontend/assets/css/styles.css | 43 ++ frontend/assets/js/main.js | 21 +- frontend/index.html | 102 +--- 14 files changed, 964 insertions(+), 228 deletions(-) create mode 100644 ai/tasks/done/TASK-102-rcon-historical-competitive-read-model-primary.md create mode 100644 ai/tasks/done/TASK-103-rcon-first-historical-aggregates-and-fallback-boundary.md create mode 100644 ai/tasks/done/TASK-104-landing-loading-state-and-stale-lock-hardening.md diff --git a/ai/tasks/done/TASK-102-rcon-historical-competitive-read-model-primary.md b/ai/tasks/done/TASK-102-rcon-historical-competitive-read-model-primary.md new file mode 100644 index 0000000..ede644c --- /dev/null +++ b/ai/tasks/done/TASK-102-rcon-historical-competitive-read-model-primary.md @@ -0,0 +1,54 @@ +# TASK-102-rcon-historical-competitive-read-model-primary + +## Goal +Construir una capa histórica competitiva primaria basada en persistencia RCON, o en una materialización derivada de ella, suficiente para soportar el producto histórico sin depender de public-scoreboard como writer principal. + +## Context +Hoy la repo ya tiene: +- captura histórica prospectiva RCON +- read model histórico RCON mínimo +- histórico clásico por public-scoreboard +- player-events V2 +- Elo/MMR mensual +Pero el writer path histórico competitivo sigue dependiendo del archivo clásico importado por scoreboard. + +## Steps +1. Auditar: + - `backend/app/rcon_historical_storage.py` + - `backend/app/rcon_historical_read_model.py` + - `backend/app/historical_storage.py` + - `backend/app/player_event_storage.py` + - `backend/app/elo_mmr_storage.py` + - `backend/app/payloads.py` +2. Definir un modelo primario histórico competitivo RCON-backed: + - directo sobre persistencia RCON, o + - mediante tablas/materializaciones derivadas generadas desde RCON +3. Ese modelo debe cubrir como prioridad: + - recent activity / recent matches + - historical server summary + - métricas mínimas competitivas reutilizables por MVP/Elo +4. Si hacen falta tablas/materialized snapshots nuevas, crearlas. +5. Dejar capabilities explícitas por dominio: + - exact + - approximate + - partial + - unavailable +6. Documentar claramente qué parte del histórico ya puede dejar de depender del import clásico. +7. Mantener scoreboard como fallback solo para lo que todavía no esté cubierto. + +## Constraints +- No romper la persistencia RCON ya existente. +- No eliminar el histórico clásico todavía. +- No inventar granularidad que la captura RCON no tenga. +- No degradar el request path HTTP innecesariamente. + +## Validation +- Existe una capa histórica competitiva primaria RCON-backed real. +- Al menos summary/recent activity dejan de depender del pipeline clásico como principal. +- Las capabilities quedan visibles y honestas. +- La repo queda consistente. + +## Expected Files +- archivos backend nuevos o modificados bajo `backend/app/` para read model/materialización histórica RCON +- `docs/elo-mmr-monthly-ranking-design.md` si hace falta +- `backend/README.md` diff --git a/ai/tasks/done/TASK-103-rcon-first-historical-aggregates-and-fallback-boundary.md b/ai/tasks/done/TASK-103-rcon-first-historical-aggregates-and-fallback-boundary.md new file mode 100644 index 0000000..0d12f30 --- /dev/null +++ b/ai/tasks/done/TASK-103-rcon-first-historical-aggregates-and-fallback-boundary.md @@ -0,0 +1,51 @@ +# TASK-103-rcon-first-historical-aggregates-and-fallback-boundary + +## Goal +Mover los agregados históricos de producto a una frontera RCON-first real, dejando scoreboard solo para las piezas que todavía no puedan calcularse desde el modelo histórico competitivo RCON-backed. + +## Context +Una vez exista el modelo primario histórico competitivo RCON-backed, hace falta conectar realmente los endpoints y payloads de producto para que usen esa capa como primaria. + +## Steps +1. Auditar: + - `backend/app/payloads.py` + - `backend/app/routes.py` + - `backend/app/historical_snapshots.py` + - `backend/app/historical_snapshot_storage.py` + - `backend/app/elo_mmr_engine.py` +2. Reorientar como RCON-first real, al menos donde la nueva capa ya lo permita: + - historical server summary + - recent matches + - Elo/MMR mensual + - y cualquier agregado competitivo mínimo ya soportado +3. Mantener fallback a public-scoreboard solo cuando: + - la capability sea partial/unavailable + - la cobertura RCON no alcance + - el cálculo falle +4. Hacer visible la frontera exacta de fallback: + - qué endpoints ya son realmente RCON-first + - cuáles siguen cayendo a scoreboard + - por qué +5. Ajustar snapshots/materializaciones si hace falta para no depender del request path directo. +6. Alinear README/runbook con esta nueva frontera funcional. + +## Constraints +- No afirmar que MVP V1/V2 completos ya sean 100% RCON-backed si no lo son. +- No romper endpoints existentes. +- No ocultar el fallback real. +- Mantener latencia razonable. + +## Validation +- Los endpoints históricos soportados ya usan RCON-backed como primario real. +- El fallback a scoreboard queda reducido y explícito. +- Elo/MMR mensual consume primariamente el modelo RCON-backed donde ya sea posible. +- La repo queda consistente. + +## Expected Files +- `backend/app/payloads.py` +- `backend/app/routes.py` +- `backend/app/historical_snapshots.py` +- `backend/app/historical_snapshot_storage.py` +- `backend/app/elo_mmr_engine.py` +- `backend/README.md` +- otros archivos backend si hace falta diff --git a/ai/tasks/done/TASK-104-landing-loading-state-and-stale-lock-hardening.md b/ai/tasks/done/TASK-104-landing-loading-state-and-stale-lock-hardening.md new file mode 100644 index 0000000..803e212 --- /dev/null +++ b/ai/tasks/done/TASK-104-landing-loading-state-and-stale-lock-hardening.md @@ -0,0 +1,45 @@ +# TASK-104-landing-loading-state-and-stale-lock-hardening + +## Goal +Corregir dos problemas de producto/operación ya detectados: +1. la landing muestra primero datos fake estáticos antes de hidratar +2. los stale locks entre contenedores Docker siguen bloqueando pasadas manuales aunque los workers ya estén parados + +## Context +Se ha confirmado que: +- la homepage renderiza cards estáticas fake y luego las sustituye al hidratar +- eso genera un flash de datos falsos +- además, el lock compartido puede quedar huérfano entre contenedores y requiere borrado manual + +## Steps +1. Para la landing: + - auditar `frontend/index.html`, `frontend/assets/js/main.js`, `frontend/assets/css/styles.css` + - aplicar la opción A: + - no mostrar cards fake iniciales + - dejar contenedor vacío o skeleton/loading state + - renderizar solo datos reales al hidratar + - degradar limpio si falla la API +2. Para el stale lock: + - auditar `backend/app/writer_lock.py` + - mejorar la detección/recuperación de locks huérfanos entre contenedores Docker + - no depender únicamente del hostname si eso bloquea recuperación válida + - mantener seguridad para no liberar locks activos por error +3. Documentar el comportamiento actualizado en README/runbook. + +## Constraints +- No rehacer la landing completa. +- No romper el single-writer lock. +- No volver al comportamiento sin coordinación. +- No introducir riesgo de liberar locks válidos sin comprobación suficiente. + +## Validation +- La landing ya no muestra datos fake antes de hidratar. +- Los locks huérfanos entre contenedores se recuperan mejor o quedan claramente resueltos. +- La repo queda consistente. + +## Expected Files +- `frontend/index.html` +- `frontend/assets/js/main.js` +- `frontend/assets/css/styles.css` +- `backend/app/writer_lock.py` +- `backend/README.md` diff --git a/backend/README.md b/backend/README.md index d535cd5..ff90d18 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1195,6 +1195,12 @@ El servicio `historical-runner` usa el mismo volumen persistente `./backend/data y ejecuta `python -m app.historical_runner --hourly` como bucle operativo dedicado, sin mezclar el scheduler con el proceso HTTP principal. +En frontend, la landing ya no arranca con cards fake estaticas para servidores: + +- el contenedor queda en estado de loading +- solo se renderizan cards con datos reales al hidratar +- si la API falla, se muestra una degradacion limpia en lugar de datos falsos + ## Coordinacion single-writer para automatizaciones y CLI Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un @@ -1224,6 +1230,10 @@ Comportamiento: - `started_at` - host - pid +- si el lock parece venir de un contenedor Docker ya parado, el backend puede + recuperarlo automaticamente cuando: + - el holder venia de un cwd tipo `/app` + - el lock ya supero una gracia minima de seguridad - la coordinacion principal es este single-writer lock; WAL y `busy_timeout` quedan como endurecimiento complementario, no como solucion unica @@ -1434,12 +1444,18 @@ Metadata observable en payloads historicos: Estado real a fecha de esta fase: -- el read model historico RCON soporta cobertura y actividad reciente +- el read model historico RCON soporta ya una capa competitiva primaria basada + en ventanas derivadas desde persistencia `rcon_historical_*` +- `server-summary` y `recent-matches` pasan a usar esa capa RCON-backed como + camino principal real - `historical_ingestion` intenta primero una captura writer-oriented por RCON y deja esa tentativa visible en su salida -- rankings competitivos, MVP y Elo/MMR siguen necesitando fallback a - `public-scoreboard` porque el read model RCON actual no expone aun detalle - historico competitivo suficiente +- leaderboards semanales/mensuales, MVP V1/V2 y player-events siguen teniendo + fallback a `public-scoreboard` mientras RCON no disponga de señal competitiva + por jugador con paridad suficiente +- Elo/MMR pasa a consumir primero contexto competitivo RCON-backed para + cobertura y calidad de match cuando existe, pero sigue necesitando + `public-scoreboard` como suplemento para estadisticas por jugador ## Elo/MMR Monthly Ranking @@ -1467,6 +1483,17 @@ Politica de exactitud: - `approximate`: role bucket, objective index, strength of schedule - `not_available`: leadership y tacticas finas no persistidas +Cuando `historical_data_source=rcon`, el motor Elo/MMR deja visible una +frontera hibrida y honesta: + +- `primary_source = rcon` +- `selected_source = hybrid-rcon-competitive-plus-public-scoreboard` +- `fallback_used = true` + +Eso significa que la capa RCON-backed ya aporta el contexto competitivo de +cobertura y calidad de match, pero las estadisticas competitivas por jugador +siguen necesitando el suplemento clasico hasta que RCON tenga esa granularidad. + La especificacion detallada y el mapa de capabilities quedan en: - `docs/elo-mmr-monthly-ranking-design.md` diff --git a/backend/app/data_sources.py b/backend/app/data_sources.py index 3f7db3b..0ca9b15 100644 --- a/backend/app/data_sources.py +++ b/backend/app/data_sources.py @@ -174,7 +174,7 @@ class RconFirstLiveDataSource: @dataclass(frozen=True, slots=True) class RconHistoricalDataSource: - """Minimal persisted historical read model over prospective RCON capture.""" + """Persisted RCON-backed historical read model over captured competitive windows.""" source_kind: str = SOURCE_KIND_RCON @@ -200,7 +200,7 @@ class RconHistoricalDataSource: ) def list_server_summaries(self, *, server_key: str | None = None) -> list[dict[str, object]]: - """Return coverage and freshness from persisted prospective RCON samples.""" + """Return coverage and freshness from persisted RCON-backed competitive history.""" return list_rcon_historical_server_summaries(server_key=server_key) def list_recent_activity( @@ -209,7 +209,7 @@ class RconHistoricalDataSource: server_key: str | None = None, limit: int = 20, ) -> list[dict[str, object]]: - """Return recent persisted RCON activity without on-demand network calls.""" + """Return recent RCON-backed competitive history without on-demand network calls.""" return list_rcon_historical_recent_activity(server_key=server_key, limit=limit) def describe_capabilities(self) -> dict[str, object]: @@ -238,7 +238,7 @@ def get_live_data_source() -> LiveDataSource: def get_rcon_historical_read_model() -> RconHistoricalDataSource | None: - """Return the minimal persisted RCON historical read model when selected.""" + """Return the persisted RCON-backed historical read model when selected.""" if get_historical_data_source_kind() != SOURCE_KIND_RCON: return None return RconHistoricalDataSource() @@ -258,8 +258,8 @@ def describe_historical_runtime_policy() -> dict[str, object]: "primary_source": SOURCE_KIND_RCON, "fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD, "summary": ( - "Historical runtime attempts the persisted RCON read model first and falls " - "back to public-scoreboard when the requested operation is unsupported, has " + "Historical runtime attempts the persisted RCON-backed competitive model first " + "and falls back to public-scoreboard when the requested operation is unsupported, has " "no coverage yet, or the primary path fails." ), } diff --git a/backend/app/elo_mmr_engine.py b/backend/app/elo_mmr_engine.py index e319464..6a25f69 100644 --- a/backend/app/elo_mmr_engine.py +++ b/backend/app/elo_mmr_engine.py @@ -15,6 +15,7 @@ from .data_sources import ( SOURCE_KIND_RCON, build_source_attempt, build_source_policy, + get_rcon_historical_read_model, ) from .elo_mmr_models import ( CAPABILITY_APPROXIMATE, @@ -39,6 +40,7 @@ from .elo_mmr_storage import ( replace_elo_mmr_state, ) from .historical_storage import ALL_SERVERS_SLUG, initialize_historical_storage +from .rcon_historical_read_model import get_rcon_historical_competitive_match_context from .sqlite_utils import connect_sqlite_readonly from .writer_lock import backend_writer_lock, build_writer_lock_holder @@ -68,6 +70,7 @@ def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]: resolved_path = initialize_historical_storage(db_path=db_path) initialize_elo_mmr_storage(db_path=resolved_path) historical_source_policy = _build_historical_source_policy_for_elo() + rcon_read_model = get_rcon_historical_read_model() match_rows = _load_closed_match_rows(db_path=resolved_path) grouped_matches = _group_match_rows(match_rows) @@ -85,6 +88,15 @@ def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]: match_group=match_group, scope_key=scope_key, ratings_by_scope=ratings_by_scope[scope_key], + rcon_match_context=( + get_rcon_historical_competitive_match_context( + server_key=str(match_group["server_slug"]), + ended_at=match_group.get("ended_at"), + map_name=match_group.get("map_pretty_name") or match_group.get("map_name"), + ) + if rcon_read_model is not None + else None + ), ) ) @@ -274,11 +286,18 @@ def _score_match_for_scope( match_group: dict[str, object], scope_key: str, ratings_by_scope: dict[str, dict[str, object]], + rcon_match_context: dict[str, object] | None = None, ) -> list[dict[str, object]]: players = list(match_group["players"]) - duration_seconds, duration_mode = _resolve_match_duration(match_group, players) + duration_seconds, duration_mode = _resolve_match_duration( + match_group, + players, + rcon_match_context=rcon_match_context, + ) quality_factor = _build_quality_factor( - player_count=len(players), + player_count=max(len(players), int(rcon_match_context.get("peak_players") or 0)) + if rcon_match_context is not None + else len(players), duration_seconds=duration_seconds, has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None, ) @@ -354,6 +373,14 @@ def _score_match_for_scope( signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps.")) else: signals.append(build_signal("quality_duration", CAPABILITY_APPROXIMATE, "Duration approximated from the maximum persisted player time.")) + if rcon_match_context is not None: + signals.append( + build_signal( + "RconCompetitiveWindow", + CAPABILITY_APPROXIMATE, + "Uses the closest RCON-backed competitive window for match duration and lobby density when coverage exists.", + ) + ) weights = ROLE_WEIGHTS.get(role_bucket, ROLE_WEIGHTS[ROLE_BUCKET_GENERALIST]) impact_score = round( @@ -541,17 +568,34 @@ def _build_historical_source_policy_for_elo() -> dict[str, object]: ) return build_source_policy( primary_source=SOURCE_KIND_RCON, - selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, + selected_source="hybrid-rcon-competitive-plus-public-scoreboard", fallback_used=True, - fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet", + fallback_reason="rcon-competitive-context-primary-but-player-stats-still-require-public-scoreboard-supplement", source_attempts=[ - build_source_attempt(source=SOURCE_KIND_RCON, role="primary", status="unsupported", reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet"), - build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="fallback", status="success"), + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="partial", + reason="rcon-competitive-context-used-for-match-coverage-and-quality", + ), + build_source_attempt( + source=SOURCE_KIND_PUBLIC_SCOREBOARD, + role="supplemental-fallback", + status="success", + reason="public-scoreboard-still-provides-player-level-competitive-stats", + ), ], ) -def _resolve_match_duration(match_group: dict[str, object], players: list[dict[str, object]]) -> tuple[int, str]: +def _resolve_match_duration( + match_group: dict[str, object], + players: list[dict[str, object]], + *, + rcon_match_context: dict[str, object] | None = None, +) -> tuple[int, str]: + if rcon_match_context and int(rcon_match_context.get("duration_seconds") or 0) > 0: + return int(rcon_match_context["duration_seconds"]), CAPABILITY_APPROXIMATE started_at = _parse_optional_timestamp(match_group.get("started_at")) ended_at = _parse_optional_timestamp(match_group.get("ended_at")) if started_at and ended_at and ended_at >= started_at: diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index 263289b..0182339 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -6,6 +6,8 @@ import sqlite3 from datetime import datetime, timezone from pathlib import Path +from .config import get_historical_data_source_kind +from .data_sources import SOURCE_KIND_RCON, get_rcon_historical_read_model from .historical_storage import ( ALL_SERVERS_SLUG, list_historical_server_summaries, @@ -364,7 +366,15 @@ def _build_server_summary_snapshot( *, db_path: Path | None = None, ) -> dict[str, object]: - summary_items = list_historical_server_summaries(server_slug=server_key, db_path=db_path) + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + data_source = get_rcon_historical_read_model() + summary_items = ( + data_source.list_server_summaries(server_key=server_key) + if data_source is not None + else [] + ) + else: + summary_items = list_historical_server_summaries(server_slug=server_key, db_path=db_path) summary_item = summary_items[0] if summary_items else {} time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {} return { @@ -457,11 +467,19 @@ def _build_recent_matches_snapshot( limit: int, db_path: Path | None = None, ) -> dict[str, object]: - items = list_recent_historical_matches( - limit=limit, - server_slug=server_key, - db_path=db_path, - ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + data_source = get_rcon_historical_read_model() + items = ( + data_source.list_recent_activity(server_key=server_key, limit=limit) + if data_source is not None + else [] + ) + else: + items = list_recent_historical_matches( + limit=limit, + server_slug=server_key, + db_path=db_path, + ) closed_points = [ _parse_optional_timestamp(item.get("closed_at")) for item in items diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 71060b7..ddb43ef 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -373,12 +373,12 @@ def build_recent_historical_matches_payload( return { "status": "ok", "data": { - "title": "Actividad reciente capturada por RCON", + "title": "Actividad competitiva reciente capturada por RCON", "context": "historical-recent-matches", - "source": "rcon-historical-read-model", + "source": "rcon-historical-competitive-read-model", "historical_data_source": "rcon", "supported": True, - "coverage_basis": "prospective-rcon-samples", + "coverage_basis": "rcon-competitive-windows", "limit": limit, "server_slug": server_slug, **build_source_policy( @@ -520,8 +520,23 @@ def build_historical_server_summary_snapshot_payload( "source": "historical-precomputed-snapshots", "server_slug": server_slug, "found": snapshot is not None and isinstance(item, dict), - **_resolve_historical_fallback_policy( - fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + **( + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="server-summary-snapshot-served-by-rcon-competitive-model", + ) + ], + ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON and isinstance(item, dict) + else _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ) ), **_build_historical_snapshot_metadata(snapshot), "item": item if isinstance(item, dict) else None, @@ -661,8 +676,23 @@ def build_recent_historical_matches_snapshot_payload( **_build_historical_snapshot_metadata(snapshot), "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "limit": limit, - **_resolve_historical_fallback_policy( - fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + **( + build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source=SOURCE_KIND_RCON, + source_attempts=[ + build_source_attempt( + source=SOURCE_KIND_RCON, + role="primary", + status="success", + reason="recent-matches-snapshot-served-by-rcon-competitive-model", + ) + ], + ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON and sliced_items + else _resolve_historical_fallback_policy( + fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", + ) ), "items": sliced_items, }, @@ -856,9 +886,9 @@ def build_historical_server_summary_payload( else "Cobertura historica minima RCON agregada" ), "context": "historical-server-summary", - "source": "rcon-historical-read-model", + "source": "rcon-historical-competitive-read-model", "historical_data_source": "rcon", - "summary_basis": "prospective-rcon-samples", + "summary_basis": "rcon-competitive-windows", "server_slug": server_slug, "supported": True, **build_source_policy( @@ -957,6 +987,7 @@ def build_elo_mmr_player_payload( ) -> dict[str, object]: """Return one Elo/MMR player profile.""" profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id) + source_policy = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=1).get("source_policy") return { "status": "ok", "data": { @@ -966,10 +997,10 @@ def build_elo_mmr_player_payload( "player_id": player_id, "server_slug": server_id, "found": profile is not None, - **_resolve_historical_fallback_policy( + **(source_policy or _resolve_historical_fallback_policy( operation="elo-mmr-player", - fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet", - ), + fallback_reason="elo-mmr-player-source-policy-missing", + )), "profile": profile, }, } diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 4bb343a..f7396b7 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -7,8 +7,9 @@ from datetime import datetime, timezone from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name from .rcon_historical_storage import ( - list_rcon_historical_target_statuses, - list_recent_rcon_historical_samples, + find_rcon_historical_competitive_window, + list_rcon_historical_competitive_summary_rows, + list_rcon_historical_competitive_windows, ) @@ -16,8 +17,8 @@ def list_rcon_historical_server_summaries( *, server_key: str | None = None, ) -> list[dict[str, object]]: - """Return per-target coverage and freshness from prospective RCON storage.""" - items = list_rcon_historical_target_statuses() + """Return per-target coverage and freshness from RCON-backed competitive storage.""" + items = list_rcon_historical_competitive_summary_rows() if server_key and server_key != ALL_SERVERS_SLUG: normalized = server_key.strip() items = [ @@ -37,14 +38,37 @@ def list_rcon_historical_recent_activity( server_key: str | None = None, limit: int = 20, ) -> list[dict[str, object]]: - """Return recent persisted RCON activity samples for one or all targets.""" + """Return recent RCON-backed competitive windows for one or all targets.""" normalized_server_key = None if server_key == ALL_SERVERS_SLUG else server_key - items = list_recent_rcon_historical_samples(target_key=normalized_server_key, limit=limit) + items = list_rcon_historical_competitive_windows(target_key=normalized_server_key, limit=limit) return [ { - **item, - "current_map": normalize_map_name(item.get("current_map")), - "minutes_since_capture": _minutes_since_timestamp(item.get("captured_at")), + "server": { + "slug": item["target_key"], + "name": item["display_name"], + "external_server_id": item["external_server_id"], + "region": item["region"], + }, + "match_id": item["session_key"], + "started_at": item["first_seen_at"], + "ended_at": item["last_seen_at"], + "closed_at": item["last_seen_at"], + "map": { + "name": item.get("map_name"), + "pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")), + }, + "result": { + "allied_score": None, + "axis_score": None, + "winner": None, + }, + "player_count": int(round(float(item.get("average_players") or 0))), + "peak_players": item.get("peak_players"), + "sample_count": item.get("sample_count"), + "duration_seconds": item.get("duration_seconds"), + "capture_basis": "rcon-competitive-window", + "capabilities": item.get("capabilities"), + "minutes_since_capture": _minutes_since_timestamp(item.get("last_seen_at")), } for item in items ] @@ -53,7 +77,7 @@ def list_rcon_historical_recent_activity( def describe_rcon_historical_read_model() -> dict[str, object]: """Describe what the minimal RCON historical read model currently supports.""" return { - "source": "rcon-historical-read-model", + "source": "rcon-historical-competitive-read-model", "supported_endpoints": [ "/api/historical/server-summary", "/api/historical/recent-matches", @@ -70,27 +94,42 @@ def describe_rcon_historical_read_model() -> dict[str, object]: "/api/historical/player-profile", "/api/historical/snapshots/*", ], - "capabilities": [ - "coverage by configured RCON target", - "recent persisted live activity", - "freshness and last successful capture metadata", - ], + "capabilities": { + "server_summary": "exact", + "recent_matches": "approximate", + "competitive_quality": "partial", + "player_stats": "unavailable", + }, "limitations": [ "No retroactive backfill of closed matches.", "No weekly or monthly competitive leaderboards.", "No MVP or player-event parity with public-scoreboard.", - "No precomputed historical snapshots for the RCON read model yet.", + "No player-level scoreboard parity from RCON samples alone.", ], } +def get_rcon_historical_competitive_match_context( + *, + server_key: str, + ended_at: str | None, + map_name: str | None = None, +) -> dict[str, object] | None: + """Return the closest RCON-backed competitive context for one historical match.""" + return find_rcon_historical_competitive_window( + server_key=server_key, + ended_at=ended_at, + map_name=map_name, + ) + + def _build_server_summary(item: dict[str, object]) -> dict[str, object]: sample_count = int(item.get("sample_count") or 0) first_last_points = list_rcon_historical_recent_activity( server_key=str(item["target_key"]), limit=1, ) - last_sample_at = item.get("last_sample_at") + last_sample_at = item.get("last_seen_at") latest_activity = first_last_points[0] if first_last_points else None return { @@ -101,31 +140,32 @@ def _build_server_summary(item: dict[str, object]) -> dict[str, object]: "region": item["region"], }, "coverage": { - "basis": "prospective-rcon-samples", - "status": "available" if sample_count > 0 else "empty", + "basis": "rcon-competitive-windows", + "status": "available" if int(item.get("window_count") or 0) > 0 else "empty", + "window_count": int(item.get("window_count") or 0), "sample_count": sample_count, - "first_sample_at": item.get("first_sample_at"), + "first_sample_at": item.get("first_seen_at"), "last_sample_at": last_sample_at, - "coverage_hours": _calculate_coverage_hours(item.get("first_sample_at"), last_sample_at), + "coverage_hours": _calculate_coverage_hours(item.get("first_seen_at"), last_sample_at), }, "freshness": { "last_successful_capture_at": item.get("last_successful_capture_at"), "minutes_since_last_capture": _minutes_since_timestamp(last_sample_at), - "last_run_id": item.get("last_run_id"), "last_run_status": item.get("last_run_status"), "last_error": item.get("last_error"), "last_error_at": item.get("last_error_at"), }, "activity": { - "latest_players": latest_activity.get("players") if latest_activity else None, - "latest_max_players": latest_activity.get("max_players") if latest_activity else None, - "latest_map": latest_activity.get("current_map") if latest_activity else None, - "latest_status": latest_activity.get("status") if latest_activity else None, + "latest_players": latest_activity.get("player_count") if latest_activity else None, + "latest_peak_players": latest_activity.get("peak_players") if latest_activity else None, + "latest_map": latest_activity.get("map", {}).get("pretty_name") if latest_activity else None, + "latest_status": "captured" if latest_activity else None, }, "time_range": { - "start": None, + "start": item.get("first_seen_at"), "end": last_sample_at, }, + "capabilities": describe_rcon_historical_read_model()["capabilities"], } @@ -145,7 +185,7 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje "region": None, }, "coverage": { - "basis": "prospective-rcon-samples-aggregate", + "basis": "rcon-competitive-windows-aggregate", "status": "available" if total_samples > 0 else "empty", "sample_count": total_samples, "first_sample_at": None, @@ -155,7 +195,6 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje "freshness": { "last_successful_capture_at": last_capture_at, "minutes_since_last_capture": _minutes_since_timestamp(last_capture_at), - "last_run_id": None, "last_run_status": None, "last_error": None, "last_error_at": None, @@ -171,6 +210,7 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje "end": last_capture_at, }, "server_count": len(items), + "capabilities": describe_rcon_historical_read_model()["capabilities"], } diff --git a/backend/app/rcon_historical_storage.py b/backend/app/rcon_historical_storage.py index e036b69..ada0e9f 100644 --- a/backend/app/rcon_historical_storage.py +++ b/backend/app/rcon_historical_storage.py @@ -9,7 +9,14 @@ from datetime import datetime, timezone from pathlib import Path from .config import get_storage_path -from .sqlite_utils import connect_sqlite_writer +from .normalizers import normalize_map_name +from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer + + +COMPETITIVE_WINDOW_GAP_SECONDS = 1800 +COMPETITIVE_MODE_PARTIAL = "partial" +COMPETITIVE_MODE_APPROXIMATE = "approximate" +COMPETITIVE_MODE_EXACT = "exact" def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path: @@ -84,6 +91,32 @@ def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path: CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time ON rcon_historical_samples(target_id, captured_at DESC); + + CREATE TABLE IF NOT EXISTS rcon_historical_competitive_windows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, + 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 TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (target_id) REFERENCES rcon_historical_targets(id) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_historical_windows_target_time + ON rcon_historical_competitive_windows(target_id, last_seen_at DESC); """ ) @@ -199,6 +232,13 @@ def persist_rcon_historical_sample( 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, @@ -241,45 +281,48 @@ def list_rcon_historical_target_statuses( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return per-target coverage and freshness for prospective RCON capture.""" - resolved_path = initialize_rcon_historical_storage(db_path=db_path) - with _connect(resolved_path) as connection: - rows = connection.execute( - """ - SELECT - targets.target_key, - targets.external_server_id, - targets.display_name, - targets.host, - targets.port, - targets.region, - targets.source_name, - checkpoints.last_successful_capture_at, - checkpoints.last_sample_at, - checkpoints.last_run_id, - checkpoints.last_run_status, - checkpoints.last_error, - checkpoints.last_error_at, - ( - SELECT MIN(samples.captured_at) - FROM rcon_historical_samples AS samples - WHERE samples.target_id = targets.id - ) AS first_sample_at, - ( - SELECT MAX(samples.captured_at) - FROM rcon_historical_samples AS samples - WHERE samples.target_id = targets.id - ) AS latest_sample_at, - ( - SELECT COUNT(*) - FROM rcon_historical_samples AS samples - WHERE samples.target_id = targets.id - ) AS sample_count - FROM rcon_historical_targets AS targets - LEFT JOIN rcon_historical_checkpoints AS checkpoints - ON checkpoints.target_id = targets.id - ORDER BY targets.display_name ASC, targets.target_key ASC - """ - ).fetchall() + resolved_path = _resolve_db_path(db_path) + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + """ + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.host, + targets.port, + targets.region, + targets.source_name, + checkpoints.last_successful_capture_at, + checkpoints.last_sample_at, + checkpoints.last_run_id, + checkpoints.last_run_status, + checkpoints.last_error, + checkpoints.last_error_at, + ( + SELECT MIN(samples.captured_at) + FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id + ) AS first_sample_at, + ( + SELECT MAX(samples.captured_at) + FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id + ) AS latest_sample_at, + ( + SELECT COUNT(*) + FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id + ) AS sample_count + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints + ON checkpoints.target_id = targets.id + ORDER BY targets.display_name ASC, targets.target_key ASC + """ + ).fetchall() + except sqlite3.OperationalError: + return [] return [ { "target_key": row["target_key"], @@ -309,35 +352,38 @@ def list_recent_rcon_historical_samples( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return recent prospective RCON samples for one or all configured targets.""" - resolved_path = initialize_rcon_historical_storage(db_path=db_path) + resolved_path = _resolve_db_path(db_path) where_clause = "" params: list[object] = [limit] if target_key: where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?" params = [target_key, target_key, limit] - with _connect(resolved_path) as connection: - rows = connection.execute( - f""" - SELECT - targets.target_key, - targets.external_server_id, - targets.display_name, - targets.region, - samples.captured_at, - samples.status, - samples.players, - samples.max_players, - samples.current_map - FROM rcon_historical_samples AS samples - INNER JOIN rcon_historical_targets AS targets - ON targets.id = samples.target_id - {where_clause} - ORDER BY samples.captured_at DESC, targets.display_name ASC - LIMIT ? - """, - params, - ).fetchall() + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.region, + samples.captured_at, + samples.status, + samples.players, + samples.max_players, + samples.current_map + FROM rcon_historical_samples AS samples + INNER JOIN rcon_historical_targets AS targets + ON targets.id = samples.target_id + {where_clause} + ORDER BY samples.captured_at DESC, targets.display_name ASC + LIMIT ? + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return [] return [ { "target_key": row["target_key"], @@ -354,10 +400,235 @@ def list_recent_rcon_historical_samples( ] +def list_rcon_historical_competitive_windows( + *, + target_key: str | None = None, + limit: int = 20, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return recent RCON-backed competitive windows derived from persisted samples.""" + resolved_path = _resolve_db_path(db_path) + where_clause = "" + params: list[object] = [limit] + if target_key: + where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?" + params = [target_key, target_key, limit] + + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + 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 + 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 ? + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return [] + items: list[dict[str, object]] = [] + for row in rows: + sample_count = int(row["sample_count"] or 0) + average_players = round((int(row["total_players"] or 0) / sample_count), 2) if sample_count > 0 else 0.0 + items.append( + { + "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": average_players, + "peak_players": int(row["peak_players"] or 0), + "last_players": row["last_players"], + "max_players": row["max_players"], + "status": row["status"], + "confidence_mode": row["confidence_mode"], + "capabilities": _deserialize_json_object(row["capabilities_json"]), + } + ) + return items + + +def list_rcon_historical_competitive_summary_rows( + *, + target_key: str | None = None, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """Return RCON-backed per-target summary rows over competitive windows.""" + resolved_path = _resolve_db_path(db_path) + where_clause = "" + params: list[object] = [] + if target_key: + where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?" + params = [target_key, target_key] + + try: + with _connect_readonly(resolved_path) as connection: + rows = connection.execute( + 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 + ORDER BY targets.display_name ASC, targets.target_key ASC + """, + params, + ).fetchall() + except sqlite3.OperationalError: + return [] + return [ + { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "window_count": int(row["window_count"] or 0), + "sample_count": int(row["sample_count"] or 0), + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "peak_players": int(row["peak_players"] or 0), + "last_successful_capture_at": row["last_successful_capture_at"], + "last_run_status": row["last_run_status"], + "last_error": row["last_error"], + "last_error_at": row["last_error_at"], + } + for row in rows + ] + + +def find_rcon_historical_competitive_window( + *, + server_key: str, + ended_at: str | None, + map_name: str | None = None, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return the closest competitive window for one server/match if coverage exists.""" + if not ended_at: + return None + resolved_path = _resolve_db_path(db_path) + normalized_map_name = normalize_map_name(map_name) + try: + with _connect_readonly(resolved_path) as connection: + candidates = connection.execute( + """ + 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 + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets + ON targets.id = windows.target_id + WHERE (targets.target_key = ? OR targets.external_server_id = ?) + ORDER BY windows.last_seen_at DESC + LIMIT 12 + """, + (server_key, server_key), + ).fetchall() + except sqlite3.OperationalError: + return None + if not candidates: + return None + + ended_point = _parse_timestamp(ended_at) + best_row: sqlite3.Row | None = None + best_distance: float | None = None + for row in candidates: + row_map_name = normalize_map_name(row["map_pretty_name"] or row["map_name"]) + if normalized_map_name and row_map_name and normalized_map_name != row_map_name: + continue + row_last = _parse_timestamp(row["last_seen_at"]) + distance = abs((row_last - ended_point).total_seconds()) + if best_distance is None or distance < best_distance: + best_row = 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 _connect(db_path: Path) -> sqlite3.Connection: return connect_sqlite_writer(db_path) +def _connect_readonly(db_path: Path) -> sqlite3.Connection: + return connect_sqlite_readonly(db_path) + + +def _resolve_db_path(db_path: Path | None) -> Path: + return db_path or get_storage_path() + + def _upsert_target(connection: sqlite3.Connection, *, target: Mapping[str, object]) -> int: target_key = str(target.get("target_key") or "").strip() if not target_key: @@ -447,5 +718,158 @@ def _upsert_checkpoint_success( ) +def _upsert_competitive_window( + connection: sqlite3.Connection, + *, + 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 = ? + ORDER BY last_seen_at DESC, id DESC + LIMIT 1 + """, + (target_id,), + ).fetchone() + if latest_window and _should_extend_competitive_window( + latest_window=latest_window, + captured_at=captured_at, + current_map=current_map_raw, + ): + connection.execute( + """ + UPDATE rcon_historical_competitive_windows + SET map_name = ?, + map_pretty_name = ?, + last_seen_at = ?, + sample_count = sample_count + 1, + total_players = total_players + ?, + peak_players = CASE WHEN peak_players > ? THEN peak_players ELSE ? END, + last_players = ?, + max_players = ?, + status = ?, + confidence_mode = ?, + capabilities_json = ?, + latest_payload_json = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + current_map_raw, + map_pretty_name, + captured_at, + players, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), ensure_ascii=True, separators=(",", ":")), + json.dumps(dict(normalized_payload), ensure_ascii=True, separators=(",", ":")), + latest_window["id"], + ), + ) + return + + session_key = f"{target_id}:{captured_at}" + 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 (?, ?, 'rcon-historical-samples', ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + target_id, + session_key, + current_map_raw, + map_pretty_name, + captured_at, + captured_at, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), ensure_ascii=True, separators=(",", ":")), + json.dumps(dict(normalized_payload), ensure_ascii=True, separators=(",", ":")), + ), + ) + + +def _should_extend_competitive_window( + *, + latest_window: sqlite3.Row, + captured_at: str, + current_map: str, +) -> bool: + latest_map = str(latest_window["map_name"] or "").strip() + if normalize_map_name(latest_map) != normalize_map_name(current_map): + return False + latest_seen = _parse_timestamp(str(latest_window["last_seen_at"])) + captured_point = _parse_timestamp(captured_at) + 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, + "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 {} + if isinstance(parsed, dict): + return parsed + return {} + + +def _parse_timestamp(raw_value: str) -> datetime: + timestamp = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + return timestamp.astimezone(timezone.utc) + + +def _calculate_duration_seconds(first_seen_at: str | None, last_seen_at: str | None) -> int | None: + if not first_seen_at or not last_seen_at: + return None + return max(0, int((_parse_timestamp(last_seen_at) - _parse_timestamp(first_seen_at)).total_seconds())) + + def _utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/writer_lock.py b/backend/app/writer_lock.py index 682a3b9..8186ee3 100644 --- a/backend/app/writer_lock.py +++ b/backend/app/writer_lock.py @@ -25,6 +25,7 @@ class BackendWriterLockTimeoutError(RuntimeError): _ACTIVE_LOCK_DEPTH_BY_PATH: dict[Path, int] = {} _ACTIVE_LOCK_TOKEN_BY_PATH: dict[Path, str] = {} +CONTAINER_STALE_LOCK_GRACE_SECONDS = 300 def resolve_backend_writer_lock_path(*, storage_path: Path | None = None) -> Path: @@ -167,15 +168,27 @@ def _read_lock_metadata(lock_path: Path) -> dict[str, object] | None: def _can_clear_stale_lock(existing_metadata: dict[str, object] | None) -> bool: if not existing_metadata: return False - if str(existing_metadata.get("hostname") or "") != socket.gethostname(): - return False try: holder_pid = int(existing_metadata.get("pid")) except (TypeError, ValueError): return False if holder_pid <= 0: return False - return not _is_process_alive(holder_pid) + + holder_hostname = str(existing_metadata.get("hostname") or "").strip() + current_hostname = socket.gethostname() + if holder_hostname == current_hostname: + if _is_process_alive(holder_pid): + return False + return True + if not _looks_like_containerized_holder(existing_metadata): + return False + lock_age_seconds = _calculate_lock_age_seconds(existing_metadata) + if lock_age_seconds is None: + return False + if lock_age_seconds < CONTAINER_STALE_LOCK_GRACE_SECONDS: + return False + return True def _is_process_alive(pid: int) -> bool: @@ -219,5 +232,24 @@ def _build_lock_timeout_message( ) +def _looks_like_containerized_holder(existing_metadata: dict[str, object]) -> bool: + holder_cwd = str(existing_metadata.get("cwd") or "").strip().lower() + return holder_cwd.startswith("/app") + + +def _calculate_lock_age_seconds(existing_metadata: dict[str, object]) -> float | None: + started_at_raw = str(existing_metadata.get("started_at") or "").strip() + if not started_at_raw: + return None + try: + started_at = datetime.fromisoformat(started_at_raw.replace("Z", "+00:00")) + except ValueError: + return None + if started_at.tzinfo is None: + started_at = started_at.replace(tzinfo=timezone.utc) + delta = datetime.now(timezone.utc) - started_at.astimezone(timezone.utc) + return max(0.0, delta.total_seconds()) + + def _utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index 9ecab70..6833dc1 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -311,6 +311,49 @@ h2 { color: var(--accent-warm); } +.servers-loading, +.servers-empty { + display: grid; + justify-items: center; + gap: 14px; + width: 100%; + padding: 36px 24px; + border: 1px dashed rgba(183, 201, 125, 0.24); + border-radius: 18px; + background: linear-gradient(180deg, rgba(19, 24, 16, 0.72), rgba(10, 13, 9, 0.84)); + color: var(--text-soft); + text-align: center; +} + +.servers-loading__pulse { + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--accent-warm); + box-shadow: 0 0 0 rgba(210, 182, 118, 0.34); + animation: servers-loading-pulse 1.6s ease-in-out infinite; +} + +@keyframes servers-loading-pulse { + 0% { + transform: scale(0.9); + box-shadow: 0 0 0 0 rgba(210, 182, 118, 0.26); + opacity: 0.78; + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 16px rgba(210, 182, 118, 0); + opacity: 1; + } + + 100% { + transform: scale(0.92); + box-shadow: 0 0 0 0 rgba(210, 182, 118, 0); + opacity: 0.82; + } +} + .discord-button { display: inline-flex; align-items: center; diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 45f2294..4f2b0a2 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -93,6 +93,7 @@ document.addEventListener("DOMContentLoaded", () => { updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle"); setServersDataState(serversBadge, { timestampLabel: "" }); + renderServersLoadingState(serversList); hydrateCommunityClans(communityClansList); let serverRefreshInFlight = false; @@ -208,10 +209,28 @@ async function hydrateServers( const visibleItems = selectPrimaryServerItems(serversData.items); serversList.innerHTML = renderServerSections(visibleItems); } catch (error) { - console.warn("Servers panel remains on static fallback", error); + console.warn("Servers panel failed to hydrate with live data", error); + serversList.innerHTML = + '

No se pudo cargar el estado real de servidores en este momento.

'; + setServersDataState(serversBadge, { + label: "Actualizacion no disponible", + isFresh: false, + }); } } +function renderServersLoadingState(serversList) { + if (!serversList) { + return; + } + serversList.innerHTML = ` +
+ +

Cargando estado real de servidores...

+
+ `; +} + function updateBackendStatus(statusNode, label, stateClass) { if (!statusNode) { return; diff --git a/frontend/index.html b/frontend/index.html index 1d647ae..2d35b52 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -96,103 +96,11 @@ Abrir zona historico -
-
-
-
-

Servidor de comunidad

-

Comunidad Hispana #01

-
-
- Online -

74 / 100

- -
-
-
-
-

Mapa

- St. Marie Du Mont -
-
-

Region

- ES -
-
-
-
-
-
-

Servidor de comunidad

-

Comunidad Hispana #02

-
-
- Offline -

0 / 100

- -
-
-
-
-

Mapa

- Sin mapa disponible -
-
-

Region

- ES -
-
-
-
-
-
-

Servidor de comunidad

-

Comunidad Hispana #03

-
-
- Offline -

0 / 100

- -
-
-
-
-

Mapa

- Sin mapa disponible -
-
-

Region

- ES -
-
-
+
+
+ +

Cargando estado real de servidores...

+