(() => {
"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 = `
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, { bootstrap = false } = {}) {
if (!state.open && !bootstrap) return;
if (state.loading) 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 = null;
if (!state.open) return;
state.timer = window.setTimeout(async () => {
if (state.open && !document.hidden) await load();
schedule();
}, 2000);
}
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, { bootstrap: true });
schedule();
})();