подгрузка данных для 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

@@ -1,3 +1,17 @@
В архив намеренно не включён settings/stat2tv_credentials.local.json. Секреты Stat2TV/KHL API не должны попадать в Git.
Если обновляете существующую рабочую папку, оставьте ваш текущий локальный файл credentials на месте.
Если разворачиваете проект в новой папке, создайте settings/stat2tv_credentials.local.json по примеру settings/stat2tv_credentials.example.json и заполните его локально. Рекомендуемый production-вариант:
/mnt/khl/.env
Переменные:
STAT2TV_LOGIN=...
STAT2TV_PASSWORD=...
STAT2TV_BASE_URL=...
STAT2TV_AUTH_MODE=auto
STAT2TV_VERIFY_SSL=true
Код берёт STAT2TV_LOGIN / STAT2TV_PASSWORD из ENV раньше локального JSON.
settings/stat2tv_credentials.local.json остаётся только fallback-вариантом и игнорируется Git.
UI-конфиги settings/ui_builder_draft.json и settings/ui_builder_published.json,
а также публичный settings/hockey_api.json теперь можно хранить в Git.

19
app.py
View File

@@ -29,10 +29,19 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.19.23" BUILD_VERSION = "2026.08.19.26"
# compatibility: BUILD_VERSION = "2026.08.19.24"
# compatibility: BUILD_VERSION = "2026.08.19.22" # compatibility: BUILD_VERSION = "2026.08.19.22"
load_dotenv(BASE_DIR / ".env.local") load_dotenv(BASE_DIR / ".env.local", override=False)
load_dotenv(BASE_DIR / ".env") load_dotenv(BASE_DIR / ".env", override=False)
# Production can keep secrets outside the Git working tree. systemd already
# injects EnvironmentFile=/mnt/khl/.env, but this fallback also makes a manual
# `python -m uvicorn app:app` launch read the same file. Existing process
# environment variables always win because override=False.
_external_env = Path(os.getenv("HOCKEY_ENV_FILE", "/mnt/khl/.env"))
if _external_env.is_file():
load_dotenv(_external_env, override=False)
VMIX_API_URL = os.getenv("VMIX_API_URL", "http://127.0.0.1:8088/api/") VMIX_API_URL = os.getenv("VMIX_API_URL", "http://127.0.0.1:8088/api/")
configure_hockey_vmix_settings(BASE_DIR) configure_hockey_vmix_settings(BASE_DIR)
@@ -353,4 +362,6 @@ async def legacy_runtime_redirect() -> RedirectResponse:
@app.get("/ui-builder", include_in_schema=False) @app.get("/ui-builder", include_in_schema=False)
async def legacy_editor_redirect() -> RedirectResponse: async def legacy_editor_redirect() -> RedirectResponse:
return RedirectResponse("/editor", status_code=302) # Legacy/bookmarked UI Builder URLs should always land in the operator runtime.
# The editor is opened explicitly from the runtime toolbar after PIN auth.
return RedirectResponse("/", status_code=302)

View File

@@ -382,12 +382,22 @@ class HockeySettingsStore:
password = os.getenv("STAT2TV_PASSWORD", str(raw.get("password") or "")) password = os.getenv("STAT2TV_PASSWORD", str(raw.get("password") or ""))
return username.strip(), password 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]: def status(self) -> dict[str, Any]:
settings = self.public() settings = self.public()
username, password = self.credentials() username, password = self.credentials()
return { return {
**settings, **settings,
"credentials_configured": bool(username and password), "credentials_configured": bool(username and password),
"credentials_source": self.credentials_source(),
"username_masked": self.mask_username(username), "username_masked": self.mask_username(username),
} }

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import date from datetime import date
from time import perf_counter
from typing import Any, Callable from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
from .auth_bridge import HockeyUser from .auth_bridge import HockeyUser
from .service import HockeyDataService from .service import HockeyDataService
@@ -347,6 +350,158 @@ def create_hockey_router(
except Exception as error: except Exception as error:
raise HTTPException(status_code=502, detail=str(error)) from 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) @router.post("/sync/tournaments", dependencies=admin)
async def sync_tournaments() -> dict[str, Any]: async def sync_tournaments() -> dict[str, Any]:
try: try:

