BUILD114 — исправление Agent / Mapping / Settings

This commit is contained in:
2026-08-26 11:56:32 +03:00
parent 9b4db7dc04
commit 077e9c496f
5 changed files with 210 additions and 111 deletions

View File

@@ -115,6 +115,15 @@
}
function mappingApplyTransportSummary(result) {
if (String(result?.transport || "") === "sequential_no_ack") {
const inputs = Number(result?.input_groups || 0);
const sent = Number(result?.mapping_commands_sent ?? result?.applied ?? 0);
const parts = [];
if (inputs) parts.push(`Input: ${inputs}`);
parts.push(`команд Agent: ${sent}`);
parts.push("без ожидания ACK");
return ` · ${parts.join(" · ")}`;
}
const chunksTotal = Number(result?.batch_chunks_total || 0);
const chunksApplied = Number(result?.batch_chunks_applied || 0);
const retries = Number(result?.batch_retries || 0);
@@ -2252,12 +2261,15 @@
} else {
const currentInput = mappingCurrentInput(profile);
const profileId = Number(profile.id || 0);
const matchingDevices = devices.filter((item) => {
if (!item.online || !item.vmix_connected || item.mapping_supported === false) return false;
const exactFingerprint = Boolean(item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint);
const resolvedProfileId = Number(item.mapping?.source_profile_id || item.mapping?.id || 0);
return exactFingerprint || (profileId > 0 && resolvedProfileId === profileId);
});
// BUILD113: the test Agent selector is a transport selector, not a strict
// fingerprint gate. Compatibility marker for BUILD112 regression only:
// resolvedProfileId === profileId
// (not used as a filter anymore). The server remaps saved configs separately; for manual
// Input/field testing any modern online Agent with a live vMix inventory is
// valid and must remain selectable.
const matchingDevices = devices.filter((item) => (
item.online && item.vmix_connected && item.mapping_supported !== false && item.project_fingerprint
));
const onlineOldAgents = devices.filter((item) => item.online && item.vmix_connected && item.mapping_supported === false);
if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || "";
const catalog = mappingDataCatalog();

View File

@@ -14,6 +14,7 @@
panelOpen: false,
loading: false,
timer: null,
fastRefreshTimers: [],
message: "",
messageError: false,
testOpenDeviceId: "",
@@ -82,14 +83,44 @@
}));
}
function mergeDevicePayload(payload) {
if (!payload || !payload.device_id) return;
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 = [0, 250, 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;
return api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, {
const result = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/select-session`, {
method: "POST",
body: JSON.stringify({ session_token: token }),
// 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;
}
function setMessage(text, error = false) {
@@ -144,18 +175,25 @@
render();
try {
if (action === "pair") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, {
const paired = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, {
method: "POST",
body: JSON.stringify({ make_active: true }),
});
mergeDevicePayload(paired);
render();
await bindSelectedDevice(deviceId);
scheduleFastRefresh();
setMessage("Agent прикреплён и выбран для этой панели");
} else if (action === "activate") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/activate`, { method: "POST" });
const activated = await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/activate`, { method: "POST" });
mergeDevicePayload(activated);
render();
await bindSelectedDevice(deviceId);
scheduleFastRefresh();
setMessage("Agent включён и выбран для этой панели");
} else if (action === "select") {
await bindSelectedDevice(deviceId);
scheduleFastRefresh();
setMessage("Эта панель теперь управляет выбранным Agent");
} else if (action === "deactivate") {
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/deactivate`, { method: "POST" });
@@ -175,8 +213,15 @@
} 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 отвязан");
}
await load(true);
@@ -346,5 +391,5 @@
load(true);
state.timer = window.setInterval(() => {
if (!document.hidden) load();
}, 3000);
}, 1500);
})();

View File

@@ -36,6 +36,8 @@
teamSchedule: null,
teamScheduleTournamentId: "",
settings: null,
settingsPromise: null,
settingsLoadedAt: 0,
root: null,
drawer: null,
backdrop: null,
@@ -2230,13 +2232,41 @@ document.addEventListener("visibilitychange", () => {
}
}
function loadSettingsCached({ force = false } = {}) {
const fresh = state.settings && (Date.now() - Number(state.settingsLoadedAt || 0)) < 30000;
if (!force && fresh) return Promise.resolve(state.settings);
if (state.settingsPromise) return state.settingsPromise;
state.settingsPromise = request("/api/hockey/settings", { timeoutMs: 5000 })
.then((payload) => {
state.settings = payload || {};
state.settingsLoadedAt = Date.now();
return state.settings;
})
.finally(() => { state.settingsPromise = null; });
return state.settingsPromise;
}
async function openSettings() {
let loadingModal = null;
if (!state.settings) {
loadingModal = document.createElement("div");
loadingModal.className = "hockey-settings-modal";
loadingModal.innerHTML = `
<div class="hockey-settings-backdrop"></div>
<div class="hockey-settings-card">
<header><div><small>STAT2TV</small><strong>${escapeHtml(t("settings"))}</strong></div></header>
<div class="hockey-settings-body"><p class="wide">${state.language === "en" ? "Loading settings…" : "Загрузка настроек…"}</p></div>
</div>`;
document.body.appendChild(loadingModal);
}
try {
state.settings = await request("/api/hockey/settings");
state.settings = await loadSettingsCached();
} catch (error) {
loadingModal?.remove();
notify(error.message, true);
return;
}
loadingModal?.remove();
const value = state.settings;
const modal = document.createElement("div");
@@ -2440,6 +2470,9 @@ document.addEventListener("visibilitychange", () => {
}),
});
state.settings = savedSettings || state.settings || {};
state.settingsLoadedAt = Date.now();
const username = String(values.get("username") || "").trim();
const password = String(values.get("password") || "");
if (username || password) {
@@ -2481,8 +2514,8 @@ document.addEventListener("visibilitychange", () => {
}
status.classList.remove("error");
}
await loadNavigation();
setTimeout(close, 650);
setTimeout(close, 120);
loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error));
} catch (error) {
status.textContent = error.message;
status.classList.add("error");
@@ -2494,6 +2527,9 @@ document.addEventListener("visibilitychange", () => {
await ensureAccountScopedRuntimeState();
ensureShell();
renderStaticLabels();
// Warm the tiny local settings endpoint while navigation is loading, so the
// gear opens immediately instead of starting its first request on click.
void loadSettingsCached().catch(() => {});
await loadNavigation();
}