подгрузка данных для api из env
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user