Implement real-time stale snapshot refresh
This commit is contained in:
@@ -62,7 +62,7 @@ Variables opcionales:
|
||||
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
|
||||
|
||||
El `frontend/index.html` viene preparado para volver a consultar el bloque de
|
||||
servidores cada `60000` ms (`60s`) sin recargar la pagina completa. La landing
|
||||
servidores cada `120000` ms (`120s`) sin recargar la pagina completa. La landing
|
||||
lee ese valor desde `data-server-refresh-ms`, por lo que puede ajustarse en el
|
||||
HTML si una demo local necesita un intervalo distinto.
|
||||
|
||||
@@ -110,10 +110,27 @@ normaliza espacios y barras finales para mantener la comparacion con el header
|
||||
- `GET /api/servers/history?limit=20`
|
||||
- `GET /api/servers/{id}/history?limit=20`
|
||||
|
||||
`GET /api/servers` devuelve en esta fase un payload controlado con servidores
|
||||
actuales de Hell Let Loose usados como referencia provisional. La respuesta
|
||||
incluye `title`, `context`, `source` e `items`, y no representa todavia datos
|
||||
reales de HLL Vietnam ni una integracion externa validada.
|
||||
`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
|
||||
esta vencido, el endpoint intenta una consulta A2S real inmediata contra los 2
|
||||
servidores configurados antes de responder.
|
||||
|
||||
La respuesta incluye metadata de frescura pensada para frontend:
|
||||
|
||||
- `last_snapshot_at`
|
||||
- `snapshot_age_seconds`
|
||||
- `snapshot_age_minutes`
|
||||
- `max_snapshot_age_seconds`
|
||||
- `is_stale`
|
||||
- `freshness`
|
||||
- `source`
|
||||
- `refresh_attempted`
|
||||
- `refresh_status`
|
||||
|
||||
Si la consulta real falla, `/api/servers` devuelve el ultimo snapshot valido
|
||||
disponible marcado como stale. Si no existe ningun snapshot valido, responde
|
||||
`items: []` en lugar de reintroducir servidores de respaldo ajenos a la
|
||||
comunidad.
|
||||
|
||||
Los endpoints historicos leen la persistencia local SQLite creada por el
|
||||
colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con
|
||||
@@ -244,7 +261,7 @@ Ese comando ejecuta capturas persistidas de forma repetida usando el mismo
|
||||
flujo del colector y la base SQLite local. Por defecto:
|
||||
|
||||
- usa `--source auto`
|
||||
- espera `60` segundos entre ejecuciones
|
||||
- espera `120` segundos entre ejecuciones
|
||||
- permite fallback controlado si A2S no responde
|
||||
- sigue en ejecucion hasta que se detiene manualmente
|
||||
|
||||
@@ -279,9 +296,9 @@ Flujo local recomendado para ver datos vivos en la landing:
|
||||
```
|
||||
|
||||
3. Servir `frontend/` con un servidor local sencillo y abrir la landing. El
|
||||
frontend volvera a pedir `/api/servers` y `/api/servers/latest` cada `60`
|
||||
segundos, por lo que los cambios de mapa o poblacion apareceran sin recarga
|
||||
manual cuando existan snapshots nuevos.
|
||||
frontend volvera a pedir `/api/servers` cada `120` segundos, por lo que los
|
||||
cambios de mapa o poblacion apareceran sin recarga manual cuando existan
|
||||
snapshots nuevos.
|
||||
|
||||
Este mecanismo deja el refresco desacoplado del servidor HTTP y es facil de
|
||||
reemplazar mas adelante por un scheduler mas serio sin rehacer el colector.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Placeholder payload builders for the HLL Vietnam backend."""
|
||||
"""Payload builders for the HLL Vietnam backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .collector import collect_server_snapshots
|
||||
from .config import get_refresh_interval_seconds
|
||||
from .server_targets import load_a2s_targets
|
||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||
|
||||
@@ -52,39 +56,69 @@ def build_discord_payload() -> dict[str, object]:
|
||||
|
||||
|
||||
def build_servers_payload() -> dict[str, object]:
|
||||
"""Return a controlled placeholder for current Hell Let Loose servers."""
|
||||
"""Return current server status, refreshing stale snapshots before responding."""
|
||||
max_snapshot_age_seconds = get_refresh_interval_seconds()
|
||||
persisted_items = _select_primary_snapshot_items(
|
||||
_enrich_server_items(list_latest_snapshots())
|
||||
)
|
||||
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
|
||||
persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at)
|
||||
|
||||
refresh_attempted = _should_refresh_snapshot(
|
||||
persisted_items,
|
||||
persisted_snapshot_age_seconds,
|
||||
max_snapshot_age_seconds,
|
||||
)
|
||||
refresh_errors: list[dict[str, object]] = []
|
||||
|
||||
if refresh_attempted:
|
||||
refreshed_items, refresh_errors = _try_collect_real_time_snapshot()
|
||||
if refreshed_items:
|
||||
refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items)
|
||||
refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at)
|
||||
return _build_servers_response(
|
||||
items=refreshed_items,
|
||||
response_source="real-time-a2s-refresh",
|
||||
last_snapshot_at=refreshed_snapshot_at,
|
||||
snapshot_age_seconds=refreshed_snapshot_age_seconds,
|
||||
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
||||
refresh_attempted=True,
|
||||
refresh_status="success",
|
||||
refresh_errors=refresh_errors,
|
||||
)
|
||||
|
||||
if persisted_items:
|
||||
refresh_status = "failed" if refresh_attempted else "not-needed"
|
||||
response_source = (
|
||||
"persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot"
|
||||
)
|
||||
return _build_servers_response(
|
||||
items=persisted_items,
|
||||
response_source=response_source,
|
||||
last_snapshot_at=persisted_snapshot_at,
|
||||
snapshot_age_seconds=persisted_snapshot_age_seconds,
|
||||
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
||||
refresh_attempted=refresh_attempted,
|
||||
refresh_status=refresh_status,
|
||||
refresh_errors=refresh_errors,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Servidores actuales de Hell Let Loose",
|
||||
"context": "current-hll-reference",
|
||||
"source": "controlled-placeholder",
|
||||
"items": [
|
||||
{
|
||||
"server_name": "HLL ESP Tactical Rotation",
|
||||
"status": "online",
|
||||
"players": 74,
|
||||
"max_players": 100,
|
||||
"current_map": "Sainte-Marie-du-Mont",
|
||||
"region": "EU",
|
||||
},
|
||||
{
|
||||
"server_name": "HLL LATAM Night Offensive",
|
||||
"status": "online",
|
||||
"players": 51,
|
||||
"max_players": 100,
|
||||
"current_map": "Carentan",
|
||||
"region": "LATAM",
|
||||
},
|
||||
{
|
||||
"server_name": "HLL Community Reserve",
|
||||
"status": "offline",
|
||||
"players": 0,
|
||||
"max_players": 100,
|
||||
"current_map": None,
|
||||
"region": "EU",
|
||||
},
|
||||
],
|
||||
"title": "Estado actual de servidores",
|
||||
"context": "current-hll-status",
|
||||
"source": "no-snapshot-available",
|
||||
"last_snapshot_at": None,
|
||||
"snapshot_age_seconds": None,
|
||||
"snapshot_age_minutes": None,
|
||||
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
||||
"is_stale": True,
|
||||
"freshness": "stale",
|
||||
"refresh_attempted": refresh_attempted,
|
||||
"refresh_status": "failed" if refresh_attempted else "not-needed",
|
||||
"refresh_errors": refresh_errors,
|
||||
"items": [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -159,6 +193,15 @@ def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, objec
|
||||
return enriched_items
|
||||
|
||||
|
||||
def _select_primary_snapshot_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
real_items = [
|
||||
item
|
||||
for item in items
|
||||
if item.get("snapshot_origin") == "real-a2s"
|
||||
]
|
||||
return real_items or items
|
||||
|
||||
|
||||
def _enrich_server_item(
|
||||
item: dict[str, object],
|
||||
target_index: dict[str, object],
|
||||
@@ -178,3 +221,97 @@ def _enrich_server_item(
|
||||
enriched["query_port"] = target.query_port
|
||||
enriched["game_port"] = target.game_port
|
||||
return enriched
|
||||
|
||||
|
||||
def _resolve_last_snapshot_at(items: list[dict[str, object]]) -> str | None:
|
||||
timestamps = [
|
||||
str(item["captured_at"])
|
||||
for item in items
|
||||
if item.get("captured_at")
|
||||
]
|
||||
if not timestamps:
|
||||
return None
|
||||
|
||||
return max(timestamps)
|
||||
|
||||
|
||||
def _should_refresh_snapshot(
|
||||
items: list[dict[str, object]],
|
||||
snapshot_age_seconds: int | None,
|
||||
max_snapshot_age_seconds: int,
|
||||
) -> bool:
|
||||
if not items:
|
||||
return True
|
||||
|
||||
if snapshot_age_seconds is None:
|
||||
return True
|
||||
|
||||
return snapshot_age_seconds > max_snapshot_age_seconds
|
||||
|
||||
|
||||
def _try_collect_real_time_snapshot() -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
||||
payload = collect_server_snapshots(
|
||||
source_mode="a2s",
|
||||
allow_controlled_fallback=False,
|
||||
persist=True,
|
||||
)
|
||||
snapshots = payload.get("snapshots")
|
||||
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
|
||||
errors = payload.get("errors")
|
||||
return items, list(errors or [])
|
||||
|
||||
|
||||
def _build_servers_response(
|
||||
*,
|
||||
items: list[dict[str, object]],
|
||||
response_source: str,
|
||||
last_snapshot_at: str | None,
|
||||
snapshot_age_seconds: int | None,
|
||||
max_snapshot_age_seconds: int,
|
||||
refresh_attempted: bool,
|
||||
refresh_status: str,
|
||||
refresh_errors: list[dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
freshness = (
|
||||
"fresh"
|
||||
if snapshot_age_seconds is not None and snapshot_age_seconds <= max_snapshot_age_seconds
|
||||
else "stale"
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Estado actual de servidores",
|
||||
"context": "current-hll-status",
|
||||
"source": response_source,
|
||||
"last_snapshot_at": last_snapshot_at,
|
||||
"snapshot_age_seconds": snapshot_age_seconds,
|
||||
"snapshot_age_minutes": _to_snapshot_age_minutes(snapshot_age_seconds),
|
||||
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
||||
"is_stale": freshness == "stale",
|
||||
"freshness": freshness,
|
||||
"refresh_attempted": refresh_attempted,
|
||||
"refresh_status": refresh_status,
|
||||
"refresh_errors": refresh_errors,
|
||||
"items": items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _calculate_snapshot_age_seconds(timestamp: str | None) -> int | None:
|
||||
if not timestamp:
|
||||
return None
|
||||
|
||||
normalized = timestamp.replace("Z", "+00:00")
|
||||
captured_at = datetime.fromisoformat(normalized)
|
||||
if captured_at.tzinfo is None:
|
||||
captured_at = captured_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
age = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
|
||||
return max(0, int(age.total_seconds()))
|
||||
|
||||
|
||||
def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None:
|
||||
if snapshot_age_seconds is None:
|
||||
return None
|
||||
|
||||
return snapshot_age_seconds // 60
|
||||
|
||||
Reference in New Issue
Block a user