diff --git a/KEEP_LOCAL_CREDENTIALS.txt b/KEEP_LOCAL_CREDENTIALS.txt index 8da655d..81974d8 100644 --- a/KEEP_LOCAL_CREDENTIALS.txt +++ b/KEEP_LOCAL_CREDENTIALS.txt @@ -1,3 +1,17 @@ -В архив намеренно не включён settings/stat2tv_credentials.local.json. -Если обновляете существующую рабочую папку, оставьте ваш текущий локальный файл credentials на месте. -Если разворачиваете проект в новой папке, создайте settings/stat2tv_credentials.local.json по примеру settings/stat2tv_credentials.example.json и заполните его локально. +Секреты Stat2TV/KHL API не должны попадать в Git. + +Рекомендуемый 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. diff --git a/app.py b/app.py index 4517def..963e97d 100644 --- a/app.py +++ b/app.py @@ -29,10 +29,19 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app 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" -load_dotenv(BASE_DIR / ".env.local") -load_dotenv(BASE_DIR / ".env") +load_dotenv(BASE_DIR / ".env.local", override=False) +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/") configure_hockey_vmix_settings(BASE_DIR) @@ -353,4 +362,6 @@ async def legacy_runtime_redirect() -> RedirectResponse: @app.get("/ui-builder", include_in_schema=False) 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) diff --git a/hockey_data/config.py b/hockey_data/config.py index 5c9c6a9..72a9a66 100644 --- a/hockey_data/config.py +++ b/hockey_data/config.py @@ -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), } diff --git a/hockey_data/router.py b/hockey_data/router.py index 72d13d6..47594ac 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -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: diff --git a/hockey_data/service.py b/hockey_data/service.py index a8ac674..8d2882f 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -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 diff --git a/hockey_data/static/tournament-menu.css b/hockey_data/static/tournament-menu.css index 118e5e5..d4b0d39 100644 --- a/hockey_data/static/tournament-menu.css +++ b/hockey_data/static/tournament-menu.css @@ -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; } +} diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index 4f887d8..8f88cf7 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -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 @@ - ${boot.mode === "editor" ? ` + ${state.isAdmin ? ` + ` : ""} ${boot.mode !== "editor" ? ` @@ -454,10 +488,12 @@ -
+ ${state.isAdmin ? ` + + ` : ""}