Make backend RCON-first with safe fallback
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
# TASK-093-rcon-first-source-selection-and-fallback-policy
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Convertir la aplicación a una política RCON-first real para extracción de datos, manteniendo fallback automático a los métodos antiguos solo cuando RCON falle.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La repo ya tiene piezas RCON para live y captura prospectiva, pero todavía no está orientada a RCON-first por defecto.
|
||||||
|
Ahora mismo el sistema sigue arrancando con defaults antiguos y la política funcional real no coincide con el objetivo del producto.
|
||||||
|
|
||||||
|
Queremos:
|
||||||
|
- live/state de servidores -> RCON primero, A2S solo como fallback
|
||||||
|
- histórico/recopilación -> RCON primero, CRCON/public-scoreboard solo como fallback
|
||||||
|
- selección de fuente transparente, consistente y observable
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Auditar la selección actual de fuentes en:
|
||||||
|
- `backend/app/data_sources.py`
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `backend/app/collector.py`
|
||||||
|
- providers live e históricos actuales
|
||||||
|
- `backend/app/rcon_historical_read_model.py`
|
||||||
|
2. Introducir una política explícita de “source arbitration” o equivalente:
|
||||||
|
- RCON como fuente primaria
|
||||||
|
- fallback a A2S para live si RCON falla
|
||||||
|
- fallback a public-scoreboard/CRCON para histórico si RCON falla o no puede servir la operación concreta
|
||||||
|
3. Definir criterios claros de fallback:
|
||||||
|
- error de red / timeout / auth / target no disponible
|
||||||
|
- falta de cobertura o capacidad para una operación histórica concreta
|
||||||
|
4. Hacer que la respuesta backend deje trazabilidad clara:
|
||||||
|
- fuente primaria intentada
|
||||||
|
- fuente finalmente usada
|
||||||
|
- si hubo fallback
|
||||||
|
- motivo del fallback
|
||||||
|
5. Ajustar defaults/config para que el comportamiento por defecto del proyecto sea coherente con RCON-first.
|
||||||
|
6. Actualizar README con una sección clara:
|
||||||
|
- política de prioridad de fuentes
|
||||||
|
- casos de fallback
|
||||||
|
- qué capacidades históricas siguen siendo parciales en RCON y cuándo entra CRCON/public-scoreboard
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No romper compatibilidad con los métodos antiguos.
|
||||||
|
- No eliminar A2S ni public-scoreboard.
|
||||||
|
- No tocar frontend salvo que haga falta exponer metadata mínima ya existente.
|
||||||
|
- Mantener el comportamiento observable y fácil de depurar.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- El backend intenta RCON primero para live.
|
||||||
|
- Si RCON live falla, el backend cae a A2S de forma controlada.
|
||||||
|
- El backend intenta RCON primero para histórico.
|
||||||
|
- Si RCON histórico falla o no soporta una operación concreta, el backend cae a public-scoreboard/CRCON.
|
||||||
|
- Las respuestas reflejan qué fuente se usó realmente.
|
||||||
|
- README queda alineado con la política RCON-first.
|
||||||
|
|
||||||
|
## Expected Files
|
||||||
|
- `backend/app/data_sources.py`
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `backend/app/collector.py`
|
||||||
|
- providers/fuentes necesarias bajo `backend/app/`
|
||||||
|
- `backend/README.md`
|
||||||
|
- `backend/app/config.py` si hace falta para defaults explícitos
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# TASK-094-rcon-first-historical-orchestration-and-safe-fallback
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Reorientar la orquestación histórica para que RCON sea la vía principal de recopilación cuando esté disponible, dejando el flujo CRCON/public-scoreboard como fallback seguro y no como camino primario permanente.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Actualmente:
|
||||||
|
- existe `historical_runner`
|
||||||
|
- existe `rcon_historical_worker`
|
||||||
|
- existe locking single-writer
|
||||||
|
Pero la orquestación no representa todavía una estrategia RCON-first coherente.
|
||||||
|
Queremos una orquestación donde:
|
||||||
|
- la recopilación prospectiva por RCON sea la prioridad
|
||||||
|
- el refresh histórico clásico solo entre cuando RCON falle o no cubra la operación
|
||||||
|
- no haya starvation del lock por loops demasiado agresivos
|
||||||
|
- las automatizaciones sean operables en Docker sin bloquear trabajo manual innecesariamente
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Auditar:
|
||||||
|
- `backend/app/historical_runner.py`
|
||||||
|
- `backend/app/historical_ingestion.py`
|
||||||
|
- `backend/app/rcon_historical_worker.py`
|
||||||
|
- `backend/app/writer_lock.py`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
2. Definir una estrategia clara:
|
||||||
|
- RCON historical capture como flujo primario
|
||||||
|
- historical_ingestion clásico como fallback
|
||||||
|
- snapshots/rebuilds alineados con esa política
|
||||||
|
3. Evitar que el worker RCON monopolice el writer lock:
|
||||||
|
- revisar intervalos por defecto
|
||||||
|
- revisar duración del trabajo por loop
|
||||||
|
- revisar si conviene separar captura frecuente y rebuild menos frecuente
|
||||||
|
4. Asegurar que las pasadas manuales sean razonables:
|
||||||
|
- si hay automatización activa, que el operador tenga mensajes claros
|
||||||
|
- reducir riesgo de starvation o lock ocupado permanente
|
||||||
|
5. Mejorar el manejo de stale locks entre contenedores Docker si es necesario:
|
||||||
|
- no depender solo del hostname si eso da falsos locks persistentes
|
||||||
|
- mantener seguridad y evitar liberar locks válidos por error
|
||||||
|
6. Ajustar `docker-compose.yml` para que el stack quede alineado con el nuevo comportamiento por defecto.
|
||||||
|
7. Actualizar README/runbook:
|
||||||
|
- qué proceso captura RCON primero
|
||||||
|
- cuándo entra el fallback histórico clásico
|
||||||
|
- cómo lanzar pasadas manuales
|
||||||
|
- cómo interpretar locks ocupados
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No eliminar el locking compartido.
|
||||||
|
- No volver al comportamiento sin coordinación de writers.
|
||||||
|
- No dejar CRCON/public-scoreboard como camino principal encubierto.
|
||||||
|
- No romper la captura prospectiva RCON ya existente.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- La automatización prioriza RCON para recopilación histórica.
|
||||||
|
- El flujo clásico entra solo como fallback.
|
||||||
|
- El lock compartido sigue funcionando.
|
||||||
|
- La operativa Docker no queda en starvation constante.
|
||||||
|
- Las pasadas manuales tienen comportamiento claro y documentado.
|
||||||
|
- README/runbook queda actualizado.
|
||||||
|
|
||||||
|
## Expected Files
|
||||||
|
- `backend/app/historical_runner.py`
|
||||||
|
- `backend/app/historical_ingestion.py`
|
||||||
|
- `backend/app/rcon_historical_worker.py`
|
||||||
|
- `backend/app/writer_lock.py`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `backend/README.md`
|
||||||
|
- otros archivos backend si la orquestación lo requiere
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# TASK-095-restore-community-scores-link-and-source-aware-ui
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Restaurar correctamente el enlace/botón hacia los scores/histórico de comunidad y asegurar que la UI no pierda acciones útiles por depender de mapas hardcodeados incompletos.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Se ha detectado que el botón/enlace hacia los scores/histórico de comunidad ha desaparecido o se degrada en algunos casos.
|
||||||
|
La lógica actual depende de un mapa hardcodeado incompleto y no cubre bien todos los servidores o casos reales.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Auditar:
|
||||||
|
- `frontend/index.html`
|
||||||
|
- `frontend/assets/js/main.js`
|
||||||
|
- `frontend/assets/css/styles.css`
|
||||||
|
- si hace falta, el payload backend consumido por `/api/servers`
|
||||||
|
2. Restaurar el CTA de scores/histórico para todos los servidores relevantes.
|
||||||
|
3. Evitar depender exclusivamente de un mapa hardcodeado incompleto cuando haya información backend utilizable.
|
||||||
|
4. Mantener una experiencia estable:
|
||||||
|
- si existe URL conocida -> mostrar enlace
|
||||||
|
- si no existe -> degradar de forma clara, no desaparecer silenciosamente
|
||||||
|
5. Mantener el diseño coherente con la landing actual.
|
||||||
|
6. No mezclar esta task con refactors amplios no relacionados.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- No rehacer toda la homepage.
|
||||||
|
- No tocar la UI histórico V1/V2 salvo necesidad estricta.
|
||||||
|
- No inventar enlaces falsos.
|
||||||
|
- Si una URL no está disponible, la UI debe indicarlo de forma limpia.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- El botón/enlace a scores/histórico vuelve a mostrarse correctamente.
|
||||||
|
- No desaparece por un mapa hardcodeado incompleto.
|
||||||
|
- La landing sigue renderizando bien.
|
||||||
|
- La repo queda consistente.
|
||||||
|
|
||||||
|
## Expected Files
|
||||||
|
- `frontend/index.html`
|
||||||
|
- `frontend/assets/js/main.js`
|
||||||
|
- `frontend/assets/css/styles.css`
|
||||||
|
- opcionalmente backend si se necesita exponer mejor la URL o metadata correspondiente
|
||||||
@@ -224,8 +224,9 @@ normaliza espacios y barras finales para mantener la comparacion con el header
|
|||||||
|
|
||||||
`GET /api/servers` trata el ultimo snapshot persistido como cache local y lo
|
`GET /api/servers` trata el ultimo snapshot persistido como cache local y lo
|
||||||
reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot
|
reutiliza solo si sigue dentro del objetivo de `120` segundos. Si ese snapshot
|
||||||
esta vencido, el endpoint intenta una consulta A2S real inmediata contra los 2
|
esta vencido, el endpoint intenta primero una consulta RCON real inmediata
|
||||||
servidores configurados antes de responder.
|
contra los targets configurados. Solo si RCON falla o no devuelve snapshots
|
||||||
|
utilizables, cae a A2S de forma controlada antes de responder.
|
||||||
|
|
||||||
La respuesta incluye metadata de frescura pensada para frontend:
|
La respuesta incluye metadata de frescura pensada para frontend:
|
||||||
|
|
||||||
@@ -238,11 +239,17 @@ La respuesta incluye metadata de frescura pensada para frontend:
|
|||||||
- `source`
|
- `source`
|
||||||
- `refresh_attempted`
|
- `refresh_attempted`
|
||||||
- `refresh_status`
|
- `refresh_status`
|
||||||
|
- `primary_source`
|
||||||
|
- `selected_source`
|
||||||
|
- `fallback_used`
|
||||||
|
- `fallback_reason`
|
||||||
|
- `source_attempts`
|
||||||
|
|
||||||
Si la consulta real falla, `/api/servers` devuelve el ultimo snapshot valido
|
Si la consulta real falla, `/api/servers` devuelve el ultimo snapshot valido
|
||||||
disponible marcado como stale. Si no existe ningun snapshot valido, responde
|
disponible marcado como stale. Si no existe ningun snapshot valido, responde
|
||||||
`items: []` en lugar de reintroducir servidores de respaldo ajenos a la
|
`items: []` en lugar de reintroducir servidores de respaldo ajenos a la
|
||||||
comunidad.
|
comunidad. Cada respuesta deja tambien trazabilidad de arbitraje de fuente para
|
||||||
|
que sea visible si se sirvio RCON directo o si hubo fallback a A2S.
|
||||||
|
|
||||||
Los endpoints historicos leen la persistencia local SQLite creada por el
|
Los endpoints historicos leen la persistencia local SQLite creada por el
|
||||||
colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con
|
colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con
|
||||||
@@ -262,16 +269,16 @@ Variables nuevas:
|
|||||||
Valores soportados en esta fase:
|
Valores soportados en esta fase:
|
||||||
|
|
||||||
- live:
|
- live:
|
||||||
- `a2s` como modo actual de desarrollo
|
- `rcon` como camino primario recomendado
|
||||||
- `rcon` como modo productivo para estado live via acceso directo al servidor
|
- `a2s` como fallback legacy o override explicito
|
||||||
- historico:
|
- historico:
|
||||||
- `public-scoreboard` como modo actual de desarrollo
|
- `rcon` como camino primario recomendado para captura y lectura minima
|
||||||
- `rcon` seleccionado pero todavia sin ingesta historica operativa en esta repo
|
- `public-scoreboard` como fallback legacy o override explicito
|
||||||
|
|
||||||
Defaults actuales:
|
Defaults actuales:
|
||||||
|
|
||||||
- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
|
- `HLL_BACKEND_LIVE_DATA_SOURCE=rcon`
|
||||||
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard`
|
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`
|
||||||
|
|
||||||
La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters
|
La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters
|
||||||
dedicados dentro de `app/providers/`:
|
dedicados dentro de `app/providers/`:
|
||||||
@@ -287,16 +294,27 @@ dedicados dentro de `app/providers/`:
|
|||||||
|
|
||||||
Proveedores operativos en esta fase:
|
Proveedores operativos en esta fase:
|
||||||
|
|
||||||
- live `a2s`
|
|
||||||
- live `rcon`
|
- live `rcon`
|
||||||
|
- live `a2s`
|
||||||
|
- historico `rcon` solo para read model minimo y captura prospectiva
|
||||||
- historico `public-scoreboard`
|
- historico `public-scoreboard`
|
||||||
|
|
||||||
|
Politica funcional actual:
|
||||||
|
|
||||||
|
- live:
|
||||||
|
- RCON primero
|
||||||
|
- A2S solo si RCON falla o no devuelve snapshots utilizables
|
||||||
|
- historico/recopilacion:
|
||||||
|
- RCON primero
|
||||||
|
- `public-scoreboard` solo si RCON no soporta aun esa operacion concreta o
|
||||||
|
falla la captura primaria
|
||||||
|
|
||||||
Limitacion actual de `rcon`:
|
Limitacion actual de `rcon`:
|
||||||
|
|
||||||
- el backend puede usar `rcon` para `/api/servers`
|
- el backend puede usar `rcon` para `/api/servers`
|
||||||
- la ingesta historica por `historical_ingestion.py` sigue requiriendo
|
- la ingesta historica competitiva por `historical_ingestion.py` sigue cayendo a
|
||||||
`public-scoreboard`, porque la repo todavia no incluye una canalizacion
|
`public-scoreboard`, porque la repo todavia no incluye una canalizacion
|
||||||
persistente de eventos o logs RCON para reconstruir partidas cerradas
|
retroactiva RCON capaz de reconstruir partidas cerradas con paridad completa
|
||||||
|
|
||||||
Estado real de "historico por RCON" en esta repo:
|
Estado real de "historico por RCON" en esta repo:
|
||||||
|
|
||||||
@@ -349,13 +367,13 @@ $env:HLL_BACKEND_RCON_TARGETS='[
|
|||||||
|
|
||||||
Runbook operativo minimo:
|
Runbook operativo minimo:
|
||||||
|
|
||||||
- desarrollo:
|
- modo recomendado por defecto:
|
||||||
|
- `HLL_BACKEND_LIVE_DATA_SOURCE=rcon`
|
||||||
|
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`
|
||||||
|
- definir `HLL_BACKEND_RCON_TARGETS` fuera de la repo
|
||||||
|
- override legacy completo:
|
||||||
- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
|
- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
|
||||||
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard`
|
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard`
|
||||||
- produccion live con RCON:
|
|
||||||
- `HLL_BACKEND_LIVE_DATA_SOURCE=rcon`
|
|
||||||
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard`
|
|
||||||
- definir `HLL_BACKEND_RCON_TARGETS` fuera de la repo
|
|
||||||
|
|
||||||
Verificacion minima del proveedor activo:
|
Verificacion minima del proveedor activo:
|
||||||
|
|
||||||
@@ -418,15 +436,15 @@ La persistencia queda separada del historico `historical_*` actual y usa:
|
|||||||
|
|
||||||
Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`:
|
Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`:
|
||||||
|
|
||||||
- endpoints soportados hoy:
|
- endpoints soportados hoy directamente por RCON persistido:
|
||||||
- `GET /api/historical/server-summary`
|
- `GET /api/historical/server-summary`
|
||||||
- `GET /api/historical/recent-matches`
|
- `GET /api/historical/recent-matches`
|
||||||
- lo que devuelven:
|
- lo que devuelven:
|
||||||
- cobertura por target RCON configurado
|
- cobertura por target RCON configurado
|
||||||
- frescura del ultimo capture exitoso
|
- frescura del ultimo capture exitoso
|
||||||
- actividad reciente persistida
|
- actividad reciente persistida
|
||||||
- endpoints que siguen dependiendo de `public-scoreboard` para el contrato
|
- endpoints que hoy caen automaticamente a `public-scoreboard` para mantener el
|
||||||
completo:
|
contrato completo:
|
||||||
- `GET /api/historical/weekly-top-kills`
|
- `GET /api/historical/weekly-top-kills`
|
||||||
- `GET /api/historical/weekly-leaderboard`
|
- `GET /api/historical/weekly-leaderboard`
|
||||||
- `GET /api/historical/leaderboard`
|
- `GET /api/historical/leaderboard`
|
||||||
@@ -437,8 +455,10 @@ Lectura historica minima cuando `HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon`:
|
|||||||
- `GET /api/historical/snapshots/*`
|
- `GET /api/historical/snapshots/*`
|
||||||
|
|
||||||
Cuando esos endpoints se consultan con `historical_data_source=rcon`, el backend
|
Cuando esos endpoints se consultan con `historical_data_source=rcon`, el backend
|
||||||
devuelve un payload coherente con `supported: false` y deja claro que esa parte
|
intenta primero RCON para la operacion soportada y, si no hay cobertura o la
|
||||||
del contrato todavia requiere `public-scoreboard`.
|
capacidad aun no existe, cae automaticamente a `public-scoreboard`. La
|
||||||
|
respuesta deja trazabilidad con `primary_source`, `selected_source`,
|
||||||
|
`fallback_used`, `fallback_reason` y `source_attempts`.
|
||||||
|
|
||||||
## Criterio de estructura
|
## Criterio de estructura
|
||||||
|
|
||||||
@@ -1065,11 +1085,15 @@ Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con:
|
|||||||
- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
|
- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
|
||||||
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`
|
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`
|
||||||
|
|
||||||
El runner `python -m app.historical_runner` deja ese refresh incremental listo
|
El runner `python -m app.historical_runner` deja ahora una orquestacion
|
||||||
para ejecucion local repetida sin depender de infraestructura externa y
|
RCON-first lista para ejecucion local repetida sin depender de infraestructura
|
||||||
mantiene calientes los snapshots historicos mas visibles tras cada refresh
|
externa y mantiene calientes los snapshots historicos mas visibles cuando el
|
||||||
correcto. Por defecto:
|
fallback clasico entra de forma controlada. Por defecto:
|
||||||
|
|
||||||
|
- intenta primero una captura prospectiva RCON en cada ciclo
|
||||||
|
- solo lanza el refresh historico clasico cuando RCON falla, cuando se pide un
|
||||||
|
scope manual que sigue requiriendo cobertura competitiva, o en la cadencia
|
||||||
|
periodica de fallback para mantener rankings y snapshots clasicos
|
||||||
- refresca cada `900` segundos
|
- refresca cada `900` segundos
|
||||||
- prewarmea en cada ciclo:
|
- prewarmea en cada ciclo:
|
||||||
- `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`
|
- `server-summary` para `comunidad-hispana-01`, `comunidad-hispana-02`, `comunidad-hispana-03` y `all-servers`
|
||||||
@@ -1109,8 +1133,10 @@ Sin `--server`, ese runner refresca:
|
|||||||
- `comunidad-hispana-02`
|
- `comunidad-hispana-02`
|
||||||
- `comunidad-hispana-03`
|
- `comunidad-hispana-03`
|
||||||
|
|
||||||
Despues de cada refresh correcto, recompone snapshots para los servidores
|
Despues de cada fallback clasico correcto, recompone snapshots para los
|
||||||
afectados y vuelve a alinear el agregado `all-servers`.
|
servidores afectados y vuelve a alinear el agregado `all-servers`. Si el ciclo
|
||||||
|
RCON primario fue suficiente y no hizo falta el fallback clasico, el runner
|
||||||
|
deja constancia explicita de ese motivo en su salida JSON.
|
||||||
|
|
||||||
Para regenerar snapshots de forma puntual dentro del contenedor sin dejar un
|
Para regenerar snapshots de forma puntual dentro del contenedor sin dejar un
|
||||||
bucle permanente, la validacion operativa minima es:
|
bucle permanente, la validacion operativa minima es:
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ DEFAULT_HOST = "127.0.0.1"
|
|||||||
DEFAULT_PORT = 8000
|
DEFAULT_PORT = 8000
|
||||||
DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3"
|
DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3"
|
||||||
DEFAULT_REFRESH_INTERVAL_SECONDS = 300
|
DEFAULT_REFRESH_INTERVAL_SECONDS = 300
|
||||||
DEFAULT_LIVE_DATA_SOURCE = "a2s"
|
DEFAULT_LIVE_DATA_SOURCE = "rcon"
|
||||||
DEFAULT_HISTORICAL_DATA_SOURCE = "public-scoreboard"
|
DEFAULT_HISTORICAL_DATA_SOURCE = "rcon"
|
||||||
DEFAULT_RCON_TIMEOUT_SECONDS = 10.0
|
DEFAULT_RCON_TIMEOUT_SECONDS = 10.0
|
||||||
DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50
|
DEFAULT_HISTORICAL_CRCON_PAGE_SIZE = 50
|
||||||
DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0
|
DEFAULT_HISTORICAL_CRCON_TIMEOUT_SECONDS = 15.0
|
||||||
@@ -30,7 +30,7 @@ DEFAULT_PLAYER_EVENT_REFRESH_INTERVAL_SECONDS = 1800
|
|||||||
DEFAULT_PLAYER_EVENT_REFRESH_OVERLAP_HOURS = 12
|
DEFAULT_PLAYER_EVENT_REFRESH_OVERLAP_HOURS = 12
|
||||||
DEFAULT_PLAYER_EVENT_REFRESH_MAX_RETRIES = 2
|
DEFAULT_PLAYER_EVENT_REFRESH_MAX_RETRIES = 2
|
||||||
DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30
|
DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30
|
||||||
DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 120
|
DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600
|
||||||
DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2
|
DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2
|
||||||
DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15
|
DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15
|
||||||
DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS = 30.0
|
DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS = 30.0
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .server_targets import A2SServerTarget, load_a2s_targets
|
|||||||
|
|
||||||
|
|
||||||
LIVE_SOURCE_A2S = "a2s"
|
LIVE_SOURCE_A2S = "a2s"
|
||||||
|
SOURCE_KIND_PUBLIC_SCOREBOARD = "public-scoreboard"
|
||||||
SOURCE_KIND_RCON = "rcon"
|
SOURCE_KIND_RCON = "rcon"
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ class LiveDataSource(Protocol):
|
|||||||
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
|
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
|
||||||
"""Collect one live snapshot batch."""
|
"""Collect one live snapshot batch."""
|
||||||
|
|
||||||
def build_target_index(self) -> dict[str | None, A2SServerTarget]:
|
def build_target_index(self) -> dict[str | None, object]:
|
||||||
"""Return optional server connection metadata keyed by external id."""
|
"""Return optional server connection metadata keyed by external id."""
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +76,102 @@ class A2SLiveDataSource:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RconFirstLiveDataSource:
|
||||||
|
"""Live source arbitration with RCON as primary and A2S as controlled fallback."""
|
||||||
|
|
||||||
|
primary_source: RconLiveDataSource = RconLiveDataSource()
|
||||||
|
fallback_source: A2SLiveDataSource = A2SLiveDataSource()
|
||||||
|
source_kind: str = SOURCE_KIND_RCON
|
||||||
|
|
||||||
|
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
|
||||||
|
attempts: list[dict[str, object]] = []
|
||||||
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
primary_payload = self.primary_source.collect_snapshots(persist=persist)
|
||||||
|
except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled
|
||||||
|
attempts.append(
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_RCON,
|
||||||
|
role="primary",
|
||||||
|
status="error",
|
||||||
|
reason="rcon-live-request-failed",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fallback_reason = "rcon-live-request-failed"
|
||||||
|
else:
|
||||||
|
primary_success_count = int(primary_payload.get("success_count") or 0)
|
||||||
|
primary_snapshots = list(primary_payload.get("snapshots") or [])
|
||||||
|
if primary_success_count > 0 and primary_snapshots:
|
||||||
|
attempts.append(
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_RCON,
|
||||||
|
role="primary",
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return attach_source_policy(
|
||||||
|
primary_payload,
|
||||||
|
build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_RCON,
|
||||||
|
selected_source=SOURCE_KIND_RCON,
|
||||||
|
source_attempts=attempts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts.append(
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_RCON,
|
||||||
|
role="primary",
|
||||||
|
status="empty",
|
||||||
|
reason="rcon-live-returned-no-usable-snapshots",
|
||||||
|
message=f"success_count={primary_success_count}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fallback_reason = "rcon-live-returned-no-usable-snapshots"
|
||||||
|
|
||||||
|
try:
|
||||||
|
fallback_payload = self.fallback_source.collect_snapshots(persist=persist)
|
||||||
|
except Exception as error: # noqa: BLE001 - keep combined failure explicit
|
||||||
|
attempts.append(
|
||||||
|
build_source_attempt(
|
||||||
|
source=LIVE_SOURCE_A2S,
|
||||||
|
role="fallback",
|
||||||
|
status="error",
|
||||||
|
reason="a2s-live-fallback-failed",
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
"RCON-first live collection failed and A2S fallback also failed."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
attempts.append(
|
||||||
|
build_source_attempt(
|
||||||
|
source=LIVE_SOURCE_A2S,
|
||||||
|
role="fallback",
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return attach_source_policy(
|
||||||
|
fallback_payload,
|
||||||
|
build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_RCON,
|
||||||
|
selected_source=LIVE_SOURCE_A2S,
|
||||||
|
fallback_used=True,
|
||||||
|
fallback_reason=fallback_reason,
|
||||||
|
source_attempts=attempts,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_target_index(self) -> dict[str | None, object]:
|
||||||
|
target_index = dict(self.fallback_source.build_target_index())
|
||||||
|
target_index.update(self.primary_source.build_target_index())
|
||||||
|
return target_index
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RconHistoricalDataSource:
|
class RconHistoricalDataSource:
|
||||||
"""Minimal persisted historical read model over prospective RCON capture."""
|
"""Minimal persisted historical read model over prospective RCON capture."""
|
||||||
@@ -123,7 +220,7 @@ class RconHistoricalDataSource:
|
|||||||
def get_historical_data_source() -> HistoricalDataSource:
|
def get_historical_data_source() -> HistoricalDataSource:
|
||||||
"""Select the historical provider configured for the current environment."""
|
"""Select the historical provider configured for the current environment."""
|
||||||
source_kind = get_historical_data_source_kind()
|
source_kind = get_historical_data_source_kind()
|
||||||
if source_kind == "public-scoreboard":
|
if source_kind == SOURCE_KIND_PUBLIC_SCOREBOARD:
|
||||||
return PublicScoreboardHistoricalDataSource()
|
return PublicScoreboardHistoricalDataSource()
|
||||||
if source_kind == SOURCE_KIND_RCON:
|
if source_kind == SOURCE_KIND_RCON:
|
||||||
return RconHistoricalDataSource()
|
return RconHistoricalDataSource()
|
||||||
@@ -136,5 +233,104 @@ def get_live_data_source() -> LiveDataSource:
|
|||||||
if source_kind == LIVE_SOURCE_A2S:
|
if source_kind == LIVE_SOURCE_A2S:
|
||||||
return A2SLiveDataSource()
|
return A2SLiveDataSource()
|
||||||
if source_kind == SOURCE_KIND_RCON:
|
if source_kind == SOURCE_KIND_RCON:
|
||||||
return RconLiveDataSource()
|
return RconFirstLiveDataSource()
|
||||||
raise ValueError(f"Unsupported live data source: {source_kind}")
|
raise ValueError(f"Unsupported live data source: {source_kind}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_rcon_historical_read_model() -> RconHistoricalDataSource | None:
|
||||||
|
"""Return the minimal persisted RCON historical read model when selected."""
|
||||||
|
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
|
||||||
|
return None
|
||||||
|
return RconHistoricalDataSource()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]:
|
||||||
|
"""Resolve the writer-oriented historical provider with safe fallback semantics."""
|
||||||
|
configured_kind = get_historical_data_source_kind()
|
||||||
|
if configured_kind == SOURCE_KIND_PUBLIC_SCOREBOARD:
|
||||||
|
return (
|
||||||
|
PublicScoreboardHistoricalDataSource(),
|
||||||
|
build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
role="primary",
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if configured_kind == SOURCE_KIND_RCON:
|
||||||
|
return (
|
||||||
|
PublicScoreboardHistoricalDataSource(),
|
||||||
|
build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_RCON,
|
||||||
|
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
fallback_used=True,
|
||||||
|
fallback_reason="rcon-historical-ingestion-not-supported-yet",
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_RCON,
|
||||||
|
role="primary",
|
||||||
|
status="unsupported",
|
||||||
|
reason="rcon-historical-ingestion-not-supported-yet",
|
||||||
|
),
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
role="fallback",
|
||||||
|
status="success",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported historical data source: {configured_kind}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_attempt(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
role: str,
|
||||||
|
status: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Build one normalized trace entry for source arbitration."""
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"role": role,
|
||||||
|
"status": status,
|
||||||
|
"reason": reason,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_source_policy(
|
||||||
|
*,
|
||||||
|
primary_source: str,
|
||||||
|
selected_source: str,
|
||||||
|
fallback_used: bool = False,
|
||||||
|
fallback_reason: str | None = None,
|
||||||
|
source_attempts: list[dict[str, object]] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Build one small source-policy block for API responses and worker output."""
|
||||||
|
return {
|
||||||
|
"primary_source": primary_source,
|
||||||
|
"selected_source": selected_source,
|
||||||
|
"fallback_used": fallback_used,
|
||||||
|
"fallback_reason": fallback_reason,
|
||||||
|
"source_attempts": list(source_attempts or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attach_source_policy(
|
||||||
|
payload: dict[str, object],
|
||||||
|
source_policy: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Attach normalized source-policy metadata to an existing payload."""
|
||||||
|
enriched = dict(payload)
|
||||||
|
enriched.update(source_policy)
|
||||||
|
return enriched
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from .config import (
|
|||||||
get_historical_crcon_page_size,
|
get_historical_crcon_page_size,
|
||||||
get_historical_refresh_overlap_hours,
|
get_historical_refresh_overlap_hours,
|
||||||
)
|
)
|
||||||
from .data_sources import HistoricalDataSource, get_historical_data_source
|
from .data_sources import HistoricalDataSource, resolve_historical_ingestion_data_source
|
||||||
from .historical_snapshots import generate_and_persist_historical_snapshots
|
from .historical_snapshots import generate_and_persist_historical_snapshots
|
||||||
from .historical_storage import (
|
from .historical_storage import (
|
||||||
finalize_backfill_progress,
|
finalize_backfill_progress,
|
||||||
@@ -119,7 +119,7 @@ def _run_ingestion(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
initialize_historical_storage()
|
initialize_historical_storage()
|
||||||
stats = IngestionStats()
|
stats = IngestionStats()
|
||||||
data_source = get_historical_data_source()
|
data_source, source_policy = resolve_historical_ingestion_data_source()
|
||||||
selected_servers = _select_servers(server_slug)
|
selected_servers = _select_servers(server_slug)
|
||||||
processed_servers: list[dict[str, object]] = []
|
processed_servers: list[dict[str, object]] = []
|
||||||
active_runs: dict[str, int] = {}
|
active_runs: dict[str, int] = {}
|
||||||
@@ -219,6 +219,7 @@ def _run_ingestion(
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"source_provider": data_source.source_kind,
|
"source_provider": data_source.source_kind,
|
||||||
|
"source_policy": source_policy,
|
||||||
"page_size": page_size or get_historical_crcon_page_size(),
|
"page_size": page_size or get_historical_crcon_page_size(),
|
||||||
"start_page": start_page,
|
"start_page": start_page,
|
||||||
"detail_workers": detail_workers or get_historical_crcon_detail_workers(),
|
"detail_workers": detail_workers or get_historical_crcon_detail_workers(),
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ from .config import (
|
|||||||
get_historical_refresh_interval_seconds,
|
get_historical_refresh_interval_seconds,
|
||||||
get_historical_refresh_max_retries,
|
get_historical_refresh_max_retries,
|
||||||
get_historical_refresh_retry_delay_seconds,
|
get_historical_refresh_retry_delay_seconds,
|
||||||
|
get_historical_data_source_kind,
|
||||||
)
|
)
|
||||||
from .historical_ingestion import run_incremental_refresh
|
from .historical_ingestion import run_incremental_refresh
|
||||||
from .historical_snapshots import (
|
from .historical_snapshots import (
|
||||||
generate_and_persist_historical_snapshots,
|
generate_and_persist_historical_snapshots,
|
||||||
generate_and_persist_priority_historical_snapshots,
|
generate_and_persist_priority_historical_snapshots,
|
||||||
)
|
)
|
||||||
|
from .rcon_historical_worker import run_rcon_historical_capture
|
||||||
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
||||||
|
|
||||||
HOURLY_INTERVAL_SECONDS = 3600
|
HOURLY_INTERVAL_SECONDS = 3600
|
||||||
@@ -95,20 +97,42 @@ def _run_refresh_with_retries(
|
|||||||
f"app.historical_runner refresh:{server_slug or 'all-servers'}"
|
f"app.historical_runner refresh:{server_slug or 'all-servers'}"
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
refresh_result = run_incremental_refresh(
|
rcon_capture_result = _run_primary_rcon_capture()
|
||||||
server_slug=server_slug,
|
should_run_classic_fallback, classic_fallback_reason = (
|
||||||
max_pages=max_pages,
|
_resolve_classic_fallback_policy(
|
||||||
page_size=page_size,
|
server_slug=server_slug,
|
||||||
rebuild_snapshots=False,
|
run_number=run_number,
|
||||||
)
|
rcon_capture_result=rcon_capture_result,
|
||||||
snapshot_result = generate_historical_snapshots(
|
)
|
||||||
server_slug=server_slug,
|
|
||||||
run_number=run_number,
|
|
||||||
)
|
)
|
||||||
|
if should_run_classic_fallback:
|
||||||
|
refresh_result = run_incremental_refresh(
|
||||||
|
server_slug=server_slug,
|
||||||
|
max_pages=max_pages,
|
||||||
|
page_size=page_size,
|
||||||
|
rebuild_snapshots=False,
|
||||||
|
)
|
||||||
|
snapshot_result = generate_historical_snapshots(
|
||||||
|
server_slug=server_slug,
|
||||||
|
run_number=run_number,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
refresh_result = {
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
|
||||||
|
}
|
||||||
|
snapshot_result = {
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
|
||||||
|
"generation_policy": "classic-historical-fallback-only",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"attempts_used": attempt,
|
"attempts_used": attempt,
|
||||||
"max_retries": max_retries,
|
"max_retries": max_retries,
|
||||||
|
"rcon_capture_result": rcon_capture_result,
|
||||||
|
"classic_fallback_used": should_run_classic_fallback,
|
||||||
|
"classic_fallback_reason": classic_fallback_reason,
|
||||||
"refresh_result": refresh_result,
|
"refresh_result": refresh_result,
|
||||||
"snapshot_result": snapshot_result,
|
"snapshot_result": snapshot_result,
|
||||||
}
|
}
|
||||||
@@ -164,6 +188,43 @@ def _describe_snapshot_scope(server_slug: str | None) -> list[str]:
|
|||||||
return [*DEFAULT_HISTORICAL_SERVER_SCOPE, "all-servers"]
|
return [*DEFAULT_HISTORICAL_SERVER_SCOPE, "all-servers"]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_primary_rcon_capture() -> dict[str, Any]:
|
||||||
|
if get_historical_data_source_kind() != "rcon":
|
||||||
|
return {
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "historical-data-source-configured-without-rcon-primary",
|
||||||
|
}
|
||||||
|
return run_rcon_historical_capture()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_classic_fallback_policy(
|
||||||
|
*,
|
||||||
|
server_slug: str | None,
|
||||||
|
run_number: int,
|
||||||
|
rcon_capture_result: dict[str, Any],
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if get_historical_data_source_kind() != "rcon":
|
||||||
|
return True, "public-scoreboard-configured-as-primary-historical-source"
|
||||||
|
|
||||||
|
if not _rcon_capture_has_usable_results(rcon_capture_result):
|
||||||
|
return True, "rcon-historical-capture-failed-or-returned-no-usable-targets"
|
||||||
|
|
||||||
|
if server_slug:
|
||||||
|
return True, "manual-server-scope-still-needs-classic-historical-fallback"
|
||||||
|
|
||||||
|
if run_number % get_historical_full_snapshot_every_runs() == 0:
|
||||||
|
return True, "periodic-classic-fallback-for-competitive-historical-coverage"
|
||||||
|
|
||||||
|
return False, "rcon-primary-cycle-succeeded-without-needing-classic-fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def _rcon_capture_has_usable_results(rcon_capture_result: dict[str, Any]) -> bool:
|
||||||
|
if rcon_capture_result.get("status") != "ok":
|
||||||
|
return False
|
||||||
|
targets = rcon_capture_result.get("targets")
|
||||||
|
return isinstance(targets, list) and len(targets) > 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Allow local scheduled historical refresh execution without external infra."""
|
"""Allow local scheduled historical refresh execution without external infra."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ from .config import (
|
|||||||
get_live_data_source_kind,
|
get_live_data_source_kind,
|
||||||
get_refresh_interval_seconds,
|
get_refresh_interval_seconds,
|
||||||
)
|
)
|
||||||
from .data_sources import RconHistoricalDataSource, get_historical_data_source, get_live_data_source
|
from .data_sources import (
|
||||||
|
LIVE_SOURCE_A2S,
|
||||||
|
SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
SOURCE_KIND_RCON,
|
||||||
|
build_source_attempt,
|
||||||
|
build_source_policy,
|
||||||
|
get_live_data_source,
|
||||||
|
get_rcon_historical_read_model,
|
||||||
|
)
|
||||||
from .historical_snapshot_storage import get_historical_snapshot
|
from .historical_snapshot_storage import get_historical_snapshot
|
||||||
from .historical_snapshots import (
|
from .historical_snapshots import (
|
||||||
DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||||
@@ -29,6 +37,7 @@ from .historical_snapshots import (
|
|||||||
)
|
)
|
||||||
from .historical_storage import (
|
from .historical_storage import (
|
||||||
ALL_SERVERS_SLUG,
|
ALL_SERVERS_SLUG,
|
||||||
|
DEFAULT_HISTORICAL_SERVERS,
|
||||||
get_historical_player_profile,
|
get_historical_player_profile,
|
||||||
list_historical_server_summaries,
|
list_historical_server_summaries,
|
||||||
list_monthly_leaderboard,
|
list_monthly_leaderboard,
|
||||||
@@ -102,21 +111,28 @@ def build_servers_payload() -> dict[str, object]:
|
|||||||
max_snapshot_age_seconds,
|
max_snapshot_age_seconds,
|
||||||
)
|
)
|
||||||
refresh_errors: list[dict[str, object]] = []
|
refresh_errors: list[dict[str, object]] = []
|
||||||
|
refresh_source_policy = build_source_policy(
|
||||||
|
primary_source=get_live_data_source_kind(),
|
||||||
|
selected_source="none",
|
||||||
|
fallback_reason=None,
|
||||||
|
source_attempts=[],
|
||||||
|
)
|
||||||
|
|
||||||
if refresh_attempted:
|
if refresh_attempted:
|
||||||
refreshed_items, refresh_errors = _try_collect_real_time_snapshot()
|
refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot()
|
||||||
if refreshed_items:
|
if refreshed_items:
|
||||||
refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items)
|
refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items)
|
||||||
refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at)
|
refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at)
|
||||||
return _build_servers_response(
|
return _build_servers_response(
|
||||||
items=refreshed_items,
|
items=refreshed_items,
|
||||||
response_source="real-time-a2s-refresh",
|
response_source=_build_live_response_source(refresh_source_policy),
|
||||||
last_snapshot_at=refreshed_snapshot_at,
|
last_snapshot_at=refreshed_snapshot_at,
|
||||||
snapshot_age_seconds=refreshed_snapshot_age_seconds,
|
snapshot_age_seconds=refreshed_snapshot_age_seconds,
|
||||||
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
||||||
refresh_attempted=True,
|
refresh_attempted=True,
|
||||||
refresh_status="success",
|
refresh_status="success",
|
||||||
refresh_errors=refresh_errors,
|
refresh_errors=refresh_errors,
|
||||||
|
source_policy=refresh_source_policy,
|
||||||
)
|
)
|
||||||
|
|
||||||
if persisted_items:
|
if persisted_items:
|
||||||
@@ -133,6 +149,11 @@ def build_servers_payload() -> dict[str, object]:
|
|||||||
refresh_attempted=refresh_attempted,
|
refresh_attempted=refresh_attempted,
|
||||||
refresh_status=refresh_status,
|
refresh_status=refresh_status,
|
||||||
refresh_errors=refresh_errors,
|
refresh_errors=refresh_errors,
|
||||||
|
source_policy=_infer_live_source_policy_from_items(
|
||||||
|
persisted_items,
|
||||||
|
refresh_attempted=refresh_attempted,
|
||||||
|
refresh_errors=refresh_errors,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -150,6 +171,7 @@ def build_servers_payload() -> dict[str, object]:
|
|||||||
"refresh_attempted": refresh_attempted,
|
"refresh_attempted": refresh_attempted,
|
||||||
"refresh_status": "failed" if refresh_attempted else "not-needed",
|
"refresh_status": "failed" if refresh_attempted else "not-needed",
|
||||||
"refresh_errors": refresh_errors,
|
"refresh_errors": refresh_errors,
|
||||||
|
**refresh_source_policy,
|
||||||
"items": [],
|
"items": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -219,14 +241,6 @@ def build_weekly_top_kills_payload(
|
|||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return weekly top kills grouped by real community server."""
|
"""Return weekly top kills grouped by real community server."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Top kills semanales por servidor",
|
|
||||||
context="historical-top-kills",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
result = list_weekly_top_kills(limit=limit, server_id=server_id)
|
result = list_weekly_top_kills(limit=limit, server_id=server_id)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -239,6 +253,9 @@ def build_weekly_top_kills_payload(
|
|||||||
"window_start": result["window_start"],
|
"window_start": result["window_start"],
|
||||||
"window_end": result["window_end"],
|
"window_end": result["window_end"],
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-weekly-top-kills",
|
||||||
|
),
|
||||||
"items": result["items"],
|
"items": result["items"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -252,16 +269,6 @@ def build_historical_leaderboard_payload(
|
|||||||
timeframe: str = "weekly",
|
timeframe: str = "weekly",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one historical leaderboard for the requested timeframe and metric."""
|
"""Return one historical leaderboard for the requested timeframe and metric."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Ranking historico",
|
|
||||||
context="historical-leaderboard",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
metric=metric,
|
|
||||||
timeframe=timeframe,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
|
normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
|
||||||
if normalized_timeframe == "monthly":
|
if normalized_timeframe == "monthly":
|
||||||
result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
result = list_monthly_leaderboard(limit=limit, server_id=server_id, metric=metric)
|
||||||
@@ -301,6 +308,9 @@ def build_historical_leaderboard_payload(
|
|||||||
"previous_month_closed_matches": result.get("previous_month_closed_matches"),
|
"previous_month_closed_matches": result.get("previous_month_closed_matches"),
|
||||||
"sufficient_sample": result.get("sufficient_sample"),
|
"sufficient_sample": result.get("sufficient_sample"),
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-competitive-leaderboards",
|
||||||
|
),
|
||||||
"items": result["items"],
|
"items": result["items"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -343,25 +353,37 @@ def build_recent_historical_matches_payload(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return recent historical matches from persisted CRCON data."""
|
"""Return recent historical matches from persisted CRCON data."""
|
||||||
if get_historical_data_source_kind() == "rcon":
|
if get_historical_data_source_kind() == "rcon":
|
||||||
data_source = get_historical_data_source()
|
data_source = get_rcon_historical_read_model()
|
||||||
if isinstance(data_source, RconHistoricalDataSource):
|
if data_source is not None:
|
||||||
items = data_source.list_recent_activity(server_key=server_slug, limit=limit)
|
items = data_source.list_recent_activity(server_key=server_slug, limit=limit)
|
||||||
capabilities = data_source.describe_capabilities()
|
capabilities = data_source.describe_capabilities()
|
||||||
return {
|
if items:
|
||||||
"status": "ok",
|
return {
|
||||||
"data": {
|
"status": "ok",
|
||||||
"title": "Actividad reciente capturada por RCON",
|
"data": {
|
||||||
"context": "historical-recent-matches",
|
"title": "Actividad reciente capturada por RCON",
|
||||||
"source": "rcon-historical-read-model",
|
"context": "historical-recent-matches",
|
||||||
"historical_data_source": "rcon",
|
"source": "rcon-historical-read-model",
|
||||||
"supported": True,
|
"historical_data_source": "rcon",
|
||||||
"coverage_basis": "prospective-rcon-samples",
|
"supported": True,
|
||||||
"limit": limit,
|
"coverage_basis": "prospective-rcon-samples",
|
||||||
"server_slug": server_slug,
|
"limit": limit,
|
||||||
"items": items,
|
"server_slug": server_slug,
|
||||||
"capabilities": capabilities,
|
**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",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"items": items,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
},
|
||||||
|
}
|
||||||
items = list_recent_historical_matches(limit=limit, server_slug=server_slug)
|
items = list_recent_historical_matches(limit=limit, server_slug=server_slug)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -371,6 +393,9 @@ def build_recent_historical_matches_payload(
|
|||||||
"source": "historical-crcon-storage",
|
"source": "historical-crcon-storage",
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"server_slug": server_slug,
|
"server_slug": server_slug,
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-has-no-recent-activity",
|
||||||
|
),
|
||||||
"items": items,
|
"items": items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -382,14 +407,6 @@ def build_monthly_mvp_payload(
|
|||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return the precomputed monthly MVP payload through the stable API surface."""
|
"""Return the precomputed monthly MVP payload through the stable API surface."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Top MVP mensual",
|
|
||||||
context="historical-monthly-mvp",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot_payload = build_monthly_mvp_snapshot_payload(
|
snapshot_payload = build_monthly_mvp_snapshot_payload(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
@@ -405,6 +422,9 @@ def build_monthly_mvp_payload(
|
|||||||
),
|
),
|
||||||
"context": "historical-monthly-mvp",
|
"context": "historical-monthly-mvp",
|
||||||
"source": "historical-precomputed-snapshots",
|
"source": "historical-precomputed-snapshots",
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-monthly-mvp-yet",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,15 +436,6 @@ def build_player_event_payload(
|
|||||||
view: str = "most-killed",
|
view: str = "most-killed",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one V2 player-event payload through the stable API surface."""
|
"""Return one V2 player-event payload through the stable API surface."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Metricas V2 mensuales",
|
|
||||||
context="historical-player-events",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
metric=view,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot_payload = build_player_event_snapshot_payload(
|
snapshot_payload = build_player_event_snapshot_payload(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
@@ -442,6 +453,9 @@ def build_player_event_payload(
|
|||||||
),
|
),
|
||||||
"context": "historical-player-events",
|
"context": "historical-player-events",
|
||||||
"source": "historical-precomputed-player-event-snapshots",
|
"source": "historical-precomputed-player-event-snapshots",
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-player-events-yet",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,14 +466,6 @@ def build_monthly_mvp_v2_payload(
|
|||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return the precomputed monthly MVP V2 payload through the stable API surface."""
|
"""Return the precomputed monthly MVP V2 payload through the stable API surface."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Top MVP mensual V2",
|
|
||||||
context="historical-monthly-mvp-v2",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot_payload = build_monthly_mvp_v2_snapshot_payload(
|
snapshot_payload = build_monthly_mvp_v2_snapshot_payload(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
@@ -475,6 +481,9 @@ def build_monthly_mvp_v2_payload(
|
|||||||
),
|
),
|
||||||
"context": "historical-monthly-mvp-v2",
|
"context": "historical-monthly-mvp-v2",
|
||||||
"source": "historical-precomputed-snapshots",
|
"source": "historical-precomputed-snapshots",
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-monthly-mvp-v2-yet",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,13 +493,6 @@ def build_historical_server_summary_snapshot_payload(
|
|||||||
server_slug: str | None = None,
|
server_slug: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed summary snapshot without recalculating aggregates."""
|
"""Return one precomputed summary snapshot without recalculating aggregates."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Snapshot historico de resumen por servidor",
|
|
||||||
context="historical-server-summary-snapshot",
|
|
||||||
server_key=server_slug,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot = _get_historical_snapshot_record(
|
snapshot = _get_historical_snapshot_record(
|
||||||
server_key=server_slug,
|
server_key=server_slug,
|
||||||
snapshot_type=SNAPSHOT_TYPE_SERVER_SUMMARY,
|
snapshot_type=SNAPSHOT_TYPE_SERVER_SUMMARY,
|
||||||
@@ -506,6 +508,9 @@ 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(
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
@@ -520,16 +525,6 @@ def build_leaderboard_snapshot_payload(
|
|||||||
timeframe: str = "weekly",
|
timeframe: str = "weekly",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed leaderboard snapshot for the requested timeframe."""
|
"""Return one precomputed leaderboard snapshot for the requested timeframe."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Snapshot historico de leaderboard",
|
|
||||||
context="historical-leaderboard-snapshot",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
metric=metric,
|
|
||||||
timeframe=timeframe,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
|
normalized_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
|
||||||
if normalized_timeframe == "monthly":
|
if normalized_timeframe == "monthly":
|
||||||
snapshot_type = SNAPSHOT_TYPE_MONTHLY_LEADERBOARD
|
snapshot_type = SNAPSHOT_TYPE_MONTHLY_LEADERBOARD
|
||||||
@@ -591,6 +586,9 @@ def build_leaderboard_snapshot_payload(
|
|||||||
"sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None,
|
"sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None,
|
||||||
"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(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
),
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -632,14 +630,6 @@ def build_recent_historical_matches_snapshot_payload(
|
|||||||
server_slug: str | None = None,
|
server_slug: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed recent-matches snapshot."""
|
"""Return one precomputed recent-matches snapshot."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Snapshot historico de actividad reciente por servidor",
|
|
||||||
context="historical-recent-matches-snapshot",
|
|
||||||
server_key=server_slug,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot = _get_historical_snapshot_record(
|
snapshot = _get_historical_snapshot_record(
|
||||||
server_key=server_slug,
|
server_key=server_slug,
|
||||||
snapshot_type=SNAPSHOT_TYPE_RECENT_MATCHES,
|
snapshot_type=SNAPSHOT_TYPE_RECENT_MATCHES,
|
||||||
@@ -659,6 +649,9 @@ 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(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
),
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -670,14 +663,6 @@ def build_monthly_mvp_snapshot_payload(
|
|||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed monthly MVP snapshot."""
|
"""Return one precomputed monthly MVP snapshot."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Snapshot top MVP mensual",
|
|
||||||
context="historical-monthly-mvp-snapshot",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot = _get_historical_snapshot_record(
|
snapshot = _get_historical_snapshot_record(
|
||||||
server_key=server_id,
|
server_key=server_id,
|
||||||
snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP,
|
snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP,
|
||||||
@@ -723,6 +708,9 @@ def build_monthly_mvp_snapshot_payload(
|
|||||||
),
|
),
|
||||||
"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(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
),
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -734,14 +722,6 @@ def build_monthly_mvp_v2_snapshot_payload(
|
|||||||
server_id: str | None = None,
|
server_id: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed monthly MVP V2 snapshot."""
|
"""Return one precomputed monthly MVP V2 snapshot."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Snapshot top MVP mensual V2",
|
|
||||||
context="historical-monthly-mvp-v2-snapshot",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot = _get_historical_snapshot_record(
|
snapshot = _get_historical_snapshot_record(
|
||||||
server_key=server_id,
|
server_key=server_id,
|
||||||
snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
||||||
@@ -789,6 +769,9 @@ def build_monthly_mvp_v2_snapshot_payload(
|
|||||||
),
|
),
|
||||||
"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(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
),
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -801,15 +784,6 @@ def build_player_event_snapshot_payload(
|
|||||||
view: str = "most-killed",
|
view: str = "most-killed",
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return one precomputed V2 player-event snapshot."""
|
"""Return one precomputed V2 player-event snapshot."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Snapshot metricas V2 mensuales",
|
|
||||||
context="historical-player-events-snapshot",
|
|
||||||
server_key=server_id,
|
|
||||||
limit=limit,
|
|
||||||
metric=view,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
snapshot_type = _resolve_player_event_snapshot_type(view)
|
snapshot_type = _resolve_player_event_snapshot_type(view)
|
||||||
snapshot = _get_historical_snapshot_record(
|
snapshot = _get_historical_snapshot_record(
|
||||||
server_key=server_id,
|
server_key=server_id,
|
||||||
@@ -839,6 +813,9 @@ def build_player_event_snapshot_payload(
|
|||||||
"month_key": payload.get("month_key") if isinstance(payload, dict) else None,
|
"month_key": payload.get("month_key") if isinstance(payload, dict) else None,
|
||||||
"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(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
|
||||||
|
),
|
||||||
"items": sliced_items,
|
"items": sliced_items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -850,28 +827,43 @@ def build_historical_server_summary_payload(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return aggregated historical metrics per server."""
|
"""Return aggregated historical metrics per server."""
|
||||||
if get_historical_data_source_kind() == "rcon":
|
if get_historical_data_source_kind() == "rcon":
|
||||||
data_source = get_historical_data_source()
|
data_source = get_rcon_historical_read_model()
|
||||||
if isinstance(data_source, RconHistoricalDataSource):
|
if data_source is not None:
|
||||||
items = data_source.list_server_summaries(server_key=server_slug)
|
items = data_source.list_server_summaries(server_key=server_slug)
|
||||||
capabilities = data_source.describe_capabilities()
|
capabilities = data_source.describe_capabilities()
|
||||||
return {
|
if items and any(
|
||||||
"status": "ok",
|
item.get("coverage", {}).get("status") != "empty"
|
||||||
"data": {
|
for item in items
|
||||||
"title": (
|
):
|
||||||
"Cobertura historica minima por RCON"
|
return {
|
||||||
if server_slug != ALL_SERVERS_SLUG
|
"status": "ok",
|
||||||
else "Cobertura historica minima RCON agregada"
|
"data": {
|
||||||
),
|
"title": (
|
||||||
"context": "historical-server-summary",
|
"Cobertura historica minima por RCON"
|
||||||
"source": "rcon-historical-read-model",
|
if server_slug != ALL_SERVERS_SLUG
|
||||||
"historical_data_source": "rcon",
|
else "Cobertura historica minima RCON agregada"
|
||||||
"summary_basis": "prospective-rcon-samples",
|
),
|
||||||
"server_slug": server_slug,
|
"context": "historical-server-summary",
|
||||||
"supported": True,
|
"source": "rcon-historical-read-model",
|
||||||
"items": items,
|
"historical_data_source": "rcon",
|
||||||
"capabilities": capabilities,
|
"summary_basis": "prospective-rcon-samples",
|
||||||
},
|
"server_slug": server_slug,
|
||||||
}
|
"supported": True,
|
||||||
|
**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",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"items": items,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
},
|
||||||
|
}
|
||||||
items = list_historical_server_summaries(server_slug=server_slug)
|
items = list_historical_server_summaries(server_slug=server_slug)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -886,6 +878,9 @@ def build_historical_server_summary_payload(
|
|||||||
"summary_basis": "persisted-import",
|
"summary_basis": "persisted-import",
|
||||||
"weekly_ranking_window_days": 7,
|
"weekly_ranking_window_days": 7,
|
||||||
"server_slug": server_slug,
|
"server_slug": server_slug,
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-has-no-summary-coverage",
|
||||||
|
),
|
||||||
"items": items,
|
"items": items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -893,13 +888,6 @@ def build_historical_server_summary_payload(
|
|||||||
|
|
||||||
def build_historical_player_profile_payload(player_id: str) -> dict[str, object]:
|
def build_historical_player_profile_payload(player_id: str) -> dict[str, object]:
|
||||||
"""Return aggregate historical metrics for one player identity."""
|
"""Return aggregate historical metrics for one player identity."""
|
||||||
rcon_payload = _build_rcon_historical_unsupported_payload(
|
|
||||||
title="Perfil historico de jugador",
|
|
||||||
context="historical-player-profile",
|
|
||||||
server_key=player_id,
|
|
||||||
)
|
|
||||||
if rcon_payload is not None:
|
|
||||||
return rcon_payload
|
|
||||||
profile = get_historical_player_profile(player_id)
|
profile = get_historical_player_profile(player_id)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -909,6 +897,9 @@ def build_historical_player_profile_payload(player_id: str) -> dict[str, object]
|
|||||||
"source": "historical-crcon-storage",
|
"source": "historical-crcon-storage",
|
||||||
"player_id": player_id,
|
"player_id": player_id,
|
||||||
"found": profile is not None,
|
"found": profile is not None,
|
||||||
|
**_resolve_historical_fallback_policy(
|
||||||
|
fallback_reason="rcon-historical-read-model-does-not-support-player-profile-yet",
|
||||||
|
),
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -931,45 +922,6 @@ def _get_historical_snapshot_record(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_rcon_historical_unsupported_payload(
|
|
||||||
*,
|
|
||||||
title: str,
|
|
||||||
context: str,
|
|
||||||
server_key: str | None = None,
|
|
||||||
limit: int | None = None,
|
|
||||||
metric: str | None = None,
|
|
||||||
timeframe: str | None = None,
|
|
||||||
) -> dict[str, object] | None:
|
|
||||||
if get_historical_data_source_kind() != "rcon":
|
|
||||||
return None
|
|
||||||
|
|
||||||
data_source = get_historical_data_source()
|
|
||||||
if not isinstance(data_source, RconHistoricalDataSource):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"data": {
|
|
||||||
"title": title,
|
|
||||||
"context": context,
|
|
||||||
"source": "rcon-historical-read-model",
|
|
||||||
"historical_data_source": "rcon",
|
|
||||||
"supported": False,
|
|
||||||
"server_slug": server_key,
|
|
||||||
"limit": limit,
|
|
||||||
"metric": metric,
|
|
||||||
"timeframe": timeframe,
|
|
||||||
"items": [],
|
|
||||||
"item": None,
|
|
||||||
"profile": None,
|
|
||||||
"found": False,
|
|
||||||
"missing_reason": "rcon-minimal-read-model-does-not-support-this-endpoint-yet",
|
|
||||||
"supported_historical_source_for_full_contract": "public-scoreboard",
|
|
||||||
"capabilities": data_source.describe_capabilities(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> dict[str, object]:
|
def _build_historical_snapshot_metadata(snapshot: dict[str, object] | None) -> dict[str, object]:
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
return {
|
return {
|
||||||
@@ -1087,11 +1039,14 @@ def _enrich_server_item(
|
|||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
enriched = dict(item)
|
enriched = dict(item)
|
||||||
enriched["current_map"] = normalize_map_name(enriched.get("current_map"))
|
enriched["current_map"] = normalize_map_name(enriched.get("current_map"))
|
||||||
|
history_url = _resolve_community_history_url(enriched.get("external_server_id"))
|
||||||
|
enriched["community_history_url"] = history_url
|
||||||
|
enriched["community_history_available"] = bool(history_url)
|
||||||
external_server_id = enriched.get("external_server_id")
|
external_server_id = enriched.get("external_server_id")
|
||||||
snapshot_origin = enriched.get("snapshot_origin")
|
snapshot_origin = enriched.get("snapshot_origin")
|
||||||
target = target_index.get(external_server_id)
|
target = target_index.get(external_server_id)
|
||||||
|
|
||||||
if not target or snapshot_origin != "real-a2s":
|
if not target or snapshot_origin not in {"real-a2s", "real-rcon"}:
|
||||||
enriched["host"] = None
|
enriched["host"] = None
|
||||||
enriched["query_port"] = None
|
enriched["query_port"] = None
|
||||||
enriched["game_port"] = None
|
enriched["game_port"] = None
|
||||||
@@ -1129,12 +1084,26 @@ def _should_refresh_snapshot(
|
|||||||
return snapshot_age_seconds > max_snapshot_age_seconds
|
return snapshot_age_seconds > max_snapshot_age_seconds
|
||||||
|
|
||||||
|
|
||||||
def _try_collect_real_time_snapshot() -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
def _try_collect_real_time_snapshot() -> tuple[
|
||||||
|
list[dict[str, object]],
|
||||||
|
list[dict[str, object]],
|
||||||
|
dict[str, object],
|
||||||
|
]:
|
||||||
payload = get_live_data_source().collect_snapshots(persist=False)
|
payload = get_live_data_source().collect_snapshots(persist=False)
|
||||||
snapshots = payload.get("snapshots")
|
snapshots = payload.get("snapshots")
|
||||||
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
|
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
|
||||||
errors = payload.get("errors")
|
errors = payload.get("errors")
|
||||||
return items, list(errors or [])
|
return (
|
||||||
|
items,
|
||||||
|
list(errors or []),
|
||||||
|
{
|
||||||
|
"primary_source": payload.get("primary_source"),
|
||||||
|
"selected_source": payload.get("selected_source"),
|
||||||
|
"fallback_used": bool(payload.get("fallback_used")),
|
||||||
|
"fallback_reason": payload.get("fallback_reason"),
|
||||||
|
"source_attempts": list(payload.get("source_attempts") or []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_servers_response(
|
def _build_servers_response(
|
||||||
@@ -1147,6 +1116,7 @@ def _build_servers_response(
|
|||||||
refresh_attempted: bool,
|
refresh_attempted: bool,
|
||||||
refresh_status: str,
|
refresh_status: str,
|
||||||
refresh_errors: list[dict[str, object]],
|
refresh_errors: list[dict[str, object]],
|
||||||
|
source_policy: dict[str, object],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
freshness = (
|
freshness = (
|
||||||
"fresh"
|
"fresh"
|
||||||
@@ -1168,6 +1138,7 @@ def _build_servers_response(
|
|||||||
"refresh_attempted": refresh_attempted,
|
"refresh_attempted": refresh_attempted,
|
||||||
"refresh_status": refresh_status,
|
"refresh_status": refresh_status,
|
||||||
"refresh_errors": refresh_errors,
|
"refresh_errors": refresh_errors,
|
||||||
|
**source_policy,
|
||||||
"items": items,
|
"items": items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1191,3 +1162,100 @@ def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return snapshot_age_seconds // 60
|
return snapshot_age_seconds // 60
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_historical_fallback_policy(*, fallback_reason: str) -> dict[str, object]:
|
||||||
|
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
|
||||||
|
return build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
role="primary",
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_RCON,
|
||||||
|
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
fallback_used=True,
|
||||||
|
fallback_reason=fallback_reason,
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_RCON,
|
||||||
|
role="primary",
|
||||||
|
status="unsupported",
|
||||||
|
reason=fallback_reason,
|
||||||
|
),
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
role="fallback",
|
||||||
|
status="success",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_live_source_policy_from_items(
|
||||||
|
items: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
refresh_attempted: bool,
|
||||||
|
refresh_errors: list[dict[str, object]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
selected_source = "persisted-snapshot"
|
||||||
|
fallback_used = False
|
||||||
|
fallback_reason = None
|
||||||
|
snapshot_origins = {
|
||||||
|
str(item.get("snapshot_origin") or "").strip()
|
||||||
|
for item in items
|
||||||
|
if item.get("snapshot_origin")
|
||||||
|
}
|
||||||
|
if "real-rcon" in snapshot_origins:
|
||||||
|
selected_source = SOURCE_KIND_RCON
|
||||||
|
elif "real-a2s" in snapshot_origins:
|
||||||
|
selected_source = LIVE_SOURCE_A2S
|
||||||
|
if get_live_data_source_kind() == SOURCE_KIND_RCON:
|
||||||
|
fallback_used = True
|
||||||
|
fallback_reason = "persisted-live-snapshot-came-from-a2s"
|
||||||
|
|
||||||
|
attempt_status = "success" if items else ("error" if refresh_attempted else "cached")
|
||||||
|
attempt_reason = None if items else "no-live-snapshot-items"
|
||||||
|
if refresh_errors and attempt_reason is None:
|
||||||
|
attempt_reason = "live-refresh-errors-present"
|
||||||
|
|
||||||
|
return build_source_policy(
|
||||||
|
primary_source=get_live_data_source_kind(),
|
||||||
|
selected_source=selected_source,
|
||||||
|
fallback_used=fallback_used,
|
||||||
|
fallback_reason=fallback_reason,
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=selected_source,
|
||||||
|
role="served-response",
|
||||||
|
status=attempt_status,
|
||||||
|
reason=attempt_reason,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_live_response_source(source_policy: dict[str, object]) -> str:
|
||||||
|
selected_source = str(source_policy.get("selected_source") or "")
|
||||||
|
if selected_source == SOURCE_KIND_RCON:
|
||||||
|
return "real-time-rcon-refresh"
|
||||||
|
if selected_source == LIVE_SOURCE_A2S:
|
||||||
|
return "real-time-a2s-fallback"
|
||||||
|
return "real-time-refresh"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_community_history_url(external_server_id: object) -> str | None:
|
||||||
|
normalized_server_id = str(external_server_id or "").strip()
|
||||||
|
if not normalized_server_id:
|
||||||
|
return None
|
||||||
|
for server in DEFAULT_HISTORICAL_SERVERS:
|
||||||
|
if server.slug == normalized_server_id:
|
||||||
|
return f"{server.scoreboard_base_url}/games"
|
||||||
|
return None
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from .config import get_historical_data_source_kind
|
from .config import get_historical_data_source_kind
|
||||||
|
from .data_sources import (
|
||||||
|
SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
SOURCE_KIND_RCON,
|
||||||
|
build_source_attempt,
|
||||||
|
build_source_policy,
|
||||||
|
)
|
||||||
from .player_event_models import PlayerEventRecord
|
from .player_event_models import PlayerEventRecord
|
||||||
from .providers.player_event_source_provider import PublicScoreboardPlayerEventSource
|
from .providers.player_event_source_provider import PublicScoreboardPlayerEventSource
|
||||||
|
|
||||||
@@ -52,11 +59,53 @@ class RconPlayerEventSource:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_player_event_source() -> PlayerEventSource:
|
@dataclass(frozen=True, slots=True)
|
||||||
"""Select the event adapter that best matches the configured historical source."""
|
class PlayerEventSourceSelection:
|
||||||
|
"""Resolved player-event adapter plus source-policy metadata."""
|
||||||
|
|
||||||
|
source: PlayerEventSource
|
||||||
|
source_policy: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_player_event_source() -> PlayerEventSourceSelection:
|
||||||
|
"""Select the event adapter with safe fallback when raw RCON events are unavailable."""
|
||||||
source_kind = get_historical_data_source_kind()
|
source_kind = get_historical_data_source_kind()
|
||||||
if source_kind == "public-scoreboard":
|
if source_kind == SOURCE_KIND_PUBLIC_SCOREBOARD:
|
||||||
return PublicScoreboardPlayerEventSource()
|
return PlayerEventSourceSelection(
|
||||||
if source_kind == "rcon":
|
source=PublicScoreboardPlayerEventSource(),
|
||||||
return RconPlayerEventSource()
|
source_policy=build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
selected_source="public-scoreboard-match-summary",
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||||
|
role="primary",
|
||||||
|
status="success",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if source_kind == SOURCE_KIND_RCON:
|
||||||
|
return PlayerEventSourceSelection(
|
||||||
|
source=PublicScoreboardPlayerEventSource(),
|
||||||
|
source_policy=build_source_policy(
|
||||||
|
primary_source=SOURCE_KIND_RCON,
|
||||||
|
selected_source="public-scoreboard-match-summary",
|
||||||
|
fallback_used=True,
|
||||||
|
fallback_reason="rcon-player-events-not-implemented-yet",
|
||||||
|
source_attempts=[
|
||||||
|
build_source_attempt(
|
||||||
|
source=SOURCE_KIND_RCON,
|
||||||
|
role="primary",
|
||||||
|
status="unsupported",
|
||||||
|
reason="rcon-player-events-not-implemented-yet",
|
||||||
|
),
|
||||||
|
build_source_attempt(
|
||||||
|
source="public-scoreboard-match-summary",
|
||||||
|
role="fallback",
|
||||||
|
status="success",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
raise ValueError(f"Unsupported player event source: {source_kind}")
|
raise ValueError(f"Unsupported player event source: {source_kind}")
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ from .config import (
|
|||||||
get_player_event_refresh_overlap_hours,
|
get_player_event_refresh_overlap_hours,
|
||||||
get_player_event_refresh_retry_delay_seconds,
|
get_player_event_refresh_retry_delay_seconds,
|
||||||
)
|
)
|
||||||
from .data_sources import get_historical_data_source
|
from .data_sources import resolve_historical_ingestion_data_source
|
||||||
from .historical_storage import list_historical_servers
|
from .historical_storage import list_historical_servers
|
||||||
from .player_event_source import get_player_event_source
|
from .player_event_source import resolve_player_event_source
|
||||||
from .player_event_storage import (
|
from .player_event_storage import (
|
||||||
finalize_player_event_ingestion_run,
|
finalize_player_event_ingestion_run,
|
||||||
finalize_player_event_progress,
|
finalize_player_event_progress,
|
||||||
@@ -62,8 +62,9 @@ def run_player_event_refresh(
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
initialize_player_event_storage()
|
initialize_player_event_storage()
|
||||||
data_source = get_historical_data_source()
|
data_source, data_source_policy = resolve_historical_ingestion_data_source()
|
||||||
event_source = get_player_event_source()
|
event_source_selection = resolve_player_event_source()
|
||||||
|
event_source = event_source_selection.source
|
||||||
resolved_page_size = page_size or get_historical_crcon_page_size()
|
resolved_page_size = page_size or get_historical_crcon_page_size()
|
||||||
resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers()
|
resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers()
|
||||||
resolved_overlap_hours = (
|
resolved_overlap_hours = (
|
||||||
@@ -153,7 +154,9 @@ def run_player_event_refresh(
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"mode": "refresh",
|
"mode": "refresh",
|
||||||
"source_provider": data_source.source_kind,
|
"source_provider": data_source.source_kind,
|
||||||
|
"source_policy": data_source_policy,
|
||||||
"event_adapter": event_source.source_kind,
|
"event_adapter": event_source.source_kind,
|
||||||
|
"event_source_policy": event_source_selection.source_policy,
|
||||||
"page_size": resolved_page_size,
|
"page_size": resolved_page_size,
|
||||||
"detail_workers": resolved_detail_workers,
|
"detail_workers": resolved_detail_workers,
|
||||||
"overlap_hours": resolved_overlap_hours,
|
"overlap_hours": resolved_overlap_hours,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"lock_token": "3b2e9b3e3e614fefa86d9e14e4e820ad",
|
|
||||||
"holder": "app.historical_runner refresh:all-servers [/app/app/historical_runner.py --hourly]",
|
|
||||||
"started_at": "2026-03-25T11:23:47.790251Z",
|
|
||||||
"hostname": "2a2c611b2eca",
|
|
||||||
"pid": 1,
|
|
||||||
"cwd": "/app"
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env.example
|
- ./backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-a2s}
|
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-public-scoreboard}
|
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10}
|
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10}
|
||||||
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-}
|
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-}
|
||||||
ports:
|
ports:
|
||||||
@@ -24,8 +24,8 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env.example
|
- ./backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-a2s}
|
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-public-scoreboard}
|
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10}
|
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10}
|
||||||
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-}
|
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -38,15 +38,15 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
container_name: hll-vietnam-rcon-historical-worker
|
container_name: hll-vietnam-rcon-historical-worker
|
||||||
command: ["python", "-m", "app.rcon_historical_worker", "loop", "--interval", "120"]
|
command: ["python", "-m", "app.rcon_historical_worker", "loop"]
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env.example
|
- ./backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-a2s}
|
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-public-scoreboard}
|
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
|
||||||
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10}
|
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-10}
|
||||||
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-}
|
HLL_BACKEND_RCON_TARGETS: ${HLL_BACKEND_RCON_TARGETS:-}
|
||||||
HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-120}
|
HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600}
|
||||||
HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2}
|
HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2}
|
||||||
HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15}
|
HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Progressive enhancement for local frontend-backend checks.
|
// Progressive enhancement for local frontend-backend checks.
|
||||||
const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000;
|
const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000;
|
||||||
const SERVER_HISTORY_URLS = Object.freeze({
|
const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({
|
||||||
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games",
|
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games",
|
||||||
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games",
|
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games",
|
||||||
|
"comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games",
|
||||||
});
|
});
|
||||||
const COMMUNITY_CLANS = Object.freeze([
|
const COMMUNITY_CLANS = Object.freeze([
|
||||||
{
|
{
|
||||||
@@ -244,13 +245,14 @@ function renderServerStatsCard(server) {
|
|||||||
const statusLabel = formatServerStatus(server.status);
|
const statusLabel = formatServerStatus(server.status);
|
||||||
const stateClass =
|
const stateClass =
|
||||||
server.status === "online" ? "server-state--online" : "server-state--offline";
|
server.status === "online" ? "server-state--online" : "server-state--offline";
|
||||||
const isRealSnapshot = server.snapshot_origin === "real-a2s";
|
const isRealSnapshot = isRealLiveSnapshot(server);
|
||||||
const currentMap = server.current_map || "Sin mapa disponible";
|
const currentMap = server.current_map || "Sin mapa disponible";
|
||||||
const region = server.region || "Region pendiente";
|
const region = server.region || "Region pendiente";
|
||||||
const players = Number.isFinite(server.players) ? server.players : 0;
|
const players = Number.isFinite(server.players) ? server.players : 0;
|
||||||
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
|
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
|
||||||
const actionMarkup = renderServerAction(server);
|
const actionMarkup = renderServerAction(server);
|
||||||
const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference";
|
const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference";
|
||||||
|
const eyebrowLabel = isRealSnapshot ? "Servidor de comunidad" : "Referencia actual";
|
||||||
const quickFacts = renderQuickFacts([
|
const quickFacts = renderQuickFacts([
|
||||||
{ label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" },
|
{ label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" },
|
||||||
{ label: "Region", value: region },
|
{ label: "Region", value: region },
|
||||||
@@ -260,7 +262,7 @@ function renderServerStatsCard(server) {
|
|||||||
<article class="server-card server-card--stats ${cardVariantClass}">
|
<article class="server-card server-card--stats ${cardVariantClass}">
|
||||||
<div class="server-card__top server-card__top--stats">
|
<div class="server-card__top server-card__top--stats">
|
||||||
<div class="server-card__identity">
|
<div class="server-card__identity">
|
||||||
<p class="server-card__eyebrow">${isRealSnapshot ? "Servidor de comunidad" : "Referencia actual"}</p>
|
<p class="server-card__eyebrow">${escapeHtml(eyebrowLabel)}</p>
|
||||||
<h3>${escapeHtml(serverName)}</h3>
|
<h3>${escapeHtml(serverName)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="server-card__status-column">
|
<div class="server-card__status-column">
|
||||||
@@ -279,7 +281,21 @@ function renderServerSections(latestItems) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderServerAction(server) {
|
function renderServerAction(server) {
|
||||||
const historyUrl = getServerHistoryUrl(server);
|
const historyState = getServerHistoryState(server);
|
||||||
|
if (!historyState.available) {
|
||||||
|
return `
|
||||||
|
<div class="server-card__actions">
|
||||||
|
<span
|
||||||
|
class="server-action-link server-action-link--disabled"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Historico no disponible
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyUrl = historyState.url;
|
||||||
if (!historyUrl) {
|
if (!historyUrl) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -396,6 +412,10 @@ function renderQuickFacts(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getServerHistoryUrl(server) {
|
function getServerHistoryUrl(server) {
|
||||||
|
if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) {
|
||||||
|
return server.community_history_url.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const externalServerId =
|
const externalServerId =
|
||||||
typeof server?.external_server_id === "string"
|
typeof server?.external_server_id === "string"
|
||||||
? server.external_server_id.trim()
|
? server.external_server_id.trim()
|
||||||
@@ -404,7 +424,15 @@ function getServerHistoryUrl(server) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return SERVER_HISTORY_URLS[externalServerId] || "";
|
return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerHistoryState(server) {
|
||||||
|
const historyUrl = getServerHistoryUrl(server);
|
||||||
|
return {
|
||||||
|
available: Boolean(historyUrl),
|
||||||
|
url: historyUrl,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPrimaryServerItems(items) {
|
function selectPrimaryServerItems(items) {
|
||||||
@@ -412,12 +440,12 @@ function selectPrimaryServerItems(items) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const realItems = items.filter(isRealA2SSnapshot);
|
const realItems = items.filter(isRealLiveSnapshot);
|
||||||
return realItems.length > 0 ? realItems : items;
|
return realItems.length > 0 ? realItems : items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRealA2SSnapshot(item) {
|
function isRealLiveSnapshot(item) {
|
||||||
return item?.snapshot_origin === "real-a2s";
|
return item?.snapshot_origin === "real-a2s" || item?.snapshot_origin === "real-rcon";
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveSnapshotState(serversData) {
|
function deriveSnapshotState(serversData) {
|
||||||
|
|||||||
@@ -161,6 +161,38 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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