feat: add current match page and live kill feed
This commit is contained in:
@@ -18,3 +18,4 @@ Date | Task | Duration | Result | Notes
|
|||||||
2026-05-19T08:39:12 | worker-cycle | 663.71 sec | success | codex-runner
|
2026-05-19T08:39:12 | worker-cycle | 663.71 sec | success | codex-runner
|
||||||
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner
|
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner
|
||||||
2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
|
2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
|
||||||
|
2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-150
|
id: TASK-150
|
||||||
title: Server card history and current match entrypoints
|
title: Server card history and current match entrypoints
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams: []
|
supporting_teams: []
|
||||||
@@ -164,9 +164,24 @@ historical page can open directly filtered by server.
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the validation performed, URL trust boundary decisions, historical
|
- Replaced the home server-card history URL path with a trusted frontend action
|
||||||
filter initialization behavior, and any follow-up task instead of expanding
|
catalog for active servers `comunidad-hispana-01` and
|
||||||
scope.
|
`comunidad-hispana-02` only. Payload-provided `community_history_url`
|
||||||
|
values and the previous server #03 fallback are no longer used for those
|
||||||
|
card actions.
|
||||||
|
- Added the three per-server entrypoints from trusted constants: public
|
||||||
|
scoreboard base URL, internal historical filter URL and internal current
|
||||||
|
match URL.
|
||||||
|
- Confirmed `frontend/assets/js/historico.js` already normalizes the supported
|
||||||
|
`?server=` values into the active selector and falls back to `all-servers`
|
||||||
|
for unknown or missing values, so no historical page code change was needed.
|
||||||
|
- Added a minimal `partida-actual.html` placeholder with a trusted server label
|
||||||
|
map and safe internal links for TASK-151 to upgrade.
|
||||||
|
- Validation: `node --check frontend/assets/js/main.js`.
|
||||||
|
- Scope review: `git diff --name-only` and `git status --short` were reviewed
|
||||||
|
for the task move plus frontend files. Browser click verification was not
|
||||||
|
completed because the in-app browser JavaScript control tool was not
|
||||||
|
available after tool discovery in this session.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-151
|
id: TASK-151
|
||||||
title: Current match page base
|
title: Current match page base
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -159,8 +159,29 @@ live server/match state without pretending the match is closed.
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the live-state source, polling behavior, validation performed, and any
|
- Upgraded `partida-actual.html` into the first internal live match page and
|
||||||
follow-up task instead of expanding scope.
|
kept it aligned with the existing historical shell/styles.
|
||||||
|
- Added frontend polling every 30 seconds with an in-flight guard. The page
|
||||||
|
rejects unknown `?server=` values before building any external link, and the
|
||||||
|
public scoreboard button is populated only from the trusted backend
|
||||||
|
projection.
|
||||||
|
- Added read-only `GET /api/current-match?server=`. It supports only active
|
||||||
|
trusted scoreboard origins and projects the existing live server snapshot
|
||||||
|
fields into the current-match shape. The current live snapshot persistence
|
||||||
|
exposes status, map, population and capture time; score, game mode and match
|
||||||
|
start fields remain `null`/unavailable when the snapshot source does not
|
||||||
|
provide them.
|
||||||
|
- Kept the combat feed and live player statistics as honest empty placeholders
|
||||||
|
for the follow-up tasks rather than fabricating closed-match or kill data.
|
||||||
|
- Validation: `python -m compileall backend/app`;
|
||||||
|
`node --check frontend/assets/js/partida-actual.js`;
|
||||||
|
`powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`;
|
||||||
|
narrow inline route guard check for missing/unknown current-match server
|
||||||
|
values; narrow inline payload projection check using a controlled live
|
||||||
|
snapshot document.
|
||||||
|
- Scope review: `git diff --name-only` and `git status --short` were reviewed.
|
||||||
|
No focused product route test file exists in `backend/tests` for this API
|
||||||
|
bootstrap layer yet.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-152
|
id: TASK-152
|
||||||
title: Current match live kill feed
|
title: Current match live kill feed
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -139,8 +139,33 @@ feed based on RCON/AdminLog data.
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the feed scope and confidence behavior, event normalization choices,
|
- Added read-only `GET /api/current-match/kills?server=&limit=` for the active
|
||||||
polling validation, and any follow-up task instead of expanding scope.
|
trusted Comunidad Hispana servers. Unsupported servers fail before any
|
||||||
|
AdminLog query is built.
|
||||||
|
- Added a safe AdminLog kill feed read that emits normalized fields only:
|
||||||
|
generated `event_id`, event time/server time, killer/victim names and teams,
|
||||||
|
weapon, and computed `is_teamkill`. It does not return raw AdminLog text,
|
||||||
|
player ids or admin-only payload fields.
|
||||||
|
- Feed scope prefers `open-admin-log-match-window` with
|
||||||
|
`confidence: "admin-log-boundary"` when the latest AdminLog boundary for the
|
||||||
|
server is an unmatched `match_start`. Otherwise it returns
|
||||||
|
`recent-admin-log-window` with `confidence: "partial"` as the explicit
|
||||||
|
fallback.
|
||||||
|
- Extended `partida-actual.js` to poll the feed with the existing 30-second
|
||||||
|
in-flight-protected current-match refresh cycle, dedupe rows by `event_id`,
|
||||||
|
keep newest events ordered by server/event time, render a TK badge for
|
||||||
|
teamkills, and show empty/error states without fake rows.
|
||||||
|
- Added a focused AdminLog storage test for open-window filtering and teamkill
|
||||||
|
normalization. The environment does not have `pytest` installed (`pytest`
|
||||||
|
and `python -m pytest` both failed), so the same storage normalization path
|
||||||
|
was verified with a narrow inline Python scenario.
|
||||||
|
- Validation: `python -m compileall backend/app`;
|
||||||
|
`node --check frontend/assets/js/partida-actual.js`;
|
||||||
|
`powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`;
|
||||||
|
narrow inline route guard check for missing, unknown and invalid-limit feed
|
||||||
|
requests; inline storage normalization check excluding pre-window kills and
|
||||||
|
confirming no raw message field leaks.
|
||||||
|
- Scope review: `git diff --name-only` and `git status --short` were reviewed.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ from .historical_storage import (
|
|||||||
)
|
)
|
||||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
|
from .rcon_admin_log_storage import list_current_match_kill_feed
|
||||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||||
|
|
||||||
@@ -237,6 +238,63 @@ def build_server_detail_history_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
|
||||||
|
"""Return the live page projection for one trusted active server."""
|
||||||
|
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||||
|
if origin is None:
|
||||||
|
raise ValueError("Unsupported current match server.")
|
||||||
|
|
||||||
|
server_payload = build_servers_payload()
|
||||||
|
server_data = server_payload["data"]
|
||||||
|
item = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in server_data.get("items", [])
|
||||||
|
if candidate.get("external_server_id") == origin.slug
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"found": item is not None,
|
||||||
|
"server_slug": origin.slug,
|
||||||
|
"server_name": item.get("server_name") if item else origin.display_name,
|
||||||
|
"status": item.get("status") if item else "unavailable",
|
||||||
|
"map": item.get("current_map") if item else None,
|
||||||
|
"game_mode": item.get("game_mode") if item else None,
|
||||||
|
"started_at": item.get("started_at") if item else None,
|
||||||
|
"allied_score": item.get("allied_score") if item else None,
|
||||||
|
"axis_score": item.get("axis_score") if item else None,
|
||||||
|
"players": item.get("players") if item else None,
|
||||||
|
"max_players": item.get("max_players") if item else None,
|
||||||
|
"captured_at": item.get("captured_at") if item else None,
|
||||||
|
"updated_at": server_data.get("last_snapshot_at"),
|
||||||
|
"public_scoreboard_url": origin.base_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_current_match_kill_feed_payload(
|
||||||
|
*,
|
||||||
|
server_slug: str,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return normalized AdminLog kill rows for one trusted current-match page."""
|
||||||
|
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||||
|
if origin is None:
|
||||||
|
raise ValueError("Unsupported current match server.")
|
||||||
|
feed = list_current_match_kill_feed(server_key=origin.slug, limit=limit)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"server_slug": origin.slug,
|
||||||
|
"server_name": origin.display_name,
|
||||||
|
**feed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_error_payload(message: str) -> dict[str, str]:
|
def build_error_payload(message: str) -> dict[str, str]:
|
||||||
"""Return the shared error payload shape used by the backend bootstrap."""
|
"""Return the shared error payload shape used by the backend bootstrap."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from contextlib import closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path, use_postgres_rcon_storage
|
from .config import get_storage_path, use_postgres_rcon_storage
|
||||||
@@ -328,6 +329,80 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic
|
|||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_current_match_kill_feed(
|
||||||
|
*,
|
||||||
|
server_key: str,
|
||||||
|
limit: int = 30,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return safe recent kill rows for one AdminLog server window."""
|
||||||
|
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||||
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
|
connection_scope = connect_postgres_compat()
|
||||||
|
else:
|
||||||
|
connection_scope = closing(sqlite3.connect(resolved_path))
|
||||||
|
|
||||||
|
with connection_scope as connection:
|
||||||
|
if isinstance(connection, sqlite3.Connection):
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
boundary = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_type, server_time
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
|
AND event_type IN ('match_start', 'match_end')
|
||||||
|
AND server_time IS NOT NULL
|
||||||
|
ORDER BY server_time DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(server_key, server_key),
|
||||||
|
).fetchone()
|
||||||
|
open_start_time = (
|
||||||
|
boundary["server_time"]
|
||||||
|
if boundary is not None and boundary["event_type"] == "match_start"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if open_start_time is None:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, target_key, external_server_id, event_timestamp, server_time,
|
||||||
|
parsed_payload_json
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
|
AND event_type = 'kill'
|
||||||
|
ORDER BY server_time DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(server_key, server_key, limit),
|
||||||
|
).fetchall()
|
||||||
|
scope = "recent-admin-log-window"
|
||||||
|
confidence = "partial"
|
||||||
|
else:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, target_key, external_server_id, event_timestamp, server_time,
|
||||||
|
parsed_payload_json
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
|
AND event_type = 'kill'
|
||||||
|
AND server_time >= ?
|
||||||
|
ORDER BY server_time DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(server_key, server_key, open_start_time, limit),
|
||||||
|
).fetchall()
|
||||||
|
scope = "open-admin-log-match-window"
|
||||||
|
confidence = "admin-log-boundary"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scope": scope,
|
||||||
|
"confidence": confidence,
|
||||||
|
"items": [_serialize_kill_feed_row(row) for row in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_latest_rcon_player_profile_summaries(
|
def get_latest_rcon_player_profile_summaries(
|
||||||
*,
|
*,
|
||||||
target_key: str,
|
target_key: str,
|
||||||
@@ -422,6 +497,33 @@ def _json_mapping(raw_value: object) -> dict[str, object]:
|
|||||||
return parsed if isinstance(parsed, dict) else {}
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]:
|
||||||
|
payload = _json_mapping(row["parsed_payload_json"])
|
||||||
|
target_key = str(row["external_server_id"] or row["target_key"] or "unknown")
|
||||||
|
killer_team = _safe_event_field(payload.get("killer_team"))
|
||||||
|
victim_team = _safe_event_field(payload.get("victim_team"))
|
||||||
|
return {
|
||||||
|
"event_id": f"rcon-admin-log:{target_key}:{row['id']}",
|
||||||
|
"event_timestamp": row["event_timestamp"],
|
||||||
|
"server_time": row["server_time"],
|
||||||
|
"killer_name": _safe_event_field(payload.get("killer_name")),
|
||||||
|
"killer_team": killer_team,
|
||||||
|
"victim_name": _safe_event_field(payload.get("victim_name")),
|
||||||
|
"victim_team": victim_team,
|
||||||
|
"weapon": _safe_event_field(payload.get("weapon")),
|
||||||
|
"is_teamkill": bool(
|
||||||
|
killer_team
|
||||||
|
and killer_team != "None"
|
||||||
|
and killer_team == victim_team
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_event_field(value: object) -> str | None:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
def _persist_rcon_admin_log_entries_postgres(
|
def _persist_rcon_admin_log_entries_postgres(
|
||||||
*,
|
*,
|
||||||
target: Mapping[str, object],
|
target: Mapping[str, object],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
|
|
||||||
from .payloads import (
|
from .payloads import (
|
||||||
build_community_payload,
|
build_community_payload,
|
||||||
|
build_current_match_kill_feed_payload,
|
||||||
|
build_current_match_payload,
|
||||||
build_discord_payload,
|
build_discord_payload,
|
||||||
build_elo_mmr_leaderboard_payload,
|
build_elo_mmr_leaderboard_payload,
|
||||||
build_elo_mmr_player_payload,
|
build_elo_mmr_player_payload,
|
||||||
@@ -37,6 +39,7 @@ from .payloads import (
|
|||||||
build_weekly_leaderboard_payload,
|
build_weekly_leaderboard_payload,
|
||||||
build_weekly_top_kills_payload,
|
build_weekly_top_kills_payload,
|
||||||
)
|
)
|
||||||
|
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||||
|
|
||||||
|
|
||||||
GET_ROUTES = {
|
GET_ROUTES = {
|
||||||
@@ -60,6 +63,28 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||||
return HTTPStatus.OK, build_server_history_payload(limit=limit)
|
return HTTPStatus.OK, build_server_history_payload(limit=limit)
|
||||||
|
|
||||||
|
if parsed.path == "/api/current-match":
|
||||||
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
|
if not server_slug:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||||
|
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
||||||
|
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
|
||||||
|
return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug)
|
||||||
|
|
||||||
|
if parsed.path == "/api/current-match/kills":
|
||||||
|
limit = _parse_limit(parsed.query)
|
||||||
|
if limit is None:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||||
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
|
if not server_slug:
|
||||||
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||||
|
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
||||||
|
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
|
||||||
|
return HTTPStatus.OK, build_current_match_kill_feed_payload(
|
||||||
|
server_slug=server_slug,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
if parsed.path == "/api/historical/weekly-top-kills":
|
if parsed.path == "/api/historical/weekly-top-kills":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sqlite3
|
|||||||
|
|
||||||
from app.rcon_admin_log_storage import (
|
from app.rcon_admin_log_storage import (
|
||||||
initialize_rcon_admin_log_storage,
|
initialize_rcon_admin_log_storage,
|
||||||
|
list_current_match_kill_feed,
|
||||||
list_rcon_admin_log_event_counts,
|
list_rcon_admin_log_event_counts,
|
||||||
persist_rcon_admin_log_entries,
|
persist_rcon_admin_log_entries,
|
||||||
)
|
)
|
||||||
@@ -259,3 +260,52 @@ def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_pa
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path):
|
||||||
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target=TARGET,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-19T09:59:00Z",
|
||||||
|
"message": (
|
||||||
|
"[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> "
|
||||||
|
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-19T10:00:00Z",
|
||||||
|
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-19T10:01:00Z",
|
||||||
|
"message": (
|
||||||
|
"[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||||
|
"Bravo(Allies/steam-bravo) with GRENADE"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed = list_current_match_kill_feed(
|
||||||
|
server_key="test-rcon-target",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feed["scope"] == "open-admin-log-match-window"
|
||||||
|
assert feed["confidence"] == "admin-log-boundary"
|
||||||
|
assert len(feed["items"]) == 1
|
||||||
|
assert feed["items"][0] == {
|
||||||
|
"event_id": "rcon-admin-log:test-rcon-target:3",
|
||||||
|
"event_timestamp": "2026-05-19T10:01:00Z",
|
||||||
|
"server_time": 120,
|
||||||
|
"killer_name": "Alpha",
|
||||||
|
"killer_team": "Allies",
|
||||||
|
"victim_name": "Bravo",
|
||||||
|
"victim_team": "Allies",
|
||||||
|
"weapon": "GRENADE",
|
||||||
|
"is_teamkill": True,
|
||||||
|
}
|
||||||
|
gc.collect()
|
||||||
|
|||||||
@@ -671,6 +671,8 @@ h2 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
// 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_FALLBACK = Object.freeze({
|
const TRUSTED_SERVER_ACTIONS = Object.freeze({
|
||||||
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games",
|
"comunidad-hispana-01": Object.freeze({
|
||||||
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games",
|
publicScoreboardUrl: "https://scoreboard.comunidadhll.es",
|
||||||
"comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games",
|
historicalUrl: "./historico.html?server=comunidad-hispana-01",
|
||||||
|
currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-01",
|
||||||
|
}),
|
||||||
|
"comunidad-hispana-02": Object.freeze({
|
||||||
|
publicScoreboardUrl: "https://scoreboard.comunidadhll.es:5443",
|
||||||
|
historicalUrl: "./historico.html?server=comunidad-hispana-02",
|
||||||
|
currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-02",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const COMMUNITY_CLANS = Object.freeze([
|
const COMMUNITY_CLANS = Object.freeze([
|
||||||
{
|
{
|
||||||
@@ -328,22 +335,8 @@ function normalizeServerRegion(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderServerAction(server) {
|
function renderServerAction(server) {
|
||||||
const historyState = getServerHistoryState(server);
|
const actions = getTrustedServerActions(server);
|
||||||
if (!historyState.available) {
|
if (!actions) {
|
||||||
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) {
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,11 +344,17 @@ function renderServerAction(server) {
|
|||||||
<div class="server-card__actions">
|
<div class="server-card__actions">
|
||||||
<a
|
<a
|
||||||
class="server-action-link"
|
class="server-action-link"
|
||||||
href="${escapeHtml(historyUrl)}"
|
href="${escapeHtml(actions.publicScoreboardUrl)}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
Historico
|
Scoreboard publico
|
||||||
|
</a>
|
||||||
|
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
|
||||||
|
Nuestro historico
|
||||||
|
</a>
|
||||||
|
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
|
||||||
|
Partida actual
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -458,28 +457,12 @@ function renderQuickFacts(items) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerHistoryUrl(server) {
|
function getTrustedServerActions(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()
|
||||||
: "";
|
: "";
|
||||||
if (!externalServerId) {
|
return TRUSTED_SERVER_ACTIONS[externalServerId] || null;
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
256
frontend/assets/js/partida-actual.js
Normal file
256
frontend/assets/js/partida-actual.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000;
|
||||||
|
const CURRENT_MATCH_SERVERS = Object.freeze({
|
||||||
|
"comunidad-hispana-01": "Comunidad Hispana #01",
|
||||||
|
"comunidad-hispana-02": "Comunidad Hispana #02",
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const serverSlug = params.get("server") || "";
|
||||||
|
const nodes = {
|
||||||
|
title: document.getElementById("current-match-title"),
|
||||||
|
summary: document.getElementById("current-match-summary"),
|
||||||
|
history: document.getElementById("current-match-history"),
|
||||||
|
scoreboard: document.getElementById("current-match-scoreboard"),
|
||||||
|
note: document.getElementById("current-match-note"),
|
||||||
|
state: document.getElementById("current-match-state"),
|
||||||
|
grid: document.getElementById("current-match-grid"),
|
||||||
|
feedTitle: document.getElementById("current-match-feed-title"),
|
||||||
|
};
|
||||||
|
const backendBaseUrl =
|
||||||
|
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
|
if (!CURRENT_MATCH_SERVERS[serverSlug]) {
|
||||||
|
renderUnsupportedServer(nodes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
|
||||||
|
const killFeedState = initializeKillFeed(nodes);
|
||||||
|
let refreshInFlight = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
if (refreshInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshInFlight = true;
|
||||||
|
try {
|
||||||
|
await Promise.allSettled([
|
||||||
|
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
|
||||||
|
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, CURRENT_MATCH_POLL_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
`${backendBaseUrl}/api/current-match?server=${encodeURIComponent(serverSlug)}`,
|
||||||
|
);
|
||||||
|
renderCurrentMatch(payload?.data || {}, nodes);
|
||||||
|
} catch (error) {
|
||||||
|
nodes.note.textContent = "Se conserva el ultimo estado visible si estaba disponible.";
|
||||||
|
setState(nodes.state, "No se pudo actualizar la partida actual.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(
|
||||||
|
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`,
|
||||||
|
);
|
||||||
|
renderKillFeed(payload?.data || {}, nodes, killFeedState);
|
||||||
|
} catch (error) {
|
||||||
|
setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentMatch(data, nodes) {
|
||||||
|
const serverName = data.server_name || data.server_slug || "Servidor no disponible";
|
||||||
|
const mapName = data.map || "Mapa no disponible";
|
||||||
|
nodes.title.textContent = serverName;
|
||||||
|
nodes.summary.textContent = mapName;
|
||||||
|
nodes.note.textContent = data.found
|
||||||
|
? "Snapshot en vivo recibido. La pagina se actualiza cada 30 segundos."
|
||||||
|
: "Todavia no hay snapshot live disponible para este servidor.";
|
||||||
|
nodes.scoreboard.href = data.public_scoreboard_url;
|
||||||
|
nodes.scoreboard.hidden = !data.public_scoreboard_url;
|
||||||
|
nodes.grid.innerHTML = [
|
||||||
|
renderStat("Estado", formatStatus(data.status)),
|
||||||
|
renderStat("Mapa", mapName),
|
||||||
|
renderStat("Modo", data.game_mode || "No disponible"),
|
||||||
|
renderStat("Inicio", formatTimestamp(data.started_at)),
|
||||||
|
renderStat("Jugadores", formatPlayers(data.players, data.max_players)),
|
||||||
|
renderStat("Marcador aliado", formatScore(data.allied_score)),
|
||||||
|
renderStat("Marcador eje", formatScore(data.axis_score)),
|
||||||
|
renderStat("Actualizado", formatTimestamp(data.captured_at || data.updated_at)),
|
||||||
|
].join("");
|
||||||
|
nodes.state.hidden = true;
|
||||||
|
nodes.grid.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUnsupportedServer(nodes) {
|
||||||
|
nodes.title.textContent = "Servidor no soportado";
|
||||||
|
nodes.summary.textContent =
|
||||||
|
"Abre esta vista desde una tarjeta activa de Comunidad Hispana.";
|
||||||
|
nodes.note.textContent = "";
|
||||||
|
nodes.scoreboard.hidden = true;
|
||||||
|
nodes.grid.hidden = true;
|
||||||
|
setState(nodes.state, "No se puede consultar la partida solicitada.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeKillFeed(nodes) {
|
||||||
|
const feedShell = nodes.feedTitle?.closest(".panel__shell");
|
||||||
|
if (feedShell) {
|
||||||
|
feedShell.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
`
|
||||||
|
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
|
||||||
|
Cargando feed de combate...
|
||||||
|
</p>
|
||||||
|
<div class="historical-match-list" id="current-match-feed-list"></div>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nodes.feedState = document.getElementById("current-match-feed-state");
|
||||||
|
nodes.feedList = document.getElementById("current-match-feed-list");
|
||||||
|
return {
|
||||||
|
byId: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKillFeed(data, nodes, state) {
|
||||||
|
const incoming = Array.isArray(data.items) ? data.items : [];
|
||||||
|
incoming.forEach((event) => {
|
||||||
|
if (event?.event_id) {
|
||||||
|
state.byId.set(event.event_id, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30);
|
||||||
|
if (events.length === 0) {
|
||||||
|
nodes.feedList.innerHTML = "";
|
||||||
|
setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
|
||||||
|
nodes.feedState.textContent =
|
||||||
|
data.scope === "open-admin-log-match-window"
|
||||||
|
? "Bajas del tramo abierto detectado por AdminLog."
|
||||||
|
: "Bajas recientes de AdminLog con cobertura parcial.";
|
||||||
|
nodes.feedState.classList.remove("historical-state--error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareKillFeedEvents(left, right) {
|
||||||
|
const rightTime = Number(right.server_time);
|
||||||
|
const leftTime = Number(left.server_time);
|
||||||
|
if (Number.isFinite(rightTime) && Number.isFinite(leftTime) && rightTime !== leftTime) {
|
||||||
|
return rightTime - leftTime;
|
||||||
|
}
|
||||||
|
return String(right.event_timestamp || "").localeCompare(String(left.event_timestamp || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKillFeedRow(event) {
|
||||||
|
const teamkillBadge = event.is_teamkill
|
||||||
|
? '<span class="status-chip status-chip--fallback">TK</span>'
|
||||||
|
: "";
|
||||||
|
const eventTime = formatEventTime(event);
|
||||||
|
return `
|
||||||
|
<article class="historical-match-card">
|
||||||
|
<div class="historical-match-card__top">
|
||||||
|
<div class="historical-match-card__title">
|
||||||
|
<p>${escapeHtml(eventTime)}</p>
|
||||||
|
<strong>${escapeHtml(event.killer_name || "Jugador no disponible")}</strong>
|
||||||
|
</div>
|
||||||
|
${teamkillBadge}
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
${escapeHtml(event.weapon || "Arma no disponible")}
|
||||||
|
-> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(event) {
|
||||||
|
const timestamp = formatTimestamp(event.event_timestamp);
|
||||||
|
if (timestamp !== "No disponible") {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
return Number.isFinite(Number(event.server_time))
|
||||||
|
? `Tiempo servidor ${Number(event.server_time)}`
|
||||||
|
: "Tiempo no disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStat(label, value) {
|
||||||
|
return `
|
||||||
|
<article class="historical-stat-card">
|
||||||
|
<p>${escapeHtml(label)}</p>
|
||||||
|
<strong>${escapeHtml(value)}</strong>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(value) {
|
||||||
|
if (value === "online") {
|
||||||
|
return "Online";
|
||||||
|
}
|
||||||
|
if (value === "offline") {
|
||||||
|
return "Offline";
|
||||||
|
}
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlayers(players, maxPlayers) {
|
||||||
|
if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
return `${Number(players)} / ${Number(maxPlayers)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(value) {
|
||||||
|
return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
const timestamp = new Date(value);
|
||||||
|
if (Number.isNaN(timestamp.getTime())) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("es-ES", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(node, message, isError = false) {
|
||||||
|
node.textContent = message;
|
||||||
|
node.hidden = false;
|
||||||
|
node.classList.toggle("historical-state--error", isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
115
frontend/partida-actual.html
Normal file
115
frontend/partida-actual.html
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Vista interna de la partida actual de Comunidad Hispana en HLL Vietnam."
|
||||||
|
/>
|
||||||
|
<title>Partida actual - HLL Vietnam</title>
|
||||||
|
<link rel="stylesheet" href="./assets/css/styles.css" />
|
||||||
|
<link rel="stylesheet" href="./assets/css/historico.css" />
|
||||||
|
</head>
|
||||||
|
<body data-backend-base-url="http://127.0.0.1:8000">
|
||||||
|
<div class="page-shell historical-shell">
|
||||||
|
<header class="hero historical-hero">
|
||||||
|
<div class="hero__overlay"></div>
|
||||||
|
<div class="hero__content historical-hero__content">
|
||||||
|
<div class="historical-hero__topline">
|
||||||
|
<a class="secondary-button secondary-button--ghost" href="./index.html">
|
||||||
|
VOLVER INICIO
|
||||||
|
</a>
|
||||||
|
<p class="eyebrow">Partida en vivo</p>
|
||||||
|
</div>
|
||||||
|
<div class="historical-hero__layout">
|
||||||
|
<div class="logo-frame historical-logo-frame">
|
||||||
|
<img
|
||||||
|
src="./assets/img/logo.png"
|
||||||
|
alt="Logo oficial de la comunidad HLL Vietnam"
|
||||||
|
class="logo-frame__image"
|
||||||
|
width="1024"
|
||||||
|
height="1044"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="historical-hero__copy">
|
||||||
|
<div>
|
||||||
|
<h1 class="historical-hero__title" id="current-match-title">
|
||||||
|
Partida actual
|
||||||
|
</h1>
|
||||||
|
<p class="hero__text historical-hero__text" id="current-match-summary">
|
||||||
|
Cargando estado del servidor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero__actions">
|
||||||
|
<a class="secondary-button" id="current-match-history" href="./historico.html">
|
||||||
|
Abrir historico
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="secondary-button"
|
||||||
|
id="current-match-scoreboard"
|
||||||
|
href="./index.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
Ver scoreboard publico
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content historical-content">
|
||||||
|
<section class="panel historical-panel" aria-labelledby="current-match-panel-title">
|
||||||
|
<div class="panel__shell">
|
||||||
|
<div class="panel__header historical-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Estado en vivo</p>
|
||||||
|
<h2 id="current-match-panel-title">Marcador en curso</h2>
|
||||||
|
<p class="historical-panel__note" id="current-match-note">
|
||||||
|
Leyendo el ultimo snapshot disponible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="historical-state" id="current-match-state" aria-live="polite">
|
||||||
|
Cargando partida actual...
|
||||||
|
</p>
|
||||||
|
<div class="historical-summary-grid" id="current-match-grid" hidden></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel historical-panel" aria-labelledby="current-match-feed-title">
|
||||||
|
<div class="panel__shell">
|
||||||
|
<div class="panel__header historical-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Combate</p>
|
||||||
|
<h2 id="current-match-feed-title">Feed de combate</h2>
|
||||||
|
<p class="historical-panel__note">
|
||||||
|
Los eventos de bajas en vivo apareceran aqui cuando se habiliten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel historical-panel" aria-labelledby="current-match-players-title">
|
||||||
|
<div class="panel__shell">
|
||||||
|
<div class="panel__header historical-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow--section">Jugadores</p>
|
||||||
|
<h2 id="current-match-players-title">Estadisticas en vivo</h2>
|
||||||
|
<p class="historical-panel__note">
|
||||||
|
Las estadisticas en vivo apareceran cuando haya datos suficientes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="./assets/js/partida-actual.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user