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

524 lines
24 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 selectedDeviceStorage = "hockey.vmix.selected_device";
const state = {
devices: [],
activeDeviceId: "",
activeDeviceIds: [],
selectedDeviceId: runtimeStorage.getItem(selectedDeviceStorage) || "",
root: null,
panelOpen: false,
loading: false,
deviceLoadPromise: null,
deviceLoadAgain: false,
deviceRevision: 0,
gameContextKey: "",
gameContextLanguage: runtimeStorage.getItem("hockey.language") === "en" ? "en" : "ru",
timer: null,
fastRefreshTimers: [],
message: "",
messageError: false,
testOpenDeviceId: "",
testCommand: { input: "", selectedName: "", value: "" },
};
const esc = (value) => String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
async function api(path, options = {}) {
const controller = new AbortController();
const timeoutMs = Number(options.timeoutMs || 5000);
const timer = setTimeout(() => controller.abort(), timeoutMs);
const fetchOptions = { ...options };
delete fetchOptions.timeoutMs;
if (!fetchOptions.signal) fetchOptions.signal = controller.signal;
let response;
try {
response = await fetch(path, {
cache: "no-store",
credentials: "same-origin",
...fetchOptions,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === "AbortError") throw new Error(`Запрос ${path} превысил ${timeoutMs / 1000} с`);
throw error;
} finally {
clearTimeout(timer);
}
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 runtimeData() {
return window.UIBuilderRuntime?.getData?.() || {};
}
function currentGameId() {
const data = runtimeData();
return String(data.hockey?.selected_game?.external_id || data.hockey?.selected_game?.id || "").trim();
}
function currentSessionToken() {
return String(runtimeData().hockey?.operator_session?.token || "").trim();
}
function gameContextKey() {
const hockey = runtimeData().hockey || {};
const session = hockey.operator_session || hockey.session || {};
return JSON.stringify([
currentGameId(), state.gameContextLanguage,
String(session.token || session.channel_key || "").trim(),
]);
}
function activeDevices() {
return state.devices.filter((item) => item.paired_to_me && item.active_for_account);
}
function selectedDevice() {
return state.devices.find((item) => (
item.device_id === state.selectedDeviceId
&& item.paired_to_me
&& item.active_for_account
)) || null;
}
function selectLocalDevice(deviceId) {
state.deviceRevision += 1;
state.selectedDeviceId = String(deviceId || "").trim();
if (state.selectedDeviceId) runtimeStorage.setItem(selectedDeviceStorage, state.selectedDeviceId);
else runtimeStorage.removeItem(selectedDeviceStorage);
window.dispatchEvent(new CustomEvent("hockey:agent-selected", {
detail: { device_id: state.selectedDeviceId },
}));
}
function mergeDevicePayload(payload) {
if (!payload || !payload.device_id) return;
state.deviceRevision += 1;
const index = state.devices.findIndex((item) => item.device_id === payload.device_id);
const previous = index >= 0 ? state.devices[index] : {};
const merged = { ...previous, ...payload };
if (merged.paired_to_me) merged.pair_state = "mine";
else if (merged.paired) merged.pair_state = "busy";
else merged.pair_state = "free";
if (index >= 0) state.devices.splice(index, 1, merged);
else state.devices.unshift(merged);
}
function scheduleFastRefresh() {
state.fastRefreshTimers.forEach((timerId) => clearTimeout(timerId));
state.fastRefreshTimers = [1000].map((delay) => setTimeout(() => {
load(true).catch(() => {});
}, delay));
}
function refreshMappingInBackground(deviceId, assignment = null) {
if (!deviceId || !assignment?.delivered) return;
api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping`, { method: "POST" })
.then(() => load(true))
.catch((error) => console.warn("Could not refresh vMix Mapping after Agent selection", error));
}
async function bindSelectedDevice(deviceId) {
selectLocalDevice(deviceId);
const token = currentSessionToken();
if (!token) return null;
const result = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, {
method: "POST",
// Agent selection must never wait for a full Mapping push. The session/match
// assignment is committed first; Mapping is refreshed asynchronously below.
body: JSON.stringify({ session_token: token, apply_mapping: false }),
});
refreshMappingInBackground(deviceId, result?.assignment || null);
return result;
}
async function connectAgentToCurrentMatch(deviceId) {
const token = currentSessionToken();
const gameId = currentGameId();
if (!token) throw new Error("Сначала откройте матч в основной панели");
if (!gameId) throw new Error("Сначала выберите матч");
const result = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, {
method: "POST",
body: JSON.stringify({
session_token: token,
apply_mapping: false,
make_primary: false,
}),
});
refreshMappingInBackground(deviceId, result?.assignment || null);
return result;
}
function setMessage(text, error = false) {
state.message = text || "";
state.messageError = error;
render();
if (text) setTimeout(() => {
if (state.message === text) {
state.message = "";
render();
}
}, 3500);
}
function ensureRoot() {
if (state.root?.isConnected) return state.root;
const root = document.createElement("section");
root.className = "hockey-agent-ui";
root.innerHTML = `
<button class="hockey-agent-chip" type="button" data-agent-toggle aria-expanded="false" aria-label="vMix Agent" title="vMix Agent">
<span class="hockey-agent-dot"></span>
</button>
<div class="hockey-agent-panel" data-agent-panel hidden></div>
`;
const toolbar = document.querySelector(".runtime-topbar .runtime-actions");
if (toolbar) toolbar.prepend(root);
else document.body.appendChild(root);
root.querySelector("[data-agent-toggle]")?.addEventListener("click", () => {
state.panelOpen = !state.panelOpen;
if (state.panelOpen) window.dispatchEvent(new CustomEvent("hockey:agent-open"));
render();
});
root.addEventListener("click", onClick);
root.addEventListener("input", (event) => {
const field = event.target.closest("[data-agent-test-field]");
if (!field) return;
const key = field.dataset.agentTestField;
if (key === "input") state.testCommand.input = field.value;
if (key === "selectedName") state.testCommand.selectedName = field.value;
if (key === "value") state.testCommand.value = field.value;
});
state.root = root;
return root;
}
async function onClick(event) {
const button = event.target.closest("button[data-agent-action]");
if (!button || state.loading) return;
const action = button.dataset.agentAction;
const deviceId = button.dataset.deviceId || "";
if (!deviceId) return;
state.loading = true;
state.deviceRevision += 1;
render();
try {
if (action === "pair") {
const hadPrimary = Boolean(selectedDevice()?.device_id && currentGameId());
const paired = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, {
method: "POST",
body: JSON.stringify({ make_active: true }),
});
mergeDevicePayload(paired);
render();
if (hadPrimary) {
await connectAgentToCurrentMatch(deviceId);
scheduleFastRefresh();
setMessage("Agent прикреплён и подключён к текущему матчу; основной Agent не изменён");
} else {
await bindSelectedDevice(deviceId);
scheduleFastRefresh();
setMessage("Agent прикреплён и выбран для этой панели");
}
} else if (action === "activate") {
const hadPrimary = Boolean(selectedDevice()?.device_id && currentGameId());
const activated = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/activate`, { method: "POST" });
mergeDevicePayload(activated);
render();
if (hadPrimary) {
await connectAgentToCurrentMatch(deviceId);
scheduleFastRefresh();
setMessage("Agent включён и подключён к текущему матчу; основной Agent не изменён");
} else {
await bindSelectedDevice(deviceId);
scheduleFastRefresh();
setMessage("Agent включён и выбран для этой панели");
}
} else if (action === "select") {
await bindSelectedDevice(deviceId);
scheduleFastRefresh();
setMessage("Эта панель теперь управляет выбранным Agent");
} else if (action === "assign-match") {
const currentMatch = String(currentGameId() || "").trim();
if (!currentMatch) throw new Error("Сначала выберите матч");
const existing = state.devices.find((item) => item.device_id === deviceId);
if (existing?.current_match_id && existing.current_match_id !== currentMatch) {
const ok = window.confirm(`Agent сейчас подключён к матчу ${existing.current_match_id}.\n\nПодключить его к текущему матчу ${currentMatch}?`);
if (!ok) return;
}
await connectAgentToCurrentMatch(deviceId);
scheduleFastRefresh();
setMessage(`Agent подключён к матчу ${currentMatch} без смены основной панели`);
} else if (action === "deactivate") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/deactivate`, { method: "POST" });
if (state.selectedDeviceId === deviceId) selectLocalDevice("");
setMessage("Получение данных для Agent остановлено");
} else if (action === "test-toggle") {
state.testOpenDeviceId = state.testOpenDeviceId === deviceId ? "" : deviceId;
} else if (action === "test-set-text") {
const input = state.testCommand.input.trim();
const selectedName = state.testCommand.selectedName.trim();
if (!input || !selectedName) throw new Error("Заполните Input и имя текстового поля");
const result = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/test-set-text`, {
method: "POST",
body: JSON.stringify({ input, selected_name: selectedName, value: state.testCommand.value }),
});
setMessage(`SetText выполнен · матч ${result.match_id}`);
} else if (action === "unpair") {
if (!window.confirm("Отвязать этот agent от вашего аккаунта?")) return;
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, { method: "DELETE" });
const existing = state.devices.find((item) => item.device_id === deviceId);
if (existing) mergeDevicePayload({
...existing,
paired: false, paired_to_me: false, active_for_account: false, pair_state: "free",
current_match_id: "", assignment_id: "", owner: "",
});
if (state.testOpenDeviceId === deviceId) state.testOpenDeviceId = "";
if (state.selectedDeviceId === deviceId) selectLocalDevice("");
scheduleFastRefresh();
setMessage("Agent отвязан");
}
state.deviceRevision += 1;
await load(true);
} catch (error) {
setMessage(error.message || "Ошибка Agent", true);
} finally {
state.loading = false;
render();
}
}
function load(force = false) {
if (state.loading && !force) return state.deviceLoadPromise;
if (state.deviceLoadPromise) {
if (force) state.deviceLoadAgain = true;
return state.deviceLoadPromise;
}
state.deviceLoadPromise = (async () => {
do {
state.deviceLoadAgain = false;
const revision = state.deviceRevision;
const contextKey = gameContextKey();
try {
const payload = await api("/api/hockey/agents/devices", { timeoutMs: 3500 });
// An older read must not undo a selection, a mutation or a match change.
if (revision !== state.deviceRevision || contextKey !== gameContextKey()) {
state.deviceLoadAgain = true;
}
if (state.deviceLoadAgain) continue;
state.devices = Array.isArray(payload.devices) ? payload.devices : [];
state.activeDeviceId = String(payload.active_device_id || "");
state.activeDeviceIds = Array.isArray(payload.active_device_ids)
? payload.active_device_ids.map(String)
: (state.activeDeviceId ? [state.activeDeviceId] : []);
const selectedStillValid = state.devices.some((item) => (
item.device_id === state.selectedDeviceId
&& item.paired_to_me
&& item.active_for_account
));
if (!selectedStillValid) {
const mine = activeDevices();
if (mine.length === 1) selectLocalDevice(mine[0].device_id);
else if (state.selectedDeviceId) selectLocalDevice("");
}
const editingTest = Boolean(state.root?.querySelector("[data-agent-test-form]")?.contains(document.activeElement));
if (!editingTest) render();
} catch (error) {
if (!String(error.message || "").includes("401")) {
console.warn("Could not load vMix agents", error);
if (state.panelOpen) setMessage(error.message || "Не удалось получить список Agent", true);
}
}
} while (state.deviceLoadAgain);
})().finally(() => { state.deviceLoadPromise = null; });
return state.deviceLoadPromise;
}
function deviceCard(item) {
const online = Boolean(item.online);
const vmix = Boolean(item.vmix_connected);
const mine = Boolean(item.paired_to_me);
const active = Boolean(item.active_for_account);
const selectedHere = state.selectedDeviceId === item.device_id;
const free = item.pair_state === "free";
const busy = item.pair_state === "busy";
const currentMatch = String(item.current_match_id || "");
const gameId = currentGameId();
let actions = "";
if (free && online) {
actions = `<button type="button" data-agent-action="pair" data-device-id="${esc(item.device_id)}">Прикрепить</button>`;
} else if (mine) {
const canTest = active && selectedHere && online && vmix && currentMatch;
actions = `
${active
? '<span class="hockey-agent-badge active">Получает данные</span>'
: `<button type="button" data-agent-action="activate" data-device-id="${esc(item.device_id)}">Включить</button>`}
${active
? (selectedHere
? '<span class="hockey-agent-badge active">Эта панель</span>'
: `<button type="button" data-agent-action="select" data-device-id="${esc(item.device_id)}">Использовать здесь</button>`)
: ""}
${active && !selectedHere && gameId && currentMatch !== gameId
? `<button class="secondary" type="button" data-agent-action="assign-match" data-device-id="${esc(item.device_id)}">Подключить к матчу</button>`
: ""}
${active && !selectedHere && gameId && currentMatch === gameId
? '<span class="hockey-agent-badge active">Этот матч</span>'
: ""}
${canTest ? `<button class="secondary" type="button" data-agent-action="test-toggle" data-device-id="${esc(item.device_id)}">${state.testOpenDeviceId === item.device_id ? "Скрыть тест" : "Тест SetText"}</button>` : ""}
${active ? `<button class="secondary" type="button" data-agent-action="deactivate" data-device-id="${esc(item.device_id)}">Не получать</button>` : ""}
<button class="danger" type="button" data-agent-action="unpair" data-device-id="${esc(item.device_id)}">Отвязать</button>
`;
} else if (busy) {
actions = '<span class="hockey-agent-badge busy">Занято другим оператором</span>';
}
const testForm = state.testOpenDeviceId === item.device_id ? `
<div class="hockey-agent-test" data-agent-test-form>
<div class="hockey-agent-test-title"><strong>Тест Web → Agent → vMix</strong><small>SetText</small></div>
<label>Input <input data-agent-test-field="input" value="${esc(state.testCommand.input)}" placeholder="например Scorebug"></label>
<label>Поле <input data-agent-test-field="selectedName" value="${esc(state.testCommand.selectedName)}" placeholder="например HomeTeam.Text"></label>
<label>Текст <input data-agent-test-field="value" value="${esc(state.testCommand.value)}" placeholder="TEST FROM WEB"></label>
<button type="button" data-agent-action="test-set-text" data-device-id="${esc(item.device_id)}">Отправить SetText</button>
<small>Тест выполняется только на этом Agent и его текущем матче.</small>
</div>` : "";
return `
<article class="hockey-agent-device ${online ? "is-online" : "is-offline"} ${active ? "is-active" : ""} ${selectedHere ? "is-selected-here" : ""}">
<div class="hockey-agent-device-title">
<span class="hockey-agent-status-light ${online ? "online" : "offline"}"></span>
<div><strong>${esc(item.name || item.hostname || item.device_id)}</strong><code>${esc(item.device_id)}</code></div>
</div>
<div class="hockey-agent-device-grid">
<span>Agent <b>${online ? "ONLINE" : "OFFLINE"}</b></span>
<span>vMix <b class="${vmix ? "ok" : "warn"}">${vmix ? "CONNECTED" : "NO CONNECTION"}</b></span>
${currentMatch ? `<span>Матч <b>${esc(currentMatch)}</b></span>` : ""}
${item.mapping ? `<span>Mapping <b class="ok">${esc(item.mapping.name)} · v${esc(item.mapping.version)}</b></span>` : (item.project_fingerprint ? `<span>Mapping <b class="warn">НЕ НАСТРОЕН</b></span>` : "")}
${item.project_input_count ? `<span>vMix структура <b>${esc(item.project_input_count)} Inputs · ${esc(item.project_field_count || 0)} полей</b></span>` : ""}
${item.agent_version ? `<span>Версия <b>${esc(item.agent_version)}</b></span>` : ""}
</div>
<div class="hockey-agent-actions">${actions}</div>
${testForm}
</article>
`;
}
function render() {
const root = ensureRoot();
const chip = root.querySelector("[data-agent-toggle]");
const panel = root.querySelector("[data-agent-panel]");
if (!chip || !panel) return;
const active = activeDevices();
const selected = selectedDevice();
const freeOnline = state.devices.filter((item) => item.online && item.pair_state === "free").length;
root.classList.toggle("has-active", Boolean(selected?.online));
root.classList.toggle("has-vmix", Boolean(selected?.online && selected?.vmix_connected));
root.classList.toggle("is-offline", Boolean(selected && !selected.online));
let chipTitle = "vMix Agent · не подключён";
if (selected) {
const name = selected.name || selected.hostname || "Agent";
const vmixState = selected.vmix_connected ? "vMix подключён" : "vMix не подключён";
chipTitle = `${name} · ${vmixState}${selected.current_match_id ? ` · матч ${selected.current_match_id}` : ""}`;
} else if (active.length > 1) {
chipTitle = `vMix Agent · ${active.length} активных · выберите устройство`;
} else if (freeOnline) {
chipTitle = `vMix Agent · ${freeOnline} свободных устройств`;
}
chip.title = chipTitle;
chip.setAttribute("aria-label", chipTitle);
chip.setAttribute("aria-expanded", state.panelOpen ? "true" : "false");
panel.hidden = !state.panelOpen;
if (!state.panelOpen) return;
const gameId = currentGameId();
const cards = state.devices.length
? state.devices.map(deviceCard).join("")
: `<div class="hockey-agent-empty">Активных agent.exe пока нет.<br>Запустите agent.exe на компьютере с vMix.</div>`;
panel.innerHTML = `
<header>
<div><small>VMIX BRIDGE</small><strong>Устройства</strong></div>
<div class="hockey-agent-header-actions">
<button type="button" class="secondary" data-agent-refresh>Обновить</button>
<button type="button" class="icon" data-agent-close aria-label="Закрыть">×</button>
</div>
</header>
<div class="hockey-agent-current-match">Web матч: <b>${esc(gameId || "не выбран")}</b> · найдено: <b>${state.devices.length}</b></div>
${state.message ? `<div class="hockey-agent-notice ${state.messageError ? "error" : ""}">${esc(state.message)}</div>` : ""}
<div class="hockey-agent-list">${cards}</div>
<footer>Активных Agent может быть несколько. Несколько Agent могут одновременно работать с одним матчем. «Использовать здесь» меняет основной Agent этой панели, а «Подключить к матчу» подключает дополнительный Agent без переключения управления.</footer>
`;
panel.querySelector("[data-agent-close]")?.addEventListener("click", () => {
state.panelOpen = false;
render();
});
panel.querySelector("[data-agent-refresh]")?.addEventListener("click", async () => {
const button = panel.querySelector("[data-agent-refresh]");
if (button) button.disabled = true;
try {
await load(true);
setMessage("Список Agent обновлён");
} finally {
if (button) button.disabled = false;
}
});
}
window.HockeyAgentRuntime = {
currentDeviceId: () => String(state.selectedDeviceId || ""),
selectDevice: (deviceId) => { selectLocalDevice(deviceId); render(); },
getDevices: () => state.devices.slice(),
};
window.addEventListener("hockey:diagnostics-open", () => {
if (!state.panelOpen) return;
state.panelOpen = false;
render();
});
window.addEventListener("hockey:game-selected", (event) => {
if (event.detail?.language === "ru" || event.detail?.language === "en") {
state.gameContextLanguage = event.detail.language;
}
const contextKey = gameContextKey();
if (contextKey === state.gameContextKey) return;
state.gameContextKey = contextKey;
load(true);
});
window.addEventListener("focus", () => load(true));
document.addEventListener("visibilitychange", () => {
if (!document.hidden) load(true);
});
ensureRoot();
state.gameContextKey = gameContextKey();
load(true);
state.timer = window.setInterval(() => {
if (!document.hidden) load();
}, 1500);
})();