View File

@@ -3945,18 +3945,44 @@ class HockeyDataService:
raise raise
schedule_warning = str(error) schedule_warning = str(error)
payload = await self.sync_match_details( 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, tournament_external_id=tournament_external_id,
game_external_id=game_external_id, game_external_id=game_external_id,
language=language, language=language,
allow_create=True, allow_create=True,
force_optional_shots=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 payload["schedule"] = schedule
if schedule_warning: if schedule_warning:
payload.setdefault("warnings", []).append( payload.setdefault("warnings", []).append(
f"Расписание не обновлено: {schedule_warning}" f"Расписание не обновлено: {schedule_warning}"
) )
if details_warning:
payload.setdefault("warnings", []).append(
f"Полная карточка матча пока недоступна: {details_warning}"
)
return payload return payload
@staticmethod @staticmethod

View File

@@ -1669,3 +1669,160 @@ html.hockey-match-loading-active body {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; 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(), gamesAutoSyncAttempted: new Set(),
scheduleSyncBusy: false, scheduleSyncBusy: false,
gameOpenBusy: false, gameOpenBusy: false,
gameOpenGeneration: 0,
gamePollTimer: null, gamePollTimer: null,
gamePollBusy: false, gamePollBusy: false,
gamePollErrorCount: 0, gamePollErrorCount: 0,
gamePollStoppedGameId: "", gamePollStoppedGameId: "",
currentUser: null,
isAdmin: false,
lastAttemptedGameId: "",
lastGameOpenError: "",
}; };
const text = { const text = {
@@ -117,6 +122,17 @@
cacheSeconds: "Кэш матчей, сек", cacheSeconds: "Кэш матчей, сек",
rediscover: "Найти endpoint заново", rediscover: "Найти endpoint заново",
diagnostics: "Диагностика", diagnostics: "Диагностика",
connections: "Подключения",
checkAgain: "Проверить ещё раз",
deepMatchTest: "Проверить выбранный матч",
matchSelectionReady: "Открытие матча",
remoteSyncReady: "Удалённая синхронизация",
database: "PostgreSQL",
stat2tv: "Stat2TV",
agentVmix: "Agent / vMix",
selectedMatchCheck: "Выбранный матч",
optionalConnection: "не требуется для открытия матча",
adminOnly: "Только для администратора",
startTime: "Начало", startTime: "Начало",
arena: "Арена", arena: "Арена",
gameNumber: "Матч", gameNumber: "Матч",
@@ -210,6 +226,17 @@
cacheSeconds: "Games cache, sec", cacheSeconds: "Games cache, sec",
rediscover: "Rediscover endpoint", rediscover: "Rediscover endpoint",
diagnostics: "Diagnostics", 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", startTime: "Start",
arena: "Arena", arena: "Arena",
gameNumber: "Game", gameNumber: "Game",
@@ -288,6 +315,8 @@
} catch (_) { } catch (_) {
return; return;
} }
state.currentUser = user;
state.isAdmin = Boolean(user?.is_admin);
const currentAccountId = String(user?.id || "").trim(); const currentAccountId = String(user?.id || "").trim();
if (!currentAccountId) return; if (!currentAccountId) return;
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim(); const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();
@@ -359,10 +388,14 @@
toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200); toast._hideTimer = setTimeout(() => toast.classList.remove("show"), 3200);
} }
function matchLabelById(id) { function gameById(id) {
const game = (state.games?.items || []).find( return (state.games?.items || []).find(
(item) => String(item?.external_id || item?.id || "") === String(id || "") (item) => String(item?.external_id || item?.id || "") === String(id || "")
); ) || null;
}
function matchLabelById(id) {
const game = gameById(id);
if (!game) return ""; if (!game) return "";
const home = game.home?.name || game.home_name || game.team1 || ""; const home = game.home?.name || game.home_name || game.team1 || "";
const away = game.away?.name || game.away_name || game.team2 || ""; 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="ru">RU</button>
<button type="button" data-language="en">EN</button> <button type="button" data-language="en">EN</button>
</div> </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> <button type="button" class="hockey-nav-icon" data-open-settings data-tooltip="${escapeHtml(t("settings"))}">⚙</button>
` : ""} ` : ""}
${boot.mode !== "editor" ? ` ${boot.mode !== "editor" ? `
@@ -454,10 +488,12 @@
</div> </div>
</header> </header>
<div class="hockey-connection-bar" data-connection-bar> ${state.isAdmin ? `
<button type="button" class="hockey-connection-bar" data-connection-bar data-open-connection-diagnostics>
<span></span> <span></span>
<strong>${escapeHtml(t("notConfigured"))}</strong> <strong>${escapeHtml(t("notConfigured"))}</strong>
</div> </button>
` : ""}
<section class="hockey-tournament-view" data-view="tournaments"> <section class="hockey-tournament-view" data-view="tournaments">
<div class="hockey-nav-tabs"> <div class="hockey-nav-tabs">
@@ -532,7 +568,7 @@
</form> </form>
<div class="hockey-games-list" data-games-list></div> <div class="hockey-games-list" data-games-list></div>
${boot.mode === "editor" ? ` ${state.isAdmin ? `
<footer class="hockey-games-footer"> <footer class="hockey-games-footer">
<button type="button" class="btn" data-games-diagnostics>${escapeHtml(t("diagnostics"))}</button> <button type="button" class="btn" data-games-diagnostics>${escapeHtml(t("diagnostics"))}</button>
<button type="button" class="btn" data-games-rediscover>${escapeHtml(t("rediscover"))}</button> <button type="button" class="btn" data-games-rediscover>${escapeHtml(t("rediscover"))}</button>
@@ -555,6 +591,9 @@
renderTournamentList(); renderTournamentList();
}); });
root.querySelector("[data-open-settings]")?.addEventListener("click", openSettings); 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", () => { root.querySelector("[data-logout]")?.addEventListener("click", () => {
const form = document.createElement("form"); const form = document.createElement("form");
form.method = "post"; form.method = "post";
@@ -565,7 +604,7 @@
root.querySelector("[data-sync]")?.addEventListener("click", syncTournaments); root.querySelector("[data-sync]")?.addEventListener("click", syncTournaments);
root.querySelector("[data-seed-import]")?.addEventListener("click", importSeed); root.querySelector("[data-seed-import]")?.addEventListener("click", importSeed);
root.querySelector("[data-games-rediscover]")?.addEventListener("click", () => syncGames(true)); 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) => { root.querySelectorAll("[data-manual-game-form]").forEach((form) => form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const input = event.currentTarget.querySelector("[data-manual-game-id]"); const input = event.currentTarget.querySelector("[data-manual-game-id]");
@@ -796,15 +835,19 @@
} }
function renderConnection() { function renderConnection() {
if (!state.isAdmin) return;
const meta = state.navigation?.meta || {}; 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); const configured = Boolean(meta.credentials_configured);
bar.classList.toggle("connected", 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; const last = meta.last_sync;
bar.dataset.tooltip = last?.source === "stat2tv" && last?.status === "success" bar.dataset.tooltip = last?.source === "stat2tv" && last?.status === "success"
? `${t("sourceApi")} · ${last.item_count || 0}` ? `${t("sourceApi")} · ${last.item_count || 0} · ${t("connections")}`
: t("sourceSeed"); : `${t("connections")} · ${t("adminOnly")}`;
} }
function tournamentListForMode() { function tournamentListForMode() {
@@ -1263,12 +1306,32 @@
<div class="hockey-games-empty error"> <div class="hockey-games-empty error">
<strong>${escapeHtml(t("syncFailed"))}</strong> <strong>${escapeHtml(t("syncFailed"))}</strong>
<p>${escapeHtml(message)}</p> <p>${escapeHtml(message)}</p>
<small>${escapeHtml(boot.mode === "editor" ? t("diagnostics") : "")}</small> <small>${escapeHtml(state.isAdmin ? `${t("diagnostics")} · ${t("adminOnly")}` : "")}</small>
</div> </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() { function renderGames() {
if (!state.root || state.view !== "games") return; if (!state.root || state.view !== "games") return;
renderSelectedTournament(); renderSelectedTournament();
@@ -1290,7 +1353,7 @@
`; `;
if (!items.length) { if (!items.length) {
list.innerHTML = ` list.innerHTML = `${matchOpenErrorMarkup()}
<div class="hockey-games-empty"> <div class="hockey-games-empty">
<strong>${escapeHtml(meta.last_synced_at ? t("noGames") : t("noGamesLoaded"))}</strong> <strong>${escapeHtml(meta.last_synced_at ? t("noGames") : t("noGamesLoaded"))}</strong>
<p>${escapeHtml(formatDate(state.gameDate))}</p> <p>${escapeHtml(formatDate(state.gameDate))}</p>
@@ -1299,14 +1362,18 @@
? t("scheduleCached") ? t("scheduleCached")
: meta.credentials_configured : meta.credentials_configured
? t("loadingGames") ? t("loadingGames")
: t("notConfigured") : state.isAdmin
? t("notConfigured")
: t("noGamesLoaded")
)}</small> )}</small>
</div> </div>
`; `;
bindMatchErrorDiagnostics(list);
return; return;
} }
list.innerHTML = items.map(gameCard).join(""); list.innerHTML = `${matchOpenErrorMarkup()}${items.map(gameCard).join("")}`;
bindMatchErrorDiagnostics(list);
list.querySelectorAll("[data-game-id]").forEach((button) => { list.querySelectorAll("[data-game-id]").forEach((button) => {
button.addEventListener("click", () => openGame(button.dataset.gameId)); 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 } = {}) { async function openGame(id, { manual = false } = {}) {
if (state.gameOpenBusy) return; if (state.gameOpenBusy) return;
state.lastAttemptedGameId = String(id || "");
state.lastGameOpenError = "";
state.gameOpenBusy = true; state.gameOpenBusy = true;
const generation = ++state.gameOpenGeneration;
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`; const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") }); setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
try { try {
try { // Match selection itself depends only on the local/cached schedule row.
const endpoint = manual // Full Stat2TV match JSON is optional enrichment and is loaded later.
? `/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}` const scheduleGame = gameById(id);
: `/api/hockey/games/${encodeURIComponent(id)}/details/sync?tournament_id=${encodeURIComponent(state.selectedId)}&language=${encodeURIComponent(state.language)}`; if (manual) {
const loaded = await request( // Manual ID entry still performs global tournament discovery because the
endpoint, // game may not exist in the local schedule yet.
{ method: "POST", timeoutMs: manual ? 60000 : 30000 } const discovered = await request(
`/api/hockey/games/manual/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ method: "POST", timeoutMs: 60000 }
); );
state.selectedGame = loaded.game; state.selectedGame = discovered.game;
state.selectedMatchDetails = loaded.details || null; state.selectedMatchDetails = discovered.details || null;
} catch (_) { } else {
// The cached schedule remains usable if Stat2TV is temporarily offline.
try { try {
const cached = await request( 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.selectedGame = cached.game;
state.selectedMatchDetails = cached.details || null; state.selectedMatchDetails = cached.details || null;
} catch (_) { } catch (_) {
try {
state.selectedGame = await request( state.selectedGame = await request(
`/api/hockey/games/${encodeURIComponent(id)}?language=${state.language}` `/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 5000 }
); );
state.selectedMatchDetails = null; 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)) { const actualTournamentId = String(
state.selectedId = String(state.selectedGame.tournament_external_id); 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); localStorage.setItem(storage.tournament, state.selectedId);
try { try {
state.selectedTournament = await request(`/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}?language=${encodeURIComponent(state.language)}`); 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.standings = null;
state.tournamentStatistics = null; state.tournamentStatistics = null;
state.games = null; state.games = null;
state.teamSchedule = null;
state.teamScheduleTournamentId = "";
applyTournamentData(); applyTournamentData();
} catch (_) {} }
if (manual && state.selectedGame?.tournament_external_id) {
state.selectedId = String(state.selectedGame.tournament_external_id);
localStorage.setItem(storage.tournament, state.selectedId);
} }
updateMatchLoading(t("loadingMatchSession")); updateMatchLoading(t("loadingMatchSession"));
@@ -1460,7 +1583,7 @@ function gameCard(game) {
if (previousToken) { if (previousToken) {
try { try {
const oldSession = await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`); 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" }); await request(`/api/hockey/sessions/${encodeURIComponent(previousToken)}`, { method: "DELETE" });
localStorage.removeItem(storage.session); localStorage.removeItem(storage.session);
} }
@@ -1473,7 +1596,7 @@ function gameCard(game) {
const selectedDeviceId = currentAgentDeviceId(); const selectedDeviceId = currentAgentDeviceId();
const sessionPayload = { const sessionPayload = {
tournament_external_id: state.selectedId, tournament_external_id: state.selectedId,
game_external_id: id, game_external_id: String(id),
display_language: state.language, display_language: state.language,
vmix_language: state.language, vmix_language: state.language,
vmix_device_id: selectedDeviceId, vmix_device_id: selectedDeviceId,
@@ -1486,8 +1609,7 @@ function gameCard(game) {
body: JSON.stringify(sessionPayload), body: JSON.stringify(sessionPayload),
}); });
} catch (sessionError) { } catch (sessionError) {
// A device stored in localStorage may belong to the previous account. // Retry the operator session without Agent if the browser carries a stale device.
// Retry the operator session without Agent instead of blocking the match.
if (selectedDeviceId) { if (selectedDeviceId) {
localStorage.removeItem(storage.vmixDevice); localStorage.removeItem(storage.vmixDevice);
try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {} try { window.HockeyAgentRuntime?.selectDevice?.(""); } catch (_) {}
@@ -1501,47 +1623,48 @@ function gameCard(game) {
} }
} }
state.lastGameOpenError = "";
state.selectedGameId = String(id); state.selectedGameId = String(id);
localStorage.setItem(storage.game, state.selectedGameId); localStorage.setItem(storage.game, state.selectedGameId);
if (session?.token) localStorage.setItem(storage.session, session.token); if (session?.token) localStorage.setItem(storage.session, session.token);
else localStorage.removeItem(storage.session); else localStorage.removeItem(storage.session);
const selectedGameDate = String(state.selectedGame?.date || "").trim(); const selectedGameDate = String(state.selectedGame?.date || "").trim();
const scheduleDate = String(state.games?.meta?.date || state.games?.selected_date || "").trim(); if (selectedGameDate) {
const scheduleTournament = String(state.games?.meta?.tournament_external_id || "").trim();
if (
selectedGameDate
&& (selectedGameDate !== scheduleDate || scheduleTournament !== String(state.selectedId))
) {
state.gameDate = selectedGameDate; state.gameDate = selectedGameDate;
localStorage.setItem(storage.gameDate, state.gameDate); localStorage.setItem(storage.gameDate, state.gameDate);
state.games = null;
applyGamesData();
await loadGames({ autoSync: false });
} }
applyGameData(session);
updateMatchLoading(t("loadingMatchStandings")); // Publish the selected match immediately. Do not wait for lineups, shots,
await loadTournamentStandings({ sync: false }); // standings or any remote Stat2TV dependency before the operator can work.
renderGames(); applyGameData(session);
renderSelectedChip(); renderSelectedChip();
startSelectedGamePolling(); startSelectedGamePolling();
setOpen(false);
updateMatchLoading(t("loadingMatchFinishing")); updateMatchLoading(t("loadingMatchFinishing"));
const playersCount = (state.selectedGame.home?.players?.length || 0) loadTournamentStandings({ sync: false }).catch(() => {});
+ (state.selectedGame.away?.players?.length || 0); if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {});
const refereesCount = state.selectedGame.referees?.length || 0;
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 const loadedText = state.selectedMatchDetails?.loaded
? ` · ${playersCount} ${state.language === "en" ? "players" : "игроков"}` ? ` · ${playersCount} ${state.language === "en" ? "players" : "игроков"}`
+ ` · ${refereesCount} ${state.language === "en" ? "officials" : "судей"}` + ` · ${refereesCount} ${state.language === "en" ? "officials" : "судей"}`
: ""; : "";
notify( const homeName = state.selectedGame?.home?.name || "—";
`${t("gameOpened")}: ${state.selectedGame.home.name}${state.selectedGame.away.name}${loadedText}` 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 = ""; }); 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) { } catch (error) {
notify(error.message, true); state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка");
if (state.view === "games") renderGames();
notify(state.lastGameOpenError, true);
} finally { } finally {
setMatchLoading(false); setMatchLoading(false);
state.gameOpenBusy = 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; if (!state.selectedId) return;
try { try {
const payload = await request( const payload = await request(
@@ -2063,9 +2325,9 @@ document.addEventListener("visibilitychange", () => {
} }
async function init() { async function init() {
await ensureAccountScopedRuntimeState();
ensureShell(); ensureShell();
renderStaticLabels(); renderStaticLabels();
await ensureAccountScopedRuntimeState();
await loadNavigation(); await loadNavigation();
} }

27
settings/README.md Normal file
View File

@@ -0,0 +1,27 @@
# settings/
Safe files that may be committed to Git:
- `ui_builder_draft.json` — editable UI Builder configuration.
- `ui_builder_published.json` — published runtime configuration used by operators.
- `hockey_api.json` — public Stat2TV endpoint/settings configuration; no password is stored here.
Files that must stay local and are ignored by Git:
- `stat2tv_credentials.local.json` — fallback local login/password.
- `editor_security.json` — local editor/PIN security data.
- `backups/`, `settings.json`, `vmix_json.json`, `vmix_functions.json` — machine/runtime-specific files.
## Recommended production credentials
Put secrets in the server environment file instead of JSON:
```env
STAT2TV_LOGIN=...
STAT2TV_PASSWORD=...
STAT2TV_BASE_URL=https://...
STAT2TV_AUTH_MODE=auto
STAT2TV_VERIFY_SSL=true
```
The application reads `STAT2TV_LOGIN` and `STAT2TV_PASSWORD` from the process environment first. The local credentials JSON is only a fallback when those variables are absent.

View File

@@ -0,0 +1,4 @@
{
"username": "",
"password": ""
}

View File

@@ -0,0 +1,34 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP = (ROOT / "app.py").read_text(encoding="utf-8")
INTEGRATION = (ROOT / "ui_builder" / "integration.py").read_text(encoding="utf-8")
UI_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
TOURNAMENT_JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
SERVICE = (ROOT / "hockey_data" / "service.py").read_text(encoding="utf-8")
def test_runtime_is_default_even_for_legacy_editor_bookmarks():
assert 'return RedirectResponse("/", status_code=302)' in APP
assert 'request.query_params.get("open") != "1"' in INTEGRATION
assert 'function explicitEditorUrl()' in UI_JS
assert 'open=1' in UI_JS
def test_match_selection_no_longer_depends_on_full_details_sync():
assert 'Match selection itself depends only on the local/cached schedule row.' in TOURNAMENT_JS
assert 'void enrichOpenedGame(String(id), state.selectedId, generation);' in TOURNAMENT_JS
assert 'Full match JSON, rosters, shots and directories are enrichment data.' in TOURNAMENT_JS
assert 'gameOpenGeneration' in TOURNAMENT_JS
def test_server_returns_cached_match_when_full_details_are_unavailable():
assert 'payload = await asyncio.wait_for(' in SERVICE
assert 'timeout=12.0' in SERVICE
assert '"details_sync_failed": True' in SERVICE
assert 'Полная карточка матча пока недоступна' in SERVICE
def test_games_url_keeps_selected_date_and_language():
assert '?on_date=${encodeURIComponent(state.gameDate)}' in TOURNAMENT_JS
assert '&language=${encodeURIComponent(state.language)}' in TOURNAMENT_JS

View File

@@ -0,0 +1,37 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
ROUTER = (ROOT / "hockey_data" / "router.py").read_text(encoding="utf-8")
CSS = (ROOT / "hockey_data" / "static" / "tournament-menu.css").read_text(encoding="utf-8")
def test_connection_diagnostics_are_admin_only():
assert '@router.get("/admin/connection-diagnostics", dependencies=admin)' in ROUTER
assert 'service.database.engine.connect()' in ROUTER
assert 'service.test_connection()' in ROUTER
assert 'agent_hub.list_mapping_devices()' in ROUTER
assert 'required_for_match_selection' in ROUTER
assert 'required_for_remote_sync' in ROUTER
def test_runtime_knows_admin_before_rendering_navigation():
assert 'state.isAdmin = Boolean(user?.is_admin);' in JS
assert 'await ensureAccountScopedRuntimeState();\n ensureShell();' in JS
assert '${state.isAdmin ? `' in JS
assert 'data-open-connection-diagnostics' in JS
assert 'data-open-settings' in JS
def test_non_admin_does_not_get_misleading_connection_setup_warning():
assert ': state.isAdmin\n ? t("notConfigured")\n : t("noGamesLoaded")' in JS
assert 'if (!state.isAdmin) return;' in JS
def test_failed_match_can_be_diagnosed_with_deep_probe():
assert 'lastAttemptedGameId' in JS
assert 'lastGameOpenError' in JS
assert 'data-match-error-diagnostics' in JS
assert 'showConnectionDiagnostics({ deep: true })' in JS
assert 'deep: deep ? "true" : "false"' in JS
assert '.hockey-connection-diagnostics-card' in CSS

View File

@@ -0,0 +1,58 @@
from pathlib import Path
from hockey_data.config import HockeySettingsStore
ROOT = Path(__file__).resolve().parents[1]
def test_git_tracks_ui_settings_but_keeps_credentials_local():
text = (ROOT / ".gitignore").read_text(encoding="utf-8")
assert "settings/*" in text
assert "!settings/ui_builder_draft.json" in text
assert "!settings/ui_builder_published.json" in text
assert "!settings/hockey_api.json" in text
assert "settings/stat2tv_credentials.local.json" in text
assert "settings/editor_security.json" in text
def test_stat2tv_env_credentials_override_local_file(tmp_path, monkeypatch):
store = HockeySettingsStore(tmp_path / "settings")
store.secret_file.write_text(
'{"username":"file-user","password":"file-pass"}',
encoding="utf-8",
)
monkeypatch.setenv("STAT2TV_LOGIN", "env-user")
monkeypatch.setenv("STAT2TV_PASSWORD", "env-pass")
assert store.credentials() == ("env-user", "env-pass")
status = store.status()
assert status["credentials_configured"] is True
assert status["credentials_source"] == "env"
assert "env-pass" not in str(status)
def test_local_credentials_remain_fallback_without_env(tmp_path, monkeypatch):
monkeypatch.delenv("STAT2TV_LOGIN", raising=False)
monkeypatch.delenv("STAT2TV_PASSWORD", raising=False)
store = HockeySettingsStore(tmp_path / "settings")
store.secret_file.write_text(
'{"username":"file-user","password":"file-pass"}',
encoding="utf-8",
)
assert store.credentials() == ("file-user", "file-pass")
assert store.status()["credentials_source"] == "local_file"
def test_production_env_file_fallback_is_present():
app = (ROOT / "app.py").read_text(encoding="utf-8")
assert 'HOCKEY_ENV_FILE' in app
assert '"/mnt/khl/.env"' in app
assert 'load_dotenv(_external_env, override=False)' in app
def test_admin_diagnostics_reports_credential_source_without_secret():
router = (ROOT / "hockey_data" / "router.py").read_text(encoding="utf-8")
js = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
assert '"credentials_source": settings_status.get("credentials_source", "missing")' in router
assert "Credentials source" in js

View File

@@ -129,6 +129,11 @@ def install_ui_builder(
@app.get(editor_url, include_in_schema=False) @app.get(editor_url, include_in_schema=False)
async def ui_builder_editor(request: Request): async def ui_builder_editor(request: Request):
# Runtime is the default landing mode, even if an old bookmark points
# directly at /editor. The visual editor is opened only explicitly
# from the runtime toolbar and carries ?open=1 after PIN auth.
if request.query_params.get("open") != "1":
return RedirectResponse(runtime_url, status_code=302)
if not auth.is_request_authenticated(request): if not auth.is_request_authenticated(request):
return RedirectResponse(runtime_url, status_code=302) return RedirectResponse(runtime_url, status_code=302)
return render_page("editor") return render_page("editor")

View File

@@ -11287,11 +11287,16 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
return [...String(input.value || "")].map((digit) => `<span>${escapeHtml(digit)}</span>`).join(""); return [...String(input.value || "")].map((digit) => `<span>${escapeHtml(digit)}</span>`).join("");
} }
function explicitEditorUrl() {
const base = String(boot.editorUrl || "/editor");
return `${base}${base.includes("?") ? "&" : "?"}open=1`;
}
async function openEditorPinDialog() { async function openEditorPinDialog() {
try { try {
const status = await authApi("/status"); const status = await authApi("/status");
if (status.authenticated) { if (status.authenticated) {
window.location.href = boot.editorUrl; window.location.href = explicitEditorUrl();
return; return;
} }
@@ -11359,7 +11364,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
}); });
message.textContent = "Доступ разрешён"; message.textContent = "Доступ разрешён";
form.classList.add("success"); form.classList.add("success");
setTimeout(() => { window.location.href = boot.editorUrl; }, 300); setTimeout(() => { window.location.href = explicitEditorUrl(); }, 300);
} catch (error) { } catch (error) {
message.textContent = error.message; message.textContent = error.message;
message.classList.add("error"); message.classList.add("error");

View File

@@ -156,7 +156,7 @@
<header class="runtime-topbar"> <header class="runtime-topbar">
<div> <div>
<div class="eyebrow">RUNTIME INTERFACE</div> <div class="eyebrow">RUNTIME INTERFACE</div>
<div class="runtime-title-line"><strong id="runtimeTitle">Интерфейс</strong><small class="runtime-build-badge">BUILD 2026.08.19.21</small></div> <div class="runtime-title-line"><strong id="runtimeTitle">Интерфейс</strong><small class="runtime-build-badge">BUILD 2026.08.19.24</small></div>
</div> </div>
<div id="runtimeTabs" class="runtime-tabs"></div> <div id="runtimeTabs" class="runtime-tabs"></div>
<div class="runtime-actions"> <div class="runtime-actions">