подгрузка данных для api из env
This commit is contained in:
@@ -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
19
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
|
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)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
27
settings/README.md
Normal 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.
|
||||||
4
settings/stat2tv_credentials.example.json
Normal file
4
settings/stat2tv_credentials.example.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
37
tests/test_build67_admin_connection_diagnostics.py
Normal file
37
tests/test_build67_admin_connection_diagnostics.py
Normal 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
|
||||||
58
tests/test_build68_settings_env_git.py
Normal file
58
tests/test_build68_settings_env_git.py
Normal 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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user