подгрузка данных для api из env

This commit is contained in:
2026-08-19 16:28:09 +03:00
parent bddc0967b3
commit c643a183d8
15 changed files with 891 additions and 86 deletions

View File

@@ -382,12 +382,22 @@ class HockeySettingsStore:
password = os.getenv("STAT2TV_PASSWORD", str(raw.get("password") or ""))
return username.strip(), password
def credentials_source(self) -> str:
"""Return where Stat2TV credentials come from without exposing secrets."""
if os.getenv("STAT2TV_LOGIN") is not None or os.getenv("STAT2TV_PASSWORD") is not None:
return "env"
raw = self._read_json(self.secret_file)
if str(raw.get("username") or "").strip() or str(raw.get("password") or ""):
return "local_file"
return "missing"
def status(self) -> dict[str, Any]:
settings = self.public()
username, password = self.credentials()
return {
**settings,
"credentials_configured": bool(username and password),
"credentials_source": self.credentials_source(),
"username_masked": self.mask_username(username),
}

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
import asyncio
from datetime import date
from time import perf_counter
from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
from .auth_bridge import HockeyUser
from .service import HockeyDataService
@@ -347,6 +350,158 @@ def create_hockey_router(
except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from error
@router.get("/admin/connection-diagnostics", dependencies=admin)
async def admin_connection_diagnostics(
tournament_id: str = Query("", max_length=64),
game_id: str = Query("", max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"),
deep: bool = False,
) -> dict[str, Any]:
"""Admin-only health check for the dependencies used by match loading.
The lightweight check never requires an Agent/vMix to open a match: the
database is the only hard dependency for cached schedule selection, while
Stat2TV is required only for remote enrichment/sync. ``deep=true`` also
exercises the selected match enrichment path and returns its warnings.
"""
checks: dict[str, Any] = {}
started = perf_counter()
try:
with service.database.engine.connect() as connection:
connection.execute(text("SELECT 1"))
checks["database"] = {
"ok": True,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"target": service.database.schema_status(),
}
except Exception as error:
checks["database"] = {
"ok": False,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"error": str(error),
}
settings_status = service.settings.status()
checks["settings"] = {
"ok": bool(settings_status.get("base_url")),
"base_url": settings_status.get("base_url", ""),
"credentials_configured": bool(settings_status.get("credentials_configured")),
"credentials_source": settings_status.get("credentials_source", "missing"),
"username_masked": settings_status.get("username_masked", ""),
"verify_ssl": bool(settings_status.get("verify_ssl")),
}
started = perf_counter()
try:
stat2tv = await asyncio.wait_for(service.test_connection(), timeout=12.0)
checks["stat2tv"] = {
"ok": True,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"status_code": stat2tv.get("status_code"),
"auth_mode": stat2tv.get("auth_mode"),
"content_type": stat2tv.get("content_type"),
"items_found": stat2tv.get("items_found", 0),
"url": stat2tv.get("url", ""),
}
except Exception as error:
checks["stat2tv"] = {
"ok": False,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"error": str(error),
}
if agent_hub is not None:
try:
devices_payload = await agent_hub.list_mapping_devices()
devices = list(devices_payload.get("devices") or [])
checks["agents"] = {
"ok": any(bool(item.get("online")) for item in devices),
"optional": True,
"online": sum(1 for item in devices if item.get("online")),
"vmix_connected": sum(1 for item in devices if item.get("online") and item.get("vmix_connected")),
"devices": [
{
"device_id": item.get("device_id", ""),
"name": item.get("name", ""),
"owner": item.get("owner", ""),
"online": bool(item.get("online")),
"vmix_connected": bool(item.get("vmix_connected")),
"vmix_version": item.get("vmix_version", ""),
}
for item in devices
],
}
except Exception as error:
checks["agents"] = {"ok": False, "optional": True, "error": str(error), "devices": []}
else:
checks["agents"] = {"ok": False, "optional": True, "error": "Agent hub недоступен", "devices": []}
tournament_id = str(tournament_id or "").strip()
game_id = str(game_id or "").strip()
if tournament_id:
tournament = service.tournament(tournament_id, language=language)
endpoint = service.endpoint_diagnostics(tournament_external_id=tournament_id)
checks["tournament"] = {
"ok": tournament is not None,
"tournament_id": tournament_id,
"local_found": tournament is not None,
"endpoint_diagnostics": endpoint,
}
if game_id:
local_game = service.game(game_id, language=language)
match_check: dict[str, Any] = {
"ok": local_game is not None,
"game_id": game_id,
"tournament_id": tournament_id,
"local_found": local_game is not None,
"deep_requested": bool(deep),
}
if deep and tournament_id:
started = perf_counter()
try:
payload = await asyncio.wait_for(
service.sync_selected_game_details(
tournament_external_id=tournament_id,
game_external_id=game_id,
language=language,
),
timeout=20.0,
)
details = payload.get("details") if isinstance(payload, dict) else None
match_check.update({
"ok": True,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"degraded": bool(payload.get("degraded")) if isinstance(payload, dict) else False,
"details_sync_failed": bool(payload.get("details_sync_failed")) if isinstance(payload, dict) else False,
"warnings": list(payload.get("warnings") or []) if isinstance(payload, dict) else [],
"details_loaded": bool(details.get("loaded")) if isinstance(details, dict) else False,
})
except Exception as error:
match_check.update({
"ok": False,
"latency_ms": round((perf_counter() - started) * 1000, 1),
"error": str(error),
})
checks["match"] = match_check
database_ok = bool(checks.get("database", {}).get("ok"))
stat2tv_ok = bool(checks.get("stat2tv", {}).get("ok"))
return {
"ok": database_ok,
"required_for_match_selection": {
"ok": database_ok,
"database": database_ok,
},
"required_for_remote_sync": {
"ok": database_ok and stat2tv_ok,
"database": database_ok,
"stat2tv": stat2tv_ok,
},
"checks": checks,
}
@router.post("/sync/tournaments", dependencies=admin)
async def sync_tournaments() -> dict[str, Any]:
try:

View File

@@ -3945,18 +3945,44 @@ class HockeyDataService:
raise
schedule_warning = str(error)
payload = await self.sync_match_details(
tournament_external_id=tournament_external_id,
game_external_id=game_external_id,
language=language,
allow_create=True,
force_optional_shots=True,
)
details_warning = ""
try:
# Opening a match must not depend on every optional Stat2TV resource.
# Some schedule entries exist before/without a full match JSON card.
# Keep the operator UI usable and enrich the selected match when the
# card becomes available instead of failing the whole selection.
payload = await asyncio.wait_for(
self.sync_match_details(
tournament_external_id=tournament_external_id,
game_external_id=game_external_id,
language=language,
allow_create=True,
force_optional_shots=True,
),
timeout=12.0,
)
except Exception as error:
details_warning = str(error)
payload = self.game_details(game_external_id, language=language)
if payload is None:
raise
payload = dict(payload)
payload.update({
"ok": True,
"degraded": True,
"details_sync_failed": True,
"shots": {"available": False},
})
payload["schedule"] = schedule
if schedule_warning:
payload.setdefault("warnings", []).append(
f"Расписание не обновлено: {schedule_warning}"
)
if details_warning:
payload.setdefault("warnings", []).append(
f"Полная карточка матча пока недоступна: {details_warning}"
)
return payload
@staticmethod

View File

@@ -1669,3 +1669,160 @@ html.hockey-match-loading-active body {
overflow-y: auto;
overscroll-behavior: contain;
}
/* BUILD 67 — admin-only connection diagnostics */
button.hockey-connection-bar {
width: 100%;
border-top: 0;
border-left: 0;
border-right: 0;
font: inherit;
text-align: left;
cursor: pointer;
}
button.hockey-connection-bar:hover {
background: rgba(15,31,49,.88);
}
.hockey-match-open-error {
margin-bottom: 8px;
}
.hockey-match-open-error .btn {
justify-self: start;
margin-top: 3px;
}
.hockey-connection-diagnostics-card {
width: min(920px,96vw);
max-height: min(92vh,980px);
display: grid;
grid-template-rows: auto minmax(0,1fr) auto;
overflow: hidden;
}
.hockey-connection-diagnostics-card .hockey-diagnostics-content {
overflow: auto;
align-content: start;
}
.hockey-connection-diagnostics-actions {
min-height: 58px;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #2b4158;
background: #091522;
}
.hockey-diagnostic-summary-grid {
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 10px;
}
.hockey-diagnostic-summary {
display: grid;
gap: 4px;
padding: 12px 13px;
border: 1px solid #30465e;
border-radius: 11px;
background: #091725;
}
.hockey-diagnostic-summary small {
color: #7d92aa;
font-size: 8px;
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
}
.hockey-diagnostic-summary strong {
font-size: 20px;
}
.hockey-diagnostic-summary span {
color: #91a5bb;
font-size: 9px;
}
.hockey-diagnostic-summary.success {
border-color: rgba(72,223,189,.38);
}
.hockey-diagnostic-summary.success strong {
color: #55ddbd;
}
.hockey-diagnostic-summary.error {
border-color: rgba(239,99,122,.40);
}
.hockey-diagnostic-summary.error strong {
color: #ef95a4;
}
.hockey-diagnostic-section {
display: grid;
gap: 10px;
padding: 12px 13px;
border: 1px solid #2b4158;
border-radius: 11px;
background: #091725;
}
.hockey-diagnostic-section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hockey-diagnostic-section > header > strong {
color: #e5eef8;
font-size: 12px;
}
.hockey-diagnostic-status {
flex: 0 0 auto;
font: 900 9px/1 "Roboto Mono","Cascadia Mono",Consolas,monospace;
letter-spacing: .035em;
}
.hockey-diagnostic-status.success { color: #55ddbd; }
.hockey-diagnostic-status.error { color: #ef95a4; }
.hockey-diagnostic-note {
margin: -3px 0 0;
color: #71869d;
font-size: 9px;
}
.hockey-diagnostic-devices {
display: grid;
gap: 6px;
}
.hockey-diagnostic-devices > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 9px;
border: 1px solid #263d54;
border-radius: 8px;
background: #07131f;
}
.hockey-diagnostic-devices strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: #cbd9e7;
font-size: 9px;
white-space: nowrap;
}
.hockey-diagnostic-devices span {
flex: 0 0 auto;
color: #8095ac;
font: 8px "Roboto Mono","Cascadia Mono",Consolas,monospace;
}
.hockey-diagnostic-warnings {
display: grid;
gap: 5px;
}
.hockey-diagnostic-warnings p,
.hockey-diagnostic-section.error > p {
margin: 0;
padding: 8px 9px;
color: #f0b0bb;
border: 1px solid rgba(239,99,122,.28);
border-radius: 8px;
background: rgba(94,29,43,.18);
font-size: 9px;
line-height: 1.45;
}
@media (max-width: 720px) {
.hockey-diagnostic-summary-grid { grid-template-columns: 1fr; }
.hockey-diagnostic-devices > div { align-items: flex-start; flex-direction: column; }
.hockey-connection-diagnostics-actions { flex-wrap: wrap; }
}

View File

@@ -44,10 +44,15 @@
gamesAutoSyncAttempted: new Set(),
scheduleSyncBusy: false,
gameOpenBusy: false,
gameOpenGeneration: 0,
gamePollTimer: null,
gamePollBusy: false,
gamePollErrorCount: 0,
gamePollStoppedGameId: "",
currentUser: null,
isAdmin: false,
lastAttemptedGameId: "",
lastGameOpenError: "",
};
const text = {
@@ -117,6 +122,17 @@
cacheSeconds: "Кэш матчей, сек",
rediscover: "Найти endpoint заново",
diagnostics: "Диагностика",
connections: "Подключения",
checkAgain: "Проверить ещё раз",
deepMatchTest: "Проверить выбранный матч",
matchSelectionReady: "Открытие матча",
remoteSyncReady: "Удалённая синхронизация",
database: "PostgreSQL",
stat2tv: "Stat2TV",
agentVmix: "Agent / vMix",
selectedMatchCheck: "Выбранный матч",
optionalConnection: "не требуется для открытия матча",
adminOnly: "Только для администратора",
startTime: "Начало",
arena: "Арена",
gameNumber: "Матч",
@@ -210,6 +226,17 @@
cacheSeconds: "Games cache, sec",
rediscover: "Rediscover endpoint",
diagnostics: "Diagnostics",
connections: "Connections",
checkAgain: "Check again",
deepMatchTest: "Check selected game",
matchSelectionReady: "Game selection",
remoteSyncReady: "Remote sync",
database: "PostgreSQL",
stat2tv: "Stat2TV",
agentVmix: "Agent / vMix",
selectedMatchCheck: "Selected game",
optionalConnection: "not required to open a game",
adminOnly: "Administrator only",
startTime: "Start",
arena: "Arena",
gameNumber: "Game",
@@ -288,6 +315,8 @@
} catch (_) {
return;
}
state.currentUser = user;
state.isAdmin = Boolean(user?.is_admin);
const currentAccountId = String(user?.id || "").trim();
if (!currentAccountId) return;
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();
@@ -359,10 +388,14 @@
toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200);
}
function matchLabelById(id) {
const game = (state.games?.items || []).find(
function gameById(id) {
return (state.games?.items || []).find(
(item) => String(item?.external_id || item?.id || "") === String(id || "")
);
) || null;
}
function matchLabelById(id) {
const game = gameById(id);
if (!game) return "";
const home = game.home?.name || game.home_name || game.team1 || "";
const away = game.away?.name || game.away_name || game.team2 || "";
@@ -444,7 +477,8 @@
<button type="button" data-language="ru">RU</button>
<button type="button" data-language="en">EN</button>
</div>
${boot.mode === "editor" ? `
${state.isAdmin ? `
<button type="button" class="hockey-nav-icon" data-open-connection-diagnostics data-tooltip="${escapeHtml(t("connections"))}">⌁</button>
<button type="button" class="hockey-nav-icon" data-open-settings data-tooltip="${escapeHtml(t("settings"))}">⚙</button>
` : ""}
${boot.mode !== "editor" ? `
@@ -454,10 +488,12 @@
</div>
</header>
<div class="hockey-connection-bar" data-connection-bar>
<span></span>
<strong>${escapeHtml(t("notConfigured"))}</strong>
</div>
${state.isAdmin ? `
<button type="button" class="hockey-connection-bar" data-connection-bar data-open-connection-diagnostics>
<span></span>
<strong>${escapeHtml(t("notConfigured"))}</strong>
</button>
` : ""}
<section class="hockey-tournament-view" data-view="tournaments">
<div class="hockey-nav-tabs">
@@ -532,7 +568,7 @@
</form>
<div class="hockey-games-list" data-games-list></div>
${boot.mode === "editor" ? `
${state.isAdmin ? `
<footer class="hockey-games-footer">
<button type="button" class="btn" data-games-diagnostics>${escapeHtml(t("diagnostics"))}</button>
<button type="button" class="btn" data-games-rediscover>${escapeHtml(t("rediscover"))}</button>
@@ -555,6 +591,9 @@
renderTournamentList();
});
root.querySelector("[data-open-settings]")?.addEventListener("click", openSettings);
root.querySelectorAll("[data-open-connection-diagnostics]").forEach((button) => {
button.addEventListener("click", () => showConnectionDiagnostics({ deep: false }));
});
root.querySelector("[data-logout]")?.addEventListener("click", () => {
const form = document.createElement("form");
form.method = "post";
@@ -565,7 +604,7 @@
root.querySelector("[data-sync]")?.addEventListener("click", syncTournaments);
root.querySelector("[data-seed-import]")?.addEventListener("click", importSeed);
root.querySelector("[data-games-rediscover]")?.addEventListener("click", () => syncGames(true));
root.querySelector("[data-games-diagnostics]")?.addEventListener("click", showDiagnostics);
root.querySelector("[data-games-diagnostics]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: false }));
root.querySelectorAll("[data-manual-game-form]").forEach((form) => form.addEventListener("submit", (event) => {
event.preventDefault();
const input = event.currentTarget.querySelector("[data-manual-game-id]");
@@ -796,15 +835,19 @@
}
function renderConnection() {
if (!state.isAdmin) return;
const meta = state.navigation?.meta || {};
const bar = state.root.querySelector("[data-connection-bar]");
const bar = state.root?.querySelector("[data-connection-bar]");
if (!bar) return;
const configured = Boolean(meta.credentials_configured);
bar.classList.toggle("connected", configured);
bar.querySelector("strong").textContent = configured ? t("configured") : t("notConfigured");
bar.querySelector("strong").textContent = configured
? (state.language === "en" ? "Stat2TV configured" : "Stat2TV настроен")
: (state.language === "en" ? "Stat2TV credentials missing" : "Stat2TV: нет данных авторизации");
const last = meta.last_sync;
bar.dataset.tooltip = last?.source === "stat2tv" && last?.status === "success"
? `${t("sourceApi")} · ${last.item_count || 0}`
: t("sourceSeed");
? `${t("sourceApi")} · ${last.item_count || 0} · ${t("connections")}`
: `${t("connections")} · ${t("adminOnly")}`;
}
function tournamentListForMode() {
@@ -1263,12 +1306,32 @@
<div class="hockey-games-empty error">
<strong>${escapeHtml(t("syncFailed"))}</strong>
<p>${escapeHtml(message)}</p>
<small>${escapeHtml(boot.mode === "editor" ? t("diagnostics") : "")}</small>
<small>${escapeHtml(state.isAdmin ? `${t("diagnostics")} · ${t("adminOnly")}` : "")}</small>
</div>
`;
}
}
function matchOpenErrorMarkup() {
if (!state.lastGameOpenError) return "";
const diagnosticButton = state.isAdmin
? `<button type="button" class="btn" data-match-error-diagnostics>${escapeHtml(t("connections"))}</button>`
: "";
return `
<div class="hockey-games-empty error hockey-match-open-error">
<strong>${escapeHtml(state.language === "en" ? "Failed to open game" : "Не удалось открыть матч")}</strong>
<p>${escapeHtml(state.lastGameOpenError)}</p>
${diagnosticButton}
</div>
`;
}
function bindMatchErrorDiagnostics(root) {
root?.querySelector("[data-match-error-diagnostics]")?.addEventListener("click", () => {
showConnectionDiagnostics({ deep: true });
});
}
function renderGames() {
if (!state.root || state.view !== "games") return;
renderSelectedTournament();
@@ -1290,7 +1353,7 @@
`;
if (!items.length) {
list.innerHTML = `
list.innerHTML = `${matchOpenErrorMarkup()}
<div class="hockey-games-empty">
<strong>${escapeHtml(meta.last_synced_at ? t("noGames") : t("noGamesLoaded"))}</strong>
<p>${escapeHtml(formatDate(state.gameDate))}</p>
@@ -1299,14 +1362,18 @@
? t("scheduleCached")
: meta.credentials_configured
? t("loadingGames")
: t("notConfigured")
: state.isAdmin
? t("notConfigured")
: t("noGamesLoaded")
)}</small>
</div>
`;
bindMatchErrorDiagnostics(list);
return;
}
list.innerHTML = items.map(gameCard).join("");
list.innerHTML = `${matchOpenErrorMarkup()}${items.map(gameCard).join("")}`;
bindMatchErrorDiagnostics(list);
list.querySelectorAll("[data-game-id]").forEach((button) => {
button.addEventListener("click", () => openGame(button.dataset.gameId));
});
@@ -1411,48 +1478,104 @@ function gameCard(game) {
}
}
async function enrichOpenedGame(id, tournamentId, generation) {
try {
const loaded = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details/sync`
+ `?tournament_id=${encodeURIComponent(tournamentId)}`
+ `&language=${encodeURIComponent(state.language)}`,
{ method: "POST", timeoutMs: 16000 }
);
if (
generation !== state.gameOpenGeneration
|| String(state.selectedGameId || "") !== String(id)
) return;
if (loaded?.game) state.selectedGame = loaded.game;
state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null;
applyGameData();
renderSelectedChip();
renderGames();
if (loaded?.details_sync_failed || loaded?.degraded) {
console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []);
}
} catch (error) {
// Full match JSON, rosters, shots and directories are enrichment data.
// Their absence must never undo a match already selected by the operator.
console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error);
}
}
async function openGame(id, { manual = false } = {}) {
if (state.gameOpenBusy) return;
state.lastAttemptedGameId = String(id || "");
state.lastGameOpenError = "";
state.gameOpenBusy = true;
const generation = ++state.gameOpenGeneration;
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
try {
try {
const endpoint = manual
? `/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`
: `/api/hockey/games/${encodeURIComponent(id)}/details/sync?tournament_id=${encodeURIComponent(state.selectedId)}&language=${encodeURIComponent(state.language)}`;
const loaded = await request(
endpoint,
{ method: "POST", timeoutMs: manual ? 60000 : 30000 }
// Match selection itself depends only on the local/cached schedule row.
// Full Stat2TV match JSON is optional enrichment and is loaded later.
const scheduleGame = gameById(id);
if (manual) {
// Manual ID entry still performs global tournament discovery because the
// game may not exist in the local schedule yet.
const discovered = await request(
`/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ method: "POST", timeoutMs: 60000 }
);
state.selectedGame = loaded.game;
state.selectedMatchDetails = loaded.details || null;
} catch (_) {
// The cached schedule remains usable if Stat2TV is temporarily offline.
state.selectedGame = discovered.game;
state.selectedMatchDetails = discovered.details || null;
} else {
try {
const cached = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${state.language}`
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedGame = cached.game;
state.selectedMatchDetails = cached.details || null;
} catch (_) {
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${state.language}`
);
state.selectedMatchDetails = null;
try {
state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
state.selectedMatchDetails = null;
} catch (baseError) {
if (!scheduleGame) throw baseError;
state.selectedGame = scheduleGame;
state.selectedMatchDetails = null;
}
}
}
if (manual && state.selectedGame?.tournament_external_id && String(state.selectedGame.tournament_external_id) !== String(state.selectedId)) {
state.selectedId = String(state.selectedGame.tournament_external_id);
const actualTournamentId = String(
state.selectedGame?.tournament_external_id || state.selectedId || ""
).trim();
if (!actualTournamentId) throw new Error("Не удалось определить турнир выбранного матча");
if (actualTournamentId !== String(state.selectedId || "")) {
state.selectedId = actualTournamentId;
localStorage.setItem(storage.tournament, state.selectedId);
try {
state.selectedTournament = await request(`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`);
state.standings = null;
state.tournamentStatistics = null;
state.games = null;
applyTournamentData();
} catch (_) {}
state.selectedTournament = await request(
`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
);
} catch (_) {
state.selectedTournament = state.selectedTournament || null;
}
state.standings = null;
state.tournamentStatistics = null;
state.games = null;
state.teamSchedule = null;
state.teamScheduleTournamentId = "";
applyTournamentData();
}
if (manual && state.selectedGame?.tournament_external_id) {
state.selectedId = String(state.selectedGame.tournament_external_id);
localStorage.setItem(storage.tournament, state.selectedId);
}
updateMatchLoading(t("loadingMatchSession"));
@@ -1460,7 +1583,7 @@ function gameCard(game) {
if (previousToken) {
try {
const oldSession = await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`);
if (oldSession.game_external_id !== id) {
if (String(oldSession.game_external_id || "") !== String(id)) {
await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`, { method: "DELETE" });
localStorage.removeItem(storage.session);
}
@@ -1473,7 +1596,7 @@ function gameCard(game) {
const selectedDeviceId = currentAgentDeviceId();
const sessionPayload = {
tournament_external_id: state.selectedId,
game_external_id: id,
game_external_id: String(id),
display_language: state.language,
vmix_language: state.language,
vmix_device_id: selectedDeviceId,
@@ -1486,8 +1609,7 @@ function gameCard(game) {
body: JSON.stringify(sessionPayload),
});
} catch (sessionError) {
// A device stored in localStorage may belong to the previous account.
// Retry the operator session without Agent instead of blocking the match.
// Retry the operator session without Agent if the browser carries a stale device.
if (selectedDeviceId) {
localStorage.removeItem(storage.vmixDevice);
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
@@ -1501,47 +1623,48 @@ function gameCard(game) {
}
}
state.lastGameOpenError = "";
state.selectedGameId = String(id);
localStorage.setItem(storage.game, state.selectedGameId);
if (session?.token) localStorage.setItem(storage.session, session.token);
else localStorage.removeItem(storage.session);
const selectedGameDate = String(state.selectedGame?.date || "").trim();
const scheduleDate = String(state.games?.meta?.date || state.games?.selected_date || "").trim();
const scheduleTournament = String(state.games?.meta?.tournament_external_id || "").trim();
if (
selectedGameDate
&& (selectedGameDate !== scheduleDate || scheduleTournament !== String(state.selectedId))
) {
if (selectedGameDate) {
state.gameDate = selectedGameDate;
localStorage.setItem(storage.gameDate, state.gameDate);
state.games = null;
applyGamesData();
await loadGames({ autoSync: false });
}
applyGameData(session);
updateMatchLoading(t("loadingMatchStandings"));
await loadTournamentStandings({ sync: false });
renderGames();
// Publish the selected match immediately. Do not wait for lineups, shots,
// standings or any remote Stat2TV dependency before the operator can work.
applyGameData(session);
renderSelectedChip();
startSelectedGamePolling();
setOpen(false);
updateMatchLoading(t("loadingMatchFinishing"));
const playersCount = (state.selectedGame.home?.players?.length || 0)
+ (state.selectedGame.away?.players?.length || 0);
const refereesCount = state.selectedGame.referees?.length || 0;
loadTournamentStandings({ sync: false }).catch(() => {});
if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {});
const playersCount = (state.selectedGame?.home?.players?.length || 0)
+ (state.selectedGame?.away?.players?.length || 0);
const refereesCount = state.selectedGame?.referees?.length || 0;
const loadedText = state.selectedMatchDetails?.loaded
? ` · ${playersCount} ${state.language === "en" ? "players" : "игроков"}`
+ ` · ${refereesCount} ${state.language === "en" ? "officials" : "судей"}`
: "";
notify(
`${t("gameOpened")}: ${state.selectedGame.home.name}${state.selectedGame.away.name}${loadedText}`
);
const homeName = state.selectedGame?.home?.name || "—";
const awayName = state.selectedGame?.away?.name || "—";
notify(`${t("gameOpened")}: ${homeName}${awayName}${loadedText}`);
if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; });
setOpen(false);
// Enrich in the background. If a particular match has no full JSON card,
// the already-open schedule match remains active and usable.
void enrichOpenedGame(String(id), state.selectedId, generation);
} catch (error) {
notify(error.message, true);
state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка");
if (state.view === "games") renderGames();
notify(state.lastGameOpenError, true);
} finally {
setMatchLoading(false);
state.gameOpenBusy = false;
@@ -1731,7 +1854,146 @@ document.addEventListener("visibilitychange", () => {
}
});
async function showDiagnostics() {
function diagnosticStatus(ok) {
return `<span class="hockey-diagnostic-status ${ok ? "success" : "error"}">${ok ? "● OK" : "● ERROR"}</span>`;
}
function diagnosticMessage(value) {
if (!value) return "—";
if (value.error) return String(value.error);
if (value.url) return String(value.url);
return "—";
}
async function showConnectionDiagnostics({ deep = false } = {}) {
if (!state.isAdmin) return;
const tournamentId = String(state.selectedId || "").trim();
const gameId = String(state.selectedGameId || state.lastAttemptedGameId || "").trim();
const query = new URLSearchParams({
tournament_id: tournamentId,
game_id: gameId,
language: state.language,
deep: deep ? "true" : "false",
});
let modal = document.querySelector(".hockey-connection-diagnostics-modal");
if (!modal) {
modal = document.createElement("div");
modal.className = "hockey-diagnostics-modal hockey-connection-diagnostics-modal";
modal.innerHTML = `
<div class="hockey-settings-backdrop"></div>
<div class="hockey-diagnostics-card hockey-connection-diagnostics-card">
<header>
<div><small>ADMIN / HEALTH</small><strong>${escapeHtml(t("connections"))}</strong></div>
<button type="button" data-close>×</button>
</header>
<div class="hockey-diagnostics-content" data-connection-diagnostics-content>
<div class="hockey-games-empty"><strong>…</strong></div>
</div>
<footer class="hockey-connection-diagnostics-actions">
<button type="button" class="btn" data-refresh>${escapeHtml(t("checkAgain"))}</button>
<button type="button" class="btn btn-accent" data-deep ${gameId && tournamentId ? "" : "disabled"}>${escapeHtml(t("deepMatchTest"))}</button>
</footer>
</div>
`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector("[data-close]")?.addEventListener("click", close);
modal.querySelector(".hockey-settings-backdrop")?.addEventListener("click", close);
modal.querySelector("[data-refresh]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: false }));
modal.querySelector("[data-deep]")?.addEventListener("click", () => showConnectionDiagnostics({ deep: true }));
}
const content = modal.querySelector("[data-connection-diagnostics-content]");
const deepButton = modal.querySelector("[data-deep]");
if (deepButton) deepButton.disabled = !(gameId && tournamentId);
if (content) content.innerHTML = `<div class="hockey-games-empty"><strong>${escapeHtml(deep ? t("deepMatchTest") : t("checkAgain"))}…</strong></div>`;
try {
const payload = await request(`/api/hockey/admin/connection-diagnostics?${query.toString()}`, { timeoutMs: deep ? 26000 : 16000 });
const checks = payload.checks || {};
const database = checks.database || {};
const stat2tv = checks.stat2tv || {};
const agents = checks.agents || {};
const settings = checks.settings || {};
const match = checks.match || null;
const tournament = checks.tournament || null;
const selection = payload.required_for_match_selection || {};
const remote = payload.required_for_remote_sync || {};
const agentDevices = Array.isArray(agents.devices) ? agents.devices : [];
const warningItems = Array.isArray(match?.warnings) ? match.warnings : [];
content.innerHTML = `
<div class="hockey-diagnostic-summary-grid">
<div class="hockey-diagnostic-summary ${selection.ok ? "success" : "error"}">
<small>${escapeHtml(t("matchSelectionReady"))}</small>
<strong>${selection.ok ? "OK" : "ERROR"}</strong>
<span>${escapeHtml(state.language === "en" ? "Depends on PostgreSQL" : "Зависит от PostgreSQL")}</span>
</div>
<div class="hockey-diagnostic-summary ${remote.ok ? "success" : "error"}">
<small>${escapeHtml(t("remoteSyncReady"))}</small>
<strong>${remote.ok ? "OK" : "ERROR"}</strong>
<span>${escapeHtml(state.language === "en" ? "PostgreSQL + Stat2TV" : "PostgreSQL + Stat2TV")}</span>
</div>
</div>
<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("database"))}</strong>${diagnosticStatus(Boolean(database.ok))}</header>
<dl>
<dt>Latency</dt><dd>${escapeHtml(database.latency_ms ?? "—")} ms</dd>
<dt>Host</dt><dd>${escapeHtml(database.target?.host || "—")}</dd>
<dt>Database</dt><dd>${escapeHtml(database.target?.database || "—")}</dd>
<dt>Error</dt><dd>${escapeHtml(database.error || "—")}</dd>
</dl>
</section>
<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("stat2tv"))}</strong>${diagnosticStatus(Boolean(stat2tv.ok))}</header>
<dl>
<dt>Configured</dt><dd>${settings.credentials_configured ? "YES" : "NO"}</dd>
<dt>Credentials source</dt><dd>${escapeHtml(settings.credentials_source || "missing")}</dd>
<dt>Login</dt><dd>${escapeHtml(settings.username_masked || "—")}</dd>
<dt>Base URL</dt><dd>${escapeHtml(settings.base_url || "—")}</dd>
<dt>HTTP</dt><dd>${escapeHtml(stat2tv.status_code ?? "—")}</dd>
<dt>Items</dt><dd>${escapeHtml(stat2tv.items_found ?? "—")}</dd>
<dt>Latency</dt><dd>${escapeHtml(stat2tv.latency_ms ?? "—")} ms</dd>
<dt>Error</dt><dd>${escapeHtml(diagnosticMessage(stat2tv))}</dd>
</dl>
</section>
<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("agentVmix"))}</strong>${diagnosticStatus(Boolean(agents.ok))}</header>
<p class="hockey-diagnostic-note">${escapeHtml(t("optionalConnection"))}</p>
<dl>
<dt>Agents online</dt><dd>${escapeHtml(agents.online ?? 0)}</dd>
<dt>vMix connected</dt><dd>${escapeHtml(agents.vmix_connected ?? 0)}</dd>
<dt>Error</dt><dd>${escapeHtml(agents.error || "—")}</dd>
</dl>
${agentDevices.length ? `<div class="hockey-diagnostic-devices">${agentDevices.map((item) => `
<div><strong>${escapeHtml(item.name || item.device_id || "Agent")}</strong><span>${item.online ? "ONLINE" : "OFFLINE"} · vMix ${item.vmix_connected ? "OK" : "OFF"}${item.owner ? ` · ${escapeHtml(item.owner)}` : ""}</span></div>
`).join("")}</div>` : ""}
</section>
${tournament ? `<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(state.language === "en" ? "Tournament" : "Турнир")} ${escapeHtml(tournament.tournament_id || "")}</strong>${diagnosticStatus(Boolean(tournament.ok))}</header>
<dl><dt>Local</dt><dd>${tournament.local_found ? "FOUND" : "NOT FOUND"}</dd></dl>
</section>` : ""}
${match ? `<section class="hockey-diagnostic-section">
<header><strong>${escapeHtml(t("selectedMatchCheck"))} ${escapeHtml(match.game_id || "")}</strong>${diagnosticStatus(Boolean(match.ok))}</header>
<dl>
<dt>Local</dt><dd>${match.local_found ? "FOUND" : "NOT FOUND"}</dd>
<dt>Deep</dt><dd>${match.deep_requested ? "YES" : "NO"}</dd>
<dt>Details</dt><dd>${match.details_loaded ? "LOADED" : (match.deep_requested ? "NOT LOADED" : "—")}</dd>
<dt>Degraded</dt><dd>${match.degraded ? "YES" : "NO"}</dd>
<dt>Latency</dt><dd>${escapeHtml(match.latency_ms ?? "—")} ms</dd>
<dt>Error</dt><dd>${escapeHtml(match.error || "—")}</dd>
</dl>
${warningItems.length ? `<div class="hockey-diagnostic-warnings">${warningItems.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>` : ""}
</section>` : ""}
${state.lastGameOpenError ? `<section class="hockey-diagnostic-section error"><header><strong>${escapeHtml(state.language === "en" ? "Last open error" : "Последняя ошибка открытия")}</strong></header><p>${escapeHtml(state.lastGameOpenError)}</p></section>` : ""}
`;
} catch (error) {
if (content) content.innerHTML = `<div class="hockey-games-empty error"><strong>${escapeHtml(t("diagnostics"))}</strong><p>${escapeHtml(error.message)}</p></div>`;
}
}
async function showEndpointDiagnostics() {
if (!state.selectedId) return;
try {
const payload = await request(
@@ -2063,9 +2325,9 @@ document.addEventListener("visibilitychange", () => {
}
async function init() {
await ensureAccountScopedRuntimeState();
ensureShell();
renderStaticLabels();
await ensureAccountScopedRuntimeState();
await loadNavigation();
}