(() => { "use strict"; const runtimeStorage = window.HockeyMatchAccess?.storage || localStorage; const boot = window.UI_BUILDER_BOOT || {}; if (boot.mode !== "runtime") return; const state = { open: false, loading: false, payload: null, error: "", root: null, timer: null, requestId: 0, }; const esc = (value) => String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); async function api() { const response = await fetch("/api/hockey/system/diagnostics", { cache: "no-store", credentials: "same-origin", }); let payload = {}; try { payload = await response.json(); } catch (_) {} if (!response.ok) { const detail = typeof payload.detail === "string" ? payload.detail : payload.detail?.message || `HTTP ${response.status}`; throw new Error(detail); } return payload; } function activeDeviceId() { return String( window.HockeyAgentRuntime?.currentDeviceId?.() || runtimeStorage.getItem("hockey.vmix.selected_device") || "" ).trim(); } function formatDuration(seconds) { const value = Math.max(0, Number(seconds) || 0); if (value < 60) return `${Math.round(value)} с`; const minutes = Math.floor(value / 60); if (minutes < 60) return `${minutes} мин ${Math.round(value % 60)} с`; const hours = Math.floor(minutes / 60); return `${hours} ч ${minutes % 60} мин`; } function formatClock(value) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function ageLabel(seconds) { if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) return "нет данных"; const value = Number(seconds); if (value < 1) return "только что"; return `${value < 60 ? value.toFixed(1) : Math.round(value)} с назад`; } function statusLabel(status) { if (status === "ok") return "OK"; if (status === "error") return "ОШИБКА"; return "ПРОВЕРИТЬ"; } function statusClass(status) { return status === "ok" ? "ok" : status === "error" ? "error" : "warning"; } function deriveButtonStatus(payload) { if (!payload) return "warning"; if (!payload.database?.ok) return "error"; const devices = Array.isArray(payload.devices) ? payload.devices : []; const active = devices.filter((item) => item.active_for_account); if (active.some((item) => !item.online || !item.vmix_connected)) return "warning"; if (!active.length) return "warning"; return "ok"; } function ensureRoot() { if (state.root?.isConnected) return state.root; const toolbar = document.querySelector(".runtime-topbar .runtime-actions"); if (!toolbar) return null; const root = document.createElement("section"); root.className = "hockey-diagnostics-ui"; root.innerHTML = ` `; toolbar.prepend(root); root.querySelector("#runtimeDiagnosticsBtn")?.addEventListener("click", () => { state.open = !state.open; if (state.open) { window.dispatchEvent(new CustomEvent("hockey:diagnostics-open")); load(); } render(); schedule(); }); root.addEventListener("click", (event) => { if (event.target.closest("[data-diagnostics-close]")) { state.open = false; render(); schedule(); } else if (event.target.closest("[data-diagnostics-refresh]")) { load(true); } }); state.root = root; return root; } function eventRow(item) { const level = String(item?.level || "info"); const icon = level === "error" ? "✕" : level === "warning" ? "!" : "·"; const source = String(item?.source || "server"); const sourceLabel = { server: "SERVER", database: "DB", agent: "AGENT", vmix: "vMix", scheduler: "AUTO", }[source] || source.toUpperCase(); const details = item?.details && typeof item.details === "object" ? Object.entries(item.details).map(([key, value]) => `${key}: ${value}`).join(" · ") : ""; return `
${icon} ${esc(sourceLabel)}
${esc(item?.message || "")}${details ? `${esc(details)}` : ""}
`; } function deviceRow(item) { const online = Boolean(item?.online); const vmix = Boolean(item?.vmix_connected); const heartbeatAge = item?.heartbeat_age_seconds; const heartbeatOk = online && heartbeatAge !== null && heartbeatAge !== undefined && Number(heartbeatAge) < 30; const selected = String(item?.device_id || "") === activeDeviceId(); return `
${esc(item?.name || item?.hostname || item?.device_id || "Agent")} ${esc(item?.device_id || "")}${item?.agent_version ? ` · Agent ${esc(item.agent_version)}` : ""}
${selected ? 'ЭТА ПАНЕЛЬ' : ""}
Agent ${online ? "ONLINE" : "OFFLINE"} vMix ${vmix ? "CONNECTED" : "NO CONNECTION"} Heartbeat ${online ? ageLabel(heartbeatAge) : "нет связи"} ${item?.vmix_version ? `vMix ${esc(item.vmix_version)}` : ""} ${item?.current_match_id ? `Матч ${esc(item.current_match_id)}` : ""}
${item?.last_error ? `
${esc(item.last_error)}
` : ""}
`; } function render() { const root = ensureRoot(); if (!root) return; const button = root.querySelector("#runtimeDiagnosticsBtn"); const dot = root.querySelector(".diagnostics-button-dot"); const panel = root.querySelector("[data-diagnostics-panel]"); if (!button || !dot || !panel) return; const status = state.error ? "error" : deriveButtonStatus(state.payload); dot.className = `diagnostics-button-dot ${status}`; button.classList.toggle("is-open", state.open); button.classList.toggle("is-loading", state.loading); button.title = state.error ? `Диагностика сервера: ${state.error}` : "Диагностика сервера"; button.setAttribute("aria-expanded", state.open ? "true" : "false"); panel.hidden = !state.open; if (!state.open) return; const data = state.payload || {}; const db = data.database || {}; const auto = data.auto_refresh || {}; const server = data.server || {}; const devices = Array.isArray(data.devices) ? data.devices : []; const events = Array.isArray(data.events) ? data.events : []; const overall = state.error ? "error" : String(data.overall || "warning"); const selectedId = activeDeviceId(); const selected = devices.find((item) => String(item.device_id) === selectedId) || null; panel.innerHTML = `
SERVER / VMIX BRIDGE Диагностика
${statusLabel(overall)}
Web / API${server.ok ? "РАБОТАЕТ" : "ОШИБКА"} uptime ${esc(formatDuration(server.uptime_seconds))}
PostgreSQL${db.ok ? "РАБОТАЕТ" : "ОШИБКА"} ${db.ok ? `${esc(db.latency_ms)} ms` : esc(db.error || "нет соединения")}
Auto refresh${auto.running ? (auto.last_error ? "ОШИБКА" : "РАБОТАЕТ") : "ОСТАНОВЛЕН"} ${auto.last_error ? esc(auto.last_error) : `цикл ${esc(formatClock(auto.last_cycle_at))}`}
Выбранный Agent${selected?.online ? (selected.vmix_connected ? "ONLINE + vMix" : "ONLINE") : "OFFLINE"} ${selected ? esc(selected.name || selected.device_id) : "не выбран"}
${state.error ? `
Не удалось получить диагностику: ${esc(state.error)}
` : ""}
Agent / vMix${devices.length} устройств
${devices.length ? devices.map(deviceRow).join("") : '
Agent пока не зарегистрирован на сервере.
'}
Последние события серверадо 120 записей
${events.length ? events.slice(0, 80).map(eventRow).join("") : '
Событий пока нет.
'}
`; } async function load(force = false) { if (state.loading && !force) return; const requestId = ++state.requestId; state.loading = true; render(); try { const payload = await api(); if (requestId !== state.requestId) return; state.payload = payload; state.error = ""; } catch (error) { if (requestId !== state.requestId) return; state.error = error?.message || "Ошибка запроса"; } finally { if (requestId === state.requestId) { state.loading = false; render(); } } } function schedule() { if (state.timer) window.clearTimeout(state.timer); state.timer = window.setTimeout(async () => { if (!document.hidden) await load(); schedule(); }, state.open ? 2000 : 5000); } window.addEventListener("hockey:agent-selected", () => { load(true); }); window.addEventListener("hockey:agent-open", () => { if (!state.open) return; state.open = false; render(); schedule(); }); window.addEventListener("focus", () => load(true)); document.addEventListener("visibilitychange", () => { if (!document.hidden) load(true); }); ensureRoot(); load(true); schedule(); })();