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

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

View File

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