Promote rcon historical model to primary
This commit is contained in:
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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
|
y ejecuta `python -m app.historical_runner --hourly` como bucle operativo
|
||||||
dedicado, sin mezclar el scheduler con el proceso HTTP principal.
|
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
|
## Coordinacion single-writer para automatizaciones y CLI
|
||||||
|
|
||||||
Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un
|
Todos los procesos writer-oriented que comparten el mismo SQLite usan ahora un
|
||||||
@@ -1224,6 +1230,10 @@ Comportamiento:
|
|||||||
- `started_at`
|
- `started_at`
|
||||||
- host
|
- host
|
||||||
- pid
|
- 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`
|
- la coordinacion principal es este single-writer lock; WAL y `busy_timeout`
|
||||||
quedan como endurecimiento complementario, no como solucion unica
|
quedan como endurecimiento complementario, no como solucion unica
|
||||||
|
|
||||||
@@ -1434,12 +1444,18 @@ Metadata observable en payloads historicos:
|
|||||||
|
|
||||||
Estado real a fecha de esta fase:
|
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
|
- `historical_ingestion` intenta primero una captura writer-oriented por RCON y
|
||||||
deja esa tentativa visible en su salida
|
deja esa tentativa visible en su salida
|
||||||
- rankings competitivos, MVP y Elo/MMR siguen necesitando fallback a
|
- leaderboards semanales/mensuales, MVP V1/V2 y player-events siguen teniendo
|
||||||
`public-scoreboard` porque el read model RCON actual no expone aun detalle
|
fallback a `public-scoreboard` mientras RCON no disponga de señal competitiva
|
||||||
historico competitivo suficiente
|
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
|
## Elo/MMR Monthly Ranking
|
||||||
|
|
||||||
@@ -1467,6 +1483,17 @@ Politica de exactitud:
|
|||||||
- `approximate`: role bucket, objective index, strength of schedule
|
- `approximate`: role bucket, objective index, strength of schedule
|
||||||
- `not_available`: leadership y tacticas finas no persistidas
|
- `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:
|
La especificacion detallada y el mapa de capabilities quedan en:
|
||||||
|
|
||||||
- `docs/elo-mmr-monthly-ranking-design.md`
|
- `docs/elo-mmr-monthly-ranking-design.md`
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class RconFirstLiveDataSource:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RconHistoricalDataSource:
|
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
|
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]]:
|
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)
|
return list_rcon_historical_server_summaries(server_key=server_key)
|
||||||
|
|
||||||
def list_recent_activity(
|
def list_recent_activity(
|
||||||
@@ -209,7 +209,7 @@ class RconHistoricalDataSource:
|
|||||||
server_key: str | None = None,
|
server_key: str | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
) -> list[dict[str, object]]:
|
) -> 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)
|
return list_rcon_historical_recent_activity(server_key=server_key, limit=limit)
|
||||||
|
|
||||||
def describe_capabilities(self) -> dict[str, object]:
|
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:
|
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:
|
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
|
||||||
return None
|
return None
|
||||||
return RconHistoricalDataSource()
|
return RconHistoricalDataSource()
|
||||||
@@ -258,8 +258,8 @@ def describe_historical_runtime_policy() -> dict[str, object]:
|
|||||||
"primary_source": SOURCE_KIND_RCON,
|
"primary_source": SOURCE_KIND_RCON,
|
||||||
"fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
|
"fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
"summary": (
|
"summary": (
|
||||||
"Historical runtime attempts the persisted RCON read model first and falls "
|
"Historical runtime attempts the persisted RCON-backed competitive model first "
|
||||||
"back to public-scoreboard when the requested operation is unsupported, has "
|
"and falls back to public-scoreboard when the requested operation is unsupported, has "
|
||||||
"no coverage yet, or the primary path fails."
|
"no coverage yet, or the primary path fails."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .data_sources import (
|
|||||||
SOURCE_KIND_RCON,
|
SOURCE_KIND_RCON,
|
||||||
build_source_attempt,
|
build_source_attempt,
|
||||||
build_source_policy,
|
build_source_policy,
|
||||||
|
get_rcon_historical_read_model,
|
||||||
)
|
)
|
||||||
from .elo_mmr_models import (
|
from .elo_mmr_models import (
|
||||||
CAPABILITY_APPROXIMATE,
|
CAPABILITY_APPROXIMATE,
|
||||||
@@ -39,6 +40,7 @@ from .elo_mmr_storage import (
|
|||||||
replace_elo_mmr_state,
|
replace_elo_mmr_state,
|
||||||
)
|
)
|
||||||
from .historical_storage import ALL_SERVERS_SLUG, initialize_historical_storage
|
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 .sqlite_utils import connect_sqlite_readonly
|
||||||
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
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)
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||||
initialize_elo_mmr_storage(db_path=resolved_path)
|
initialize_elo_mmr_storage(db_path=resolved_path)
|
||||||
historical_source_policy = _build_historical_source_policy_for_elo()
|
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)
|
match_rows = _load_closed_match_rows(db_path=resolved_path)
|
||||||
grouped_matches = _group_match_rows(match_rows)
|
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,
|
match_group=match_group,
|
||||||
scope_key=scope_key,
|
scope_key=scope_key,
|
||||||
ratings_by_scope=ratings_by_scope[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],
|
match_group: dict[str, object],
|
||||||
scope_key: str,
|
scope_key: str,
|
||||||
ratings_by_scope: dict[str, dict[str, object]],
|
ratings_by_scope: dict[str, dict[str, object]],
|
||||||
|
rcon_match_context: dict[str, object] | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
players = list(match_group["players"])
|
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(
|
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,
|
duration_seconds=duration_seconds,
|
||||||
has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None,
|
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."))
|
signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps."))
|
||||||
else:
|
else:
|
||||||
signals.append(build_signal("quality_duration", CAPABILITY_APPROXIMATE, "Duration approximated from the maximum persisted player time."))
|
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])
|
weights = ROLE_WEIGHTS.get(role_bucket, ROLE_WEIGHTS[ROLE_BUCKET_GENERALIST])
|
||||||
impact_score = round(
|
impact_score = round(
|
||||||
@@ -541,17 +568,34 @@ def _build_historical_source_policy_for_elo() -> dict[str, object]:
|
|||||||
)
|
)
|
||||||
return build_source_policy(
|
return build_source_policy(
|
||||||
primary_source=SOURCE_KIND_RCON,
|
primary_source=SOURCE_KIND_RCON,
|
||||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
selected_source="hybrid-rcon-competitive-plus-public-scoreboard",
|
||||||
fallback_used=True,
|
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=[
|
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(
|
||||||
build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="fallback", status="success"),
|
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"))
|
started_at = _parse_optional_timestamp(match_group.get("started_at"))
|
||||||
ended_at = _parse_optional_timestamp(match_group.get("ended_at"))
|
ended_at = _parse_optional_timestamp(match_group.get("ended_at"))
|
||||||
if started_at and ended_at and ended_at >= started_at:
|
if started_at and ended_at and ended_at >= started_at:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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 (
|
from .historical_storage import (
|
||||||
ALL_SERVERS_SLUG,
|
ALL_SERVERS_SLUG,
|
||||||
list_historical_server_summaries,
|
list_historical_server_summaries,
|
||||||
@@ -364,6 +366,14 @@ def _build_server_summary_snapshot(
|
|||||||
*,
|
*,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
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_items = list_historical_server_summaries(server_slug=server_key, db_path=db_path)
|
||||||
summary_item = summary_items[0] if summary_items else {}
|
summary_item = summary_items[0] if summary_items else {}
|
||||||
time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {}
|
time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {}
|
||||||
@@ -457,6 +467,14 @@ def _build_recent_matches_snapshot(
|
|||||||
limit: int,
|
limit: int,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
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(
|
items = list_recent_historical_matches(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
server_slug=server_key,
|
server_slug=server_key,
|
||||||
|
|||||||
@@ -373,12 +373,12 @@ def build_recent_historical_matches_payload(
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Actividad reciente capturada por RCON",
|
"title": "Actividad competitiva reciente capturada por RCON",
|
||||||
"context": "historical-recent-matches",
|
"context": "historical-recent-matches",
|
||||||
"source": "rcon-historical-read-model",
|
"source": "rcon-historical-competitive-read-model",
|
||||||
"historical_data_source": "rcon",
|
"historical_data_source": "rcon",
|
||||||
"supported": True,
|
"supported": True,
|
||||||
"coverage_basis": "prospective-rcon-samples",
|
"coverage_basis": "rcon-competitive-windows",
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"server_slug": server_slug,
|
"server_slug": server_slug,
|
||||||
**build_source_policy(
|
**build_source_policy(
|
||||||
@@ -520,8 +520,23 @@ def build_historical_server_summary_snapshot_payload(
|
|||||||
"source": "historical-precomputed-snapshots",
|
"source": "historical-precomputed-snapshots",
|
||||||
"server_slug": server_slug,
|
"server_slug": server_slug,
|
||||||
"found": snapshot is not None and isinstance(item, dict),
|
"found": snapshot is not None and isinstance(item, dict),
|
||||||
**_resolve_historical_fallback_policy(
|
**(
|
||||||
|
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",
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
)
|
||||||
),
|
),
|
||||||
**_build_historical_snapshot_metadata(snapshot),
|
**_build_historical_snapshot_metadata(snapshot),
|
||||||
"item": item if isinstance(item, dict) else None,
|
"item": item if isinstance(item, dict) else None,
|
||||||
@@ -661,8 +676,23 @@ def build_recent_historical_matches_snapshot_payload(
|
|||||||
**_build_historical_snapshot_metadata(snapshot),
|
**_build_historical_snapshot_metadata(snapshot),
|
||||||
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
**_resolve_historical_fallback_policy(
|
**(
|
||||||
|
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",
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
},
|
},
|
||||||
@@ -856,9 +886,9 @@ def build_historical_server_summary_payload(
|
|||||||
else "Cobertura historica minima RCON agregada"
|
else "Cobertura historica minima RCON agregada"
|
||||||
),
|
),
|
||||||
"context": "historical-server-summary",
|
"context": "historical-server-summary",
|
||||||
"source": "rcon-historical-read-model",
|
"source": "rcon-historical-competitive-read-model",
|
||||||
"historical_data_source": "rcon",
|
"historical_data_source": "rcon",
|
||||||
"summary_basis": "prospective-rcon-samples",
|
"summary_basis": "rcon-competitive-windows",
|
||||||
"server_slug": server_slug,
|
"server_slug": server_slug,
|
||||||
"supported": True,
|
"supported": True,
|
||||||
**build_source_policy(
|
**build_source_policy(
|
||||||
@@ -957,6 +987,7 @@ def build_elo_mmr_player_payload(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one Elo/MMR player profile."""
|
"""Return one Elo/MMR player profile."""
|
||||||
profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id)
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -966,10 +997,10 @@ def build_elo_mmr_player_payload(
|
|||||||
"player_id": player_id,
|
"player_id": player_id,
|
||||||
"server_slug": server_id,
|
"server_slug": server_id,
|
||||||
"found": profile is not None,
|
"found": profile is not None,
|
||||||
**_resolve_historical_fallback_policy(
|
**(source_policy or _resolve_historical_fallback_policy(
|
||||||
operation="elo-mmr-player",
|
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,
|
"profile": profile,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from datetime import datetime, timezone
|
|||||||
from .historical_storage import ALL_SERVERS_SLUG
|
from .historical_storage import ALL_SERVERS_SLUG
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
from .rcon_historical_storage import (
|
from .rcon_historical_storage import (
|
||||||
list_rcon_historical_target_statuses,
|
find_rcon_historical_competitive_window,
|
||||||
list_recent_rcon_historical_samples,
|
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,
|
server_key: str | None = None,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
"""Return per-target coverage and freshness from prospective RCON storage."""
|
"""Return per-target coverage and freshness from RCON-backed competitive storage."""
|
||||||
items = list_rcon_historical_target_statuses()
|
items = list_rcon_historical_competitive_summary_rows()
|
||||||
if server_key and server_key != ALL_SERVERS_SLUG:
|
if server_key and server_key != ALL_SERVERS_SLUG:
|
||||||
normalized = server_key.strip()
|
normalized = server_key.strip()
|
||||||
items = [
|
items = [
|
||||||
@@ -37,14 +38,37 @@ def list_rcon_historical_recent_activity(
|
|||||||
server_key: str | None = None,
|
server_key: str | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
) -> list[dict[str, object]]:
|
) -> 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
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
**item,
|
"server": {
|
||||||
"current_map": normalize_map_name(item.get("current_map")),
|
"slug": item["target_key"],
|
||||||
"minutes_since_capture": _minutes_since_timestamp(item.get("captured_at")),
|
"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
|
for item in items
|
||||||
]
|
]
|
||||||
@@ -53,7 +77,7 @@ def list_rcon_historical_recent_activity(
|
|||||||
def describe_rcon_historical_read_model() -> dict[str, object]:
|
def describe_rcon_historical_read_model() -> dict[str, object]:
|
||||||
"""Describe what the minimal RCON historical read model currently supports."""
|
"""Describe what the minimal RCON historical read model currently supports."""
|
||||||
return {
|
return {
|
||||||
"source": "rcon-historical-read-model",
|
"source": "rcon-historical-competitive-read-model",
|
||||||
"supported_endpoints": [
|
"supported_endpoints": [
|
||||||
"/api/historical/server-summary",
|
"/api/historical/server-summary",
|
||||||
"/api/historical/recent-matches",
|
"/api/historical/recent-matches",
|
||||||
@@ -70,27 +94,42 @@ def describe_rcon_historical_read_model() -> dict[str, object]:
|
|||||||
"/api/historical/player-profile",
|
"/api/historical/player-profile",
|
||||||
"/api/historical/snapshots/*",
|
"/api/historical/snapshots/*",
|
||||||
],
|
],
|
||||||
"capabilities": [
|
"capabilities": {
|
||||||
"coverage by configured RCON target",
|
"server_summary": "exact",
|
||||||
"recent persisted live activity",
|
"recent_matches": "approximate",
|
||||||
"freshness and last successful capture metadata",
|
"competitive_quality": "partial",
|
||||||
],
|
"player_stats": "unavailable",
|
||||||
|
},
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"No retroactive backfill of closed matches.",
|
"No retroactive backfill of closed matches.",
|
||||||
"No weekly or monthly competitive leaderboards.",
|
"No weekly or monthly competitive leaderboards.",
|
||||||
"No MVP or player-event parity with public-scoreboard.",
|
"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]:
|
def _build_server_summary(item: dict[str, object]) -> dict[str, object]:
|
||||||
sample_count = int(item.get("sample_count") or 0)
|
sample_count = int(item.get("sample_count") or 0)
|
||||||
first_last_points = list_rcon_historical_recent_activity(
|
first_last_points = list_rcon_historical_recent_activity(
|
||||||
server_key=str(item["target_key"]),
|
server_key=str(item["target_key"]),
|
||||||
limit=1,
|
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
|
latest_activity = first_last_points[0] if first_last_points else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,31 +140,32 @@ def _build_server_summary(item: dict[str, object]) -> dict[str, object]:
|
|||||||
"region": item["region"],
|
"region": item["region"],
|
||||||
},
|
},
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"basis": "prospective-rcon-samples",
|
"basis": "rcon-competitive-windows",
|
||||||
"status": "available" if sample_count > 0 else "empty",
|
"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,
|
"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,
|
"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": {
|
"freshness": {
|
||||||
"last_successful_capture_at": item.get("last_successful_capture_at"),
|
"last_successful_capture_at": item.get("last_successful_capture_at"),
|
||||||
"minutes_since_last_capture": _minutes_since_timestamp(last_sample_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_run_status": item.get("last_run_status"),
|
||||||
"last_error": item.get("last_error"),
|
"last_error": item.get("last_error"),
|
||||||
"last_error_at": item.get("last_error_at"),
|
"last_error_at": item.get("last_error_at"),
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
"latest_players": latest_activity.get("players") if latest_activity else None,
|
"latest_players": latest_activity.get("player_count") if latest_activity else None,
|
||||||
"latest_max_players": latest_activity.get("max_players") if latest_activity else None,
|
"latest_peak_players": latest_activity.get("peak_players") if latest_activity else None,
|
||||||
"latest_map": latest_activity.get("current_map") if latest_activity else None,
|
"latest_map": latest_activity.get("map", {}).get("pretty_name") if latest_activity else None,
|
||||||
"latest_status": latest_activity.get("status") if latest_activity else None,
|
"latest_status": "captured" if latest_activity else None,
|
||||||
},
|
},
|
||||||
"time_range": {
|
"time_range": {
|
||||||
"start": None,
|
"start": item.get("first_seen_at"),
|
||||||
"end": last_sample_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,
|
"region": None,
|
||||||
},
|
},
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"basis": "prospective-rcon-samples-aggregate",
|
"basis": "rcon-competitive-windows-aggregate",
|
||||||
"status": "available" if total_samples > 0 else "empty",
|
"status": "available" if total_samples > 0 else "empty",
|
||||||
"sample_count": total_samples,
|
"sample_count": total_samples,
|
||||||
"first_sample_at": None,
|
"first_sample_at": None,
|
||||||
@@ -155,7 +195,6 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje
|
|||||||
"freshness": {
|
"freshness": {
|
||||||
"last_successful_capture_at": last_capture_at,
|
"last_successful_capture_at": last_capture_at,
|
||||||
"minutes_since_last_capture": _minutes_since_timestamp(last_capture_at),
|
"minutes_since_last_capture": _minutes_since_timestamp(last_capture_at),
|
||||||
"last_run_id": None,
|
|
||||||
"last_run_status": None,
|
"last_run_status": None,
|
||||||
"last_error": None,
|
"last_error": None,
|
||||||
"last_error_at": 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,
|
"end": last_capture_at,
|
||||||
},
|
},
|
||||||
"server_count": len(items),
|
"server_count": len(items),
|
||||||
|
"capabilities": describe_rcon_historical_read_model()["capabilities"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ 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
|
||||||
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:
|
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
|
CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time
|
||||||
ON rcon_historical_samples(target_id, captured_at DESC);
|
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,
|
run_id=run_id,
|
||||||
captured_at=captured_at,
|
captured_at=captured_at,
|
||||||
)
|
)
|
||||||
|
if inserted:
|
||||||
|
_upsert_competitive_window(
|
||||||
|
connection,
|
||||||
|
target_id=target_id,
|
||||||
|
captured_at=captured_at,
|
||||||
|
normalized_payload=normalized_payload,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"samples_inserted": inserted,
|
"samples_inserted": inserted,
|
||||||
"duplicate_samples": 0 if inserted else 1,
|
"duplicate_samples": 0 if inserted else 1,
|
||||||
@@ -241,8 +281,9 @@ 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."""
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
with _connect(resolved_path) as connection:
|
try:
|
||||||
|
with _connect_readonly(resolved_path) as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@@ -280,6 +321,8 @@ def list_rcon_historical_target_statuses(
|
|||||||
ORDER BY targets.display_name ASC, targets.target_key ASC
|
ORDER BY targets.display_name ASC, targets.target_key ASC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"target_key": row["target_key"],
|
"target_key": row["target_key"],
|
||||||
@@ -309,14 +352,15 @@ 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."""
|
||||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
resolved_path = _resolve_db_path(db_path)
|
||||||
where_clause = ""
|
where_clause = ""
|
||||||
params: list[object] = [limit]
|
params: list[object] = [limit]
|
||||||
if target_key:
|
if target_key:
|
||||||
where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?"
|
where_clause = "WHERE targets.target_key = ? OR targets.external_server_id = ?"
|
||||||
params = [target_key, target_key, limit]
|
params = [target_key, target_key, limit]
|
||||||
|
|
||||||
with _connect(resolved_path) as connection:
|
try:
|
||||||
|
with _connect_readonly(resolved_path) as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
@@ -338,6 +382,8 @@ def list_recent_rcon_historical_samples(
|
|||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"target_key": row["target_key"],
|
"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:
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||||
return connect_sqlite_writer(db_path)
|
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:
|
def _upsert_target(connection: sqlite3.Connection, *, target: Mapping[str, object]) -> int:
|
||||||
target_key = str(target.get("target_key") or "").strip()
|
target_key = str(target.get("target_key") or "").strip()
|
||||||
if not target_key:
|
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:
|
def _utc_now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class BackendWriterLockTimeoutError(RuntimeError):
|
|||||||
|
|
||||||
_ACTIVE_LOCK_DEPTH_BY_PATH: dict[Path, int] = {}
|
_ACTIVE_LOCK_DEPTH_BY_PATH: dict[Path, int] = {}
|
||||||
_ACTIVE_LOCK_TOKEN_BY_PATH: dict[Path, str] = {}
|
_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:
|
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:
|
def _can_clear_stale_lock(existing_metadata: dict[str, object] | None) -> bool:
|
||||||
if not existing_metadata:
|
if not existing_metadata:
|
||||||
return False
|
return False
|
||||||
if str(existing_metadata.get("hostname") or "") != socket.gethostname():
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
holder_pid = int(existing_metadata.get("pid"))
|
holder_pid = int(existing_metadata.get("pid"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return False
|
return False
|
||||||
if holder_pid <= 0:
|
if holder_pid <= 0:
|
||||||
return False
|
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:
|
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:
|
def _utc_now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|||||||
@@ -311,6 +311,49 @@ h2 {
|
|||||||
color: var(--accent-warm);
|
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 {
|
.discord-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle");
|
updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle");
|
||||||
setServersDataState(serversBadge, { timestampLabel: "" });
|
setServersDataState(serversBadge, { timestampLabel: "" });
|
||||||
|
renderServersLoadingState(serversList);
|
||||||
hydrateCommunityClans(communityClansList);
|
hydrateCommunityClans(communityClansList);
|
||||||
|
|
||||||
let serverRefreshInFlight = false;
|
let serverRefreshInFlight = false;
|
||||||
@@ -208,10 +209,28 @@ async function hydrateServers(
|
|||||||
const visibleItems = selectPrimaryServerItems(serversData.items);
|
const visibleItems = selectPrimaryServerItems(serversData.items);
|
||||||
serversList.innerHTML = renderServerSections(visibleItems);
|
serversList.innerHTML = renderServerSections(visibleItems);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Servers panel remains on static fallback", error);
|
console.warn("Servers panel failed to hydrate with live data", error);
|
||||||
|
serversList.innerHTML =
|
||||||
|
'<p class="servers-empty">No se pudo cargar el estado real de servidores en este momento.</p>';
|
||||||
|
setServersDataState(serversBadge, {
|
||||||
|
label: "Actualizacion no disponible",
|
||||||
|
isFresh: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderServersLoadingState(serversList) {
|
||||||
|
if (!serversList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serversList.innerHTML = `
|
||||||
|
<div class="servers-loading">
|
||||||
|
<span class="servers-loading__pulse"></span>
|
||||||
|
<p>Cargando estado real de servidores...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function updateBackendStatus(statusNode, label, stateClass) {
|
function updateBackendStatus(statusNode, label, stateClass) {
|
||||||
if (!statusNode) {
|
if (!statusNode) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -96,103 +96,11 @@
|
|||||||
Abrir zona historico
|
Abrir zona historico
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="servers-grid" id="servers-list">
|
<div class="servers-grid" id="servers-list" aria-live="polite">
|
||||||
<article class="server-card server-card--stats server-card--real">
|
<div class="servers-loading" id="servers-loading-state">
|
||||||
<div class="server-card__top server-card__top--stats">
|
<span class="servers-loading__pulse"></span>
|
||||||
<div class="server-card__identity">
|
<p>Cargando estado real de servidores...</p>
|
||||||
<p class="server-card__eyebrow">Servidor de comunidad</p>
|
|
||||||
<h3>Comunidad Hispana #01</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="server-card__status-column">
|
|
||||||
<span class="server-state server-state--online">Online</span>
|
|
||||||
<p class="server-card__population">74 / 100</p>
|
|
||||||
<div class="server-card__actions">
|
|
||||||
<a
|
|
||||||
class="server-action-link"
|
|
||||||
href="https://scoreboard.comunidadhll.es/games"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Historico
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="server-card__quickfacts">
|
|
||||||
<article class="server-card__quickfact">
|
|
||||||
<p>Mapa</p>
|
|
||||||
<strong>St. Marie Du Mont</strong>
|
|
||||||
</article>
|
|
||||||
<article class="server-card__quickfact">
|
|
||||||
<p>Region</p>
|
|
||||||
<strong>ES</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="server-card server-card--stats server-card--real">
|
|
||||||
<div class="server-card__top server-card__top--stats">
|
|
||||||
<div class="server-card__identity">
|
|
||||||
<p class="server-card__eyebrow">Servidor de comunidad</p>
|
|
||||||
<h3>Comunidad Hispana #02</h3>
|
|
||||||
</div>
|
|
||||||
<div class="server-card__status-column">
|
|
||||||
<span class="server-state server-state--offline">Offline</span>
|
|
||||||
<p class="server-card__population">0 / 100</p>
|
|
||||||
<div class="server-card__actions">
|
|
||||||
<a
|
|
||||||
class="server-action-link"
|
|
||||||
href="https://scoreboard.comunidadhll.es:5443/games"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Historico
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="server-card__quickfacts">
|
|
||||||
<article class="server-card__quickfact">
|
|
||||||
<p>Mapa</p>
|
|
||||||
<strong>Sin mapa disponible</strong>
|
|
||||||
</article>
|
|
||||||
<article class="server-card__quickfact">
|
|
||||||
<p>Region</p>
|
|
||||||
<strong>ES</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="server-card server-card--stats server-card--real">
|
|
||||||
<div class="server-card__top server-card__top--stats">
|
|
||||||
<div class="server-card__identity">
|
|
||||||
<p class="server-card__eyebrow">Servidor de comunidad</p>
|
|
||||||
<h3>Comunidad Hispana #03</h3>
|
|
||||||
</div>
|
|
||||||
<div class="server-card__status-column">
|
|
||||||
<span class="server-state server-state--offline">Offline</span>
|
|
||||||
<p class="server-card__population">0 / 100</p>
|
|
||||||
<div class="server-card__actions">
|
|
||||||
<a
|
|
||||||
class="server-action-link"
|
|
||||||
href="https://scoreboard.comunidadhll.es:3443/games"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Historico
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="server-card__quickfacts">
|
|
||||||
<article class="server-card__quickfact">
|
|
||||||
<p>Mapa</p>
|
|
||||||
<strong>Sin mapa disponible</strong>
|
|
||||||
</article>
|
|
||||||
<article class="server-card__quickfact">
|
|
||||||
<p>Region</p>
|
|
||||||
<strong>ES</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user