Files
hockey_new/hockey_data/static/agent-diagnostics.js

313 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
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();
})();