Implement real-time stale snapshot refresh
This commit is contained in:
102
ai/tasks/done/TASK-022-real-time-server-snapshot-refresh.md
Normal file
102
ai/tasks/done/TASK-022-real-time-server-snapshot-refresh.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# TASK-022-real-time-server-snapshot-refresh
|
||||
|
||||
## Goal
|
||||
Hacer que `GET /api/servers` devuelva datos realmente actuales de los 2 servidores de la comunidad, refrescando el snapshot cuando el último estado persistido esté vencido respecto al objetivo de 120 segundos.
|
||||
|
||||
## Context
|
||||
La implementación actual ya tiene snapshots persistidos y polling desde frontend, pero el backend está sirviendo datos antiguos desde almacenamiento local (`local-snapshot-storage`) incluso cuando el snapshot tiene varias horas de antigüedad. Eso rompe el objetivo de mostrar la situación actual de los servidores. El frontend no debe depender de un snapshot viejo si el backend puede consultar el estado real de los servidores en ese momento.
|
||||
|
||||
## Steps
|
||||
1. Revisar la implementación actual de:
|
||||
- `GET /api/servers`
|
||||
- carga de snapshots persistidos
|
||||
- lógica de refresco objetivo a 120 s
|
||||
- consulta real A2S o equivalente que ya exista en el backend
|
||||
2. Identificar por qué el backend está devolviendo snapshots antiguos sin forzar actualización.
|
||||
3. Ajustar la lógica para que:
|
||||
- si el snapshot actual tiene menos de 120 s, pueda reutilizarse
|
||||
- si el snapshot actual supera 120 s, el backend intente una consulta real inmediata de los 2 servidores antes de responder
|
||||
4. Si la consulta real tiene éxito:
|
||||
- persistir el nuevo snapshot
|
||||
- devolver ese snapshot fresco al frontend
|
||||
5. Si la consulta real falla:
|
||||
- devolver el último snapshot válido disponible
|
||||
- marcar claramente en el payload que el dato es stale o desactualizado
|
||||
6. Asegurar que el payload de `/api/servers` incluya campos claros para frontend, como por ejemplo:
|
||||
- `last_snapshot_at`
|
||||
- indicador de stale/fresh
|
||||
- edad del snapshot en segundos o minutos
|
||||
- origen real del dato devuelto
|
||||
7. Ajustar el frontend solo si hace falta para que:
|
||||
- no presente como “actual” un snapshot viejo
|
||||
- pueda mostrar una nota honesta si el dato está desactualizado
|
||||
8. Mantener el alcance centrado en datos actuales de los 2 servidores reales de la comunidad.
|
||||
9. No reintroducir servidores ficticios o de referencia ajenos a la comunidad.
|
||||
10. Al completar la implementación, dejar el repositorio en estado consistente y preparado para integración.
|
||||
11. Hacer commit de los cambios realizados y hacer push al repositorio remoto siguiendo el workflow del proyecto, siempre que el entorno tenga permisos y configuración git disponibles.
|
||||
|
||||
## Files to Read First
|
||||
- AGENTS.md
|
||||
- ai/repo-context.md
|
||||
- ai/architecture-index.md
|
||||
- docs/frontend-backend-contract.md
|
||||
- docs/current-hll-servers-source-plan.md
|
||||
- backend/README.md
|
||||
- backend/app/__init__.py
|
||||
- backend/app/main.py
|
||||
- backend/app/routes.py
|
||||
- backend/app/payloads.py
|
||||
- backend/app/config.py
|
||||
- cualquier servicio o módulo de collector/query ya existente en `backend/app/`
|
||||
- frontend/index.html
|
||||
- frontend/assets/js/main.js
|
||||
- frontend/assets/css/styles.css
|
||||
|
||||
## Expected Files to Modify
|
||||
- backend/app/routes.py
|
||||
- backend/app/payloads.py
|
||||
- backend/app/config.py
|
||||
- backend/app/main.py
|
||||
- opcionalmente uno o más archivos de servicio existentes del backend si ahí vive la lógica de snapshots o de consulta real
|
||||
- frontend/assets/js/main.js
|
||||
- opcionalmente frontend/index.html y frontend/assets/css/styles.css si hace falta ajustar el estado visual stale/fresh
|
||||
- backend/README.md si el comportamiento operativo cambia
|
||||
|
||||
## Constraints
|
||||
- No reintroducir servidores ficticios.
|
||||
- No usar datos viejos como si fueran datos actuales.
|
||||
- No consultar fuentes externas directamente desde frontend.
|
||||
- Mantener la arquitectura frontend → backend → consulta real/persistencia.
|
||||
- No romper el fallback si la consulta real falla.
|
||||
- No hacer cambios destructivos.
|
||||
- Mantener la solución centrada en los 2 servidores reales de la comunidad.
|
||||
- Si el entorno no permite push, dejar el commit local realizado e informar claramente de ello en el resumen final.
|
||||
|
||||
## Validation
|
||||
- Si el snapshot persistido tiene más de 120 s, `/api/servers` intenta refrescarlo antes de responder.
|
||||
- Si la consulta real tiene éxito, el frontend recibe datos actuales de los 2 servidores reales.
|
||||
- Si la consulta real falla, el backend devuelve el último snapshot válido con una indicación clara de dato stale.
|
||||
- La UI no presenta como actual un snapshot antiguo.
|
||||
- No aparecen servidores ajenos a la comunidad.
|
||||
- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite.
|
||||
|
||||
## Change Budget
|
||||
- Preferir menos de 8 archivos modificados o creados.
|
||||
- Preferir menos de 320 líneas cambiadas.
|
||||
## Outcome
|
||||
- `backend/app/payloads.py` deja de servir `/api/servers` como simple lectura del ultimo snapshot persistido: ahora reutiliza cache solo si sigue dentro del objetivo de `120` segundos y, si no, intenta un refresco A2S inmediato antes de responder.
|
||||
- El payload principal de `/api/servers` ahora expone `last_snapshot_at`, `snapshot_age_seconds`, `snapshot_age_minutes`, `max_snapshot_age_seconds`, `is_stale`, `freshness`, `source`, `refresh_attempted`, `refresh_status` y `refresh_errors`.
|
||||
- Si el refresco real falla, backend devuelve el ultimo snapshot valido con marca clara de dato stale; si no existe snapshot valido, responde `items: []` en vez de reintroducir servidores ficticios o de referencia.
|
||||
- `frontend/assets/js/main.js` usa la metadata de frescura del backend para no presentar un snapshot viejo como si fuera actual y muestra una nota honesta cuando el dato esta desactualizado.
|
||||
- `backend/README.md` y `docs/frontend-backend-contract.md` quedan alineados con el comportamiento real del endpoint.
|
||||
|
||||
## Validation Result
|
||||
- Validado con `python -m py_compile backend/app/config.py backend/app/payloads.py backend/app/routes.py backend/app/main.py`.
|
||||
- Validado con `node --check frontend/assets/js/main.js`.
|
||||
- Validado con una comprobacion local desde Python que cubre tres rutas de `build_servers_payload()`: refresco real exitoso, fallo de refresco con fallback stale al ultimo snapshot valido y ausencia total de snapshot valido.
|
||||
- Validado ejecutando `build_servers_payload()` contra el entorno actual: el backend devolvio `source: "real-time-a2s-refresh"`, `freshness: "fresh"` e `item_count: 2`.
|
||||
- Revisado en `git diff --name-only`: el alcance queda limitado a la task, documentacion del contrato/backend, frontend del panel de servidores y backend de payload/config, ademas de archivos ya presentes en el worktree (`ai/worker.lock` y `backend/data/hll_vietnam_dev.sqlite3`).
|
||||
|
||||
## Decision Notes
|
||||
- `/api/servers` se mantiene como endpoint principal para la landing y pasa a tratar la persistencia local como cache, no como fuente autoritativa cuando el snapshot ya vencio.
|
||||
- Se elimina el uso de respaldo controlado en este endpoint cuando no hay snapshot valido disponible para cumplir la restriccion de no reintroducir servidores ajenos a la comunidad.
|
||||
@@ -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
|
||||
|
||||
@@ -120,10 +120,10 @@ Ejemplo JSON:
|
||||
|
||||
### `GET /api/servers`
|
||||
|
||||
- Proposito: exponer un bloque provisional de servidores actuales de Hell Let Loose como referencia temporal para la comunidad.
|
||||
- Proposito: exponer el estado actual de los 2 servidores reales de la comunidad desde backend, usando el ultimo snapshot valido y forzando refresco real cuando el cache local supere el objetivo de 120 segundos.
|
||||
- Metodo HTTP: `GET`
|
||||
- Ruta: `/api/servers`
|
||||
- Estado actual: placeholder implementado
|
||||
- Estado actual: implementado con refresco A2S bajo demanda y fallback a snapshot persistido stale
|
||||
|
||||
Ejemplo JSON:
|
||||
|
||||
@@ -131,28 +131,41 @@ Ejemplo JSON:
|
||||
{
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Servidores actuales de Hell Let Loose",
|
||||
"context": "current-hll-reference",
|
||||
"source": "controlled-placeholder",
|
||||
"title": "Estado actual de servidores",
|
||||
"context": "current-hll-status",
|
||||
"source": "real-time-a2s-refresh",
|
||||
"last_snapshot_at": "2026-03-20T18:37:58.628122Z",
|
||||
"snapshot_age_seconds": 0,
|
||||
"snapshot_age_minutes": 0,
|
||||
"max_snapshot_age_seconds": 120,
|
||||
"is_stale": false,
|
||||
"freshness": "fresh",
|
||||
"refresh_attempted": true,
|
||||
"refresh_status": "success",
|
||||
"refresh_errors": [],
|
||||
"items": [
|
||||
{
|
||||
"server_name": "HLL ESP Tactical Rotation",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"players": 74,
|
||||
"max_players": 100,
|
||||
"current_map": "Sainte-Marie-du-Mont",
|
||||
"region": "EU"
|
||||
"region": "ES",
|
||||
"snapshot_origin": "real-a2s",
|
||||
"captured_at": "2026-03-20T18:37:58.628122Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notas del placeholder actual:
|
||||
Notas del comportamiento actual:
|
||||
|
||||
- El contenido representa servidores actuales de Hell Let Loose, no servidores de HLL Vietnam.
|
||||
- `context` permite al frontend etiquetar el bloque como referencia provisional.
|
||||
- `source` indica que la respuesta actual sale de datos controlados del backend.
|
||||
- Si el snapshot persistido tiene `120` segundos o menos, puede reutilizarse sin refresco inmediato.
|
||||
- Si el snapshot supera ese umbral, backend intenta una consulta A2S real antes de responder.
|
||||
- Si la consulta real falla, backend devuelve el ultimo snapshot valido con `is_stale: true`.
|
||||
- Si no existe ningun snapshot valido, backend responde `items: []` y no inventa servidores de referencia.
|
||||
|
||||
### `GET /api/servers/latest`
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Progressive enhancement for local frontend-backend checks.
|
||||
const RECENT_SNAPSHOT_WINDOW_MS = 30 * 60 * 1000;
|
||||
const DEFAULT_SERVER_POLL_INTERVAL_MS = 60 * 1000;
|
||||
const DEFAULT_SERVER_POLL_INTERVAL_MS = 120 * 1000;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.info("HLL Vietnam frontend ready");
|
||||
@@ -114,12 +113,7 @@ async function hydrateServers(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendBaseUrl}/api/servers`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Servers request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const payload = await fetchJson(`${backendBaseUrl}/api/servers`);
|
||||
const serversData = payload.data;
|
||||
if (!serversData || !Array.isArray(serversData.items)) {
|
||||
throw new Error("Servers payload incomplete");
|
||||
@@ -127,12 +121,11 @@ async function hydrateServers(
|
||||
|
||||
serversTitle.textContent =
|
||||
serversData.title || "Estado actual de servidores";
|
||||
setServersDataState(serversBadge, { kind: "fallback" });
|
||||
|
||||
if (serversData.context === "current-hll-reference") {
|
||||
serversNote.textContent =
|
||||
"Referencia actual de servidores de Hell Let Loose.";
|
||||
}
|
||||
setServersDataState(
|
||||
serversBadge,
|
||||
deriveSnapshotState(serversData),
|
||||
);
|
||||
serversNote.textContent = buildServersNote(serversData);
|
||||
|
||||
if (serversData.items.length === 0) {
|
||||
serversList.innerHTML =
|
||||
@@ -140,47 +133,10 @@ async function hydrateServers(
|
||||
return;
|
||||
}
|
||||
|
||||
serversList.innerHTML = serversData.items.map(renderServerCard).join("");
|
||||
await hydrateServerStats(
|
||||
backendBaseUrl,
|
||||
serversTitle,
|
||||
serversNote,
|
||||
serversList,
|
||||
serversBadge,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Servers panel remains on static fallback", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateServerStats(
|
||||
backendBaseUrl,
|
||||
serversTitle,
|
||||
serversNote,
|
||||
serversList,
|
||||
serversBadge,
|
||||
) {
|
||||
try {
|
||||
const latestPayload = await fetchJson(`${backendBaseUrl}/api/servers/latest`);
|
||||
const latestItems = latestPayload?.data?.items;
|
||||
if (!Array.isArray(latestItems) || latestItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleItems = selectPrimaryServerItems(latestItems);
|
||||
const latestState = deriveSnapshotState(visibleItems);
|
||||
const hasRealSnapshots = visibleItems.some(isRealA2SSnapshot);
|
||||
|
||||
serversTitle.textContent = hasRealSnapshots
|
||||
? "Servidores disponibles"
|
||||
: latestPayload.data.title || "Actividad reciente de servidores";
|
||||
serversNote.textContent = hasRealSnapshots
|
||||
? "Estado actual y acceso directo a los servidores disponibles."
|
||||
: "Ultimo estado disponible de servidores.";
|
||||
setServersDataState(serversBadge, latestState);
|
||||
const visibleItems = selectPrimaryServerItems(serversData.items);
|
||||
serversList.innerHTML = renderServerSections(visibleItems);
|
||||
} catch (error) {
|
||||
console.warn("Historical server enrichment unavailable", error);
|
||||
console.warn("Servers panel remains on static fallback", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,20 +158,24 @@ function setServersDataState(badgeNode, state) {
|
||||
}
|
||||
|
||||
if (state.kind === "live") {
|
||||
badgeNode.textContent = "Datos en vivo";
|
||||
badgeNode.textContent = state.timestampLabel
|
||||
? `Actualizado ${state.timestampLabel}`
|
||||
: "Datos en vivo";
|
||||
badgeNode.classList.remove("status-chip--fallback");
|
||||
badgeNode.classList.add("status-chip--ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.kind === "historical") {
|
||||
badgeNode.textContent = "Actividad reciente";
|
||||
badgeNode.textContent = state.timestampLabel
|
||||
? `Snapshot ${state.timestampLabel}`
|
||||
: "Actividad reciente";
|
||||
badgeNode.classList.remove("status-chip--fallback");
|
||||
badgeNode.classList.add("status-chip--ok");
|
||||
return;
|
||||
}
|
||||
|
||||
badgeNode.textContent = "Referencia actual";
|
||||
badgeNode.textContent = "Respaldo controlado";
|
||||
badgeNode.classList.remove("status-chip--ok");
|
||||
badgeNode.classList.add("status-chip--fallback");
|
||||
}
|
||||
@@ -367,25 +327,41 @@ function isRealA2SSnapshot(item) {
|
||||
return item?.snapshot_origin === "real-a2s";
|
||||
}
|
||||
|
||||
function deriveSnapshotState(items) {
|
||||
const stateItems = Array.isArray(items) ? items : [];
|
||||
const realItems = stateItems.filter(isRealA2SSnapshot);
|
||||
const itemsForState = realItems.length > 0 ? realItems : stateItems;
|
||||
const latestTimestamp = itemsForState
|
||||
.map((item) => item.captured_at)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1);
|
||||
const latestDate = latestTimestamp ? new Date(latestTimestamp) : null;
|
||||
const latestTime = latestDate && !Number.isNaN(latestDate.getTime()) ? latestDate.getTime() : null;
|
||||
const timestampLabel = latestTimestamp ? formatTimestamp(latestTimestamp) : "";
|
||||
const hasRealA2S = realItems.length > 0;
|
||||
function deriveSnapshotState(serversData) {
|
||||
const timestampLabel = serversData?.last_snapshot_at
|
||||
? formatTimestamp(serversData.last_snapshot_at)
|
||||
: "";
|
||||
|
||||
if (hasRealA2S && latestTime && Date.now() - latestTime <= RECENT_SNAPSHOT_WINDOW_MS) {
|
||||
if (!serversData) {
|
||||
return { kind: "fallback", timestampLabel };
|
||||
}
|
||||
|
||||
if (serversData.freshness === "fresh" && serversData.source === "real-time-a2s-refresh") {
|
||||
return { kind: "live", timestampLabel };
|
||||
}
|
||||
|
||||
if (Array.isArray(serversData.items) && serversData.items.length > 0) {
|
||||
return { kind: "historical", timestampLabel };
|
||||
}
|
||||
|
||||
return { kind: "fallback", timestampLabel };
|
||||
}
|
||||
|
||||
function buildServersNote(serversData) {
|
||||
const lastSnapshotLabel = serversData.last_snapshot_at
|
||||
? formatTimestamp(serversData.last_snapshot_at)
|
||||
: "sin timestamp disponible";
|
||||
const snapshotAgeLabel = formatSnapshotAge(serversData.snapshot_age_seconds);
|
||||
|
||||
if (serversData.freshness === "fresh") {
|
||||
return `Estado real consultado desde backend. Ultimo snapshot: ${lastSnapshotLabel}${snapshotAgeLabel ? `, ${snapshotAgeLabel}.` : "."}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(serversData.items) && serversData.items.length > 0) {
|
||||
return `El backend no pudo refrescar ahora mismo y muestra el ultimo snapshot valido. Captura: ${lastSnapshotLabel}${snapshotAgeLabel ? `, ${snapshotAgeLabel}.` : "."}`;
|
||||
}
|
||||
|
||||
return "El backend no pudo obtener un snapshot valido de los 2 servidores en este momento.";
|
||||
}
|
||||
|
||||
function formatServerStatus(status) {
|
||||
@@ -434,6 +410,24 @@ function formatElapsedMinutes(minutes) {
|
||||
return `hace ${days} d`;
|
||||
}
|
||||
|
||||
function formatSnapshotAge(snapshotAgeSeconds) {
|
||||
if (!Number.isFinite(snapshotAgeSeconds)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (snapshotAgeSeconds < 60) {
|
||||
return `hace ${snapshotAgeSeconds} s`;
|
||||
}
|
||||
|
||||
const wholeMinutes = Math.floor(snapshotAgeSeconds / 60);
|
||||
if (wholeMinutes < 60) {
|
||||
return `hace ${wholeMinutes} min`;
|
||||
}
|
||||
|
||||
const wholeHours = Math.floor(wholeMinutes / 60);
|
||||
return `hace ${wholeHours} h`;
|
||||
}
|
||||
|
||||
function getPopulationPercent(players, maxPlayers) {
|
||||
if (!Number.isFinite(players) || !Number.isFinite(maxPlayers) || maxPlayers <= 0) {
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user