313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
(() => {
|
||
"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 = `
|
||
<button id="runtimeDiagnosticsBtn" class="icon-btn runtime-diagnostics-btn" type="button"
|
||
title="Диагностика сервера" aria-label="Диагностика сервера" aria-expanded="false">
|
||
<span class="diagnostics-button-dot"></span>
|
||
</button>
|
||
<div class="hockey-diagnostics-panel" data-diagnostics-panel hidden></div>
|
||
`;
|
||
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 `
|
||
<div class="hockey-diagnostic-event ${esc(level)}">
|
||
<span class="diagnostic-event-icon">${icon}</span>
|
||
<time>${esc(formatClock(item?.time))}</time>
|
||
<b>${esc(sourceLabel)}</b>
|
||
<div><strong>${esc(item?.message || "")}</strong>${details ? `<small>${esc(details)}</small>` : ""}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `
|
||
<article class="hockey-diagnostic-device ${online ? "online" : "offline"} ${selected ? "selected" : ""}">
|
||
<div class="diagnostic-device-head">
|
||
<span class="diagnostic-device-dot ${online ? "online" : "offline"}"></span>
|
||
<div>
|
||
<strong>${esc(item?.name || item?.hostname || item?.device_id || "Agent")}</strong>
|
||
<small>${esc(item?.device_id || "")}${item?.agent_version ? ` · Agent ${esc(item.agent_version)}` : ""}</small>
|
||
</div>
|
||
${selected ? '<em>ЭТА ПАНЕЛЬ</em>' : ""}
|
||
</div>
|
||
<div class="diagnostic-device-grid">
|
||
<span>Agent <b class="${online ? "ok" : "error"}">${online ? "ONLINE" : "OFFLINE"}</b></span>
|
||
<span>vMix <b class="${vmix ? "ok" : "warning"}">${vmix ? "CONNECTED" : "NO CONNECTION"}</b></span>
|
||
<span>Heartbeat <b class="${heartbeatOk ? "ok" : online ? "warning" : "muted"}">${online ? ageLabel(heartbeatAge) : "нет связи"}</b></span>
|
||
${item?.vmix_version ? `<span>vMix <b>${esc(item.vmix_version)}</b></span>` : ""}
|
||
${item?.current_match_id ? `<span>Матч <b>${esc(item.current_match_id)}</b></span>` : ""}
|
||
</div>
|
||
${item?.last_error ? `<div class="diagnostic-device-error">${esc(item.last_error)}</div>` : ""}
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<header class="hockey-diagnostics-header">
|
||
<div>
|
||
<small>SERVER / VMIX BRIDGE</small>
|
||
<strong>Диагностика</strong>
|
||
</div>
|
||
<div class="hockey-diagnostics-header-actions">
|
||
<span class="diagnostic-overall ${statusClass(overall)}">${statusLabel(overall)}</span>
|
||
<button type="button" class="diagnostic-icon-btn" data-diagnostics-refresh title="Обновить">↻</button>
|
||
<button type="button" class="diagnostic-icon-btn" data-diagnostics-close title="Закрыть">×</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="hockey-diagnostics-summary">
|
||
<div class="diagnostic-health-card ${server.ok ? "ok" : "error"}">
|
||
<span>Web / API</span><b>${server.ok ? "РАБОТАЕТ" : "ОШИБКА"}</b>
|
||
<small>uptime ${esc(formatDuration(server.uptime_seconds))}</small>
|
||
</div>
|
||
<div class="diagnostic-health-card ${db.ok ? "ok" : "error"}">
|
||
<span>PostgreSQL</span><b>${db.ok ? "РАБОТАЕТ" : "ОШИБКА"}</b>
|
||
<small>${db.ok ? `${esc(db.latency_ms)} ms` : esc(db.error || "нет соединения")}</small>
|
||
</div>
|
||
<div class="diagnostic-health-card ${auto.running && !auto.last_error ? "ok" : "warning"}">
|
||
<span>Auto refresh</span><b>${auto.running ? (auto.last_error ? "ОШИБКА" : "РАБОТАЕТ") : "ОСТАНОВЛЕН"}</b>
|
||
<small>${auto.last_error ? esc(auto.last_error) : `цикл ${esc(formatClock(auto.last_cycle_at))}`}</small>
|
||
</div>
|
||
<div class="diagnostic-health-card ${selected?.online && selected?.vmix_connected ? "ok" : "warning"}">
|
||
<span>Выбранный Agent</span><b>${selected?.online ? (selected.vmix_connected ? "ONLINE + vMix" : "ONLINE") : "OFFLINE"}</b>
|
||
<small>${selected ? esc(selected.name || selected.device_id) : "не выбран"}</small>
|
||
</div>
|
||
</div>
|
||
|
||
${state.error ? `<div class="hockey-diagnostics-error">Не удалось получить диагностику: ${esc(state.error)}</div>` : ""}
|
||
|
||
<section class="hockey-diagnostics-section">
|
||
<div class="diagnostic-section-title"><strong>Agent / vMix</strong><span>${devices.length} устройств</span></div>
|
||
<div class="hockey-diagnostic-devices">
|
||
${devices.length ? devices.map(deviceRow).join("") : '<div class="diagnostic-empty">Agent пока не зарегистрирован на сервере.</div>'}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="hockey-diagnostics-section diagnostics-events-section">
|
||
<div class="diagnostic-section-title"><strong>Последние события сервера</strong><span>до 120 записей</span></div>
|
||
<div class="hockey-diagnostic-events">
|
||
${events.length ? events.slice(0, 80).map(eventRow).join("") : '<div class="diagnostic-empty">Событий пока нет.</div>'}
|
||
</div>
|
||
</section>
|
||
|
||
<footer class="hockey-diagnostics-footer">
|
||
<span>Последняя проверка: ${esc(formatClock(server.time))}</span>
|
||
<span>Протокол Agent: ${esc(server.agent_protocol ?? "—")}</span>
|
||
</footer>
|
||
`;
|
||
}
|
||
|
||
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();
|
||
})();
|