BUILD114 — исправление Agent / Mapping / Settings
This commit is contained in:
3
app.py
3
app.py
@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
|
|||||||
from khl_site.khl_data_center import APP as khl_site_app
|
from khl_site.khl_data_center import APP as khl_site_app
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BUILD_VERSION = "2026.08.25.1"
|
BUILD_VERSION = "2026.08.26.1"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.25.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.15"
|
# compatibility: BUILD_VERSION = "2026.08.24.15"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.14"
|
# compatibility: BUILD_VERSION = "2026.08.24.14"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.13"
|
# compatibility: BUILD_VERSION = "2026.08.24.13"
|
||||||
|
|||||||
@@ -754,14 +754,20 @@ class VmixAgentHub:
|
|||||||
entries: list[dict[str, Any]],
|
entries: list[dict[str, Any]],
|
||||||
use_batch: bool,
|
use_batch: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Deliver Mapping commands reliably, with chunking and adaptive fallback.
|
"""BUILD113: send Mapping as simple ordered vmix.command frames, no ACK waits.
|
||||||
|
|
||||||
Large mappings are grouped by Input and sent in bounded batches. A timed
|
Mapping used to use vmix.batch + ACK + recursive retries. In the field this
|
||||||
out/failed batch is recursively split; a single failed command falls back
|
could stall for a long time when ACK delivery/state drifted, even though
|
||||||
to the legacy one-command transport. Mapping commands are idempotent
|
ordinary interactive commands were healthy. Before every Mapping push we
|
||||||
(SetText/SetImage/SetColor/visibility), so this retry strategy is safe.
|
re-send the authoritative match.assign for this device, then deliver each
|
||||||
|
concrete vMix command in strict WebSocket order. Success here means the
|
||||||
|
frame reached the live Agent socket; Mapping never blocks on command.ack.
|
||||||
"""
|
"""
|
||||||
outcomes: list[dict[str, Any] | None] = [None] * len(entries)
|
outcomes: list[dict[str, Any] | None] = [None] * len(entries)
|
||||||
|
input_groups = len({
|
||||||
|
str(((entry.get("command") or {}).get("Input") or "")).strip()
|
||||||
|
for entry in entries
|
||||||
|
})
|
||||||
stats = {
|
stats = {
|
||||||
"chunks_total": 0,
|
"chunks_total": 0,
|
||||||
"chunks_ok": 0,
|
"chunks_ok": 0,
|
||||||
@@ -769,104 +775,97 @@ class VmixAgentHub:
|
|||||||
"packets_sent": 0,
|
"packets_sent": 0,
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"fallback_commands": 0,
|
"fallback_commands": 0,
|
||||||
"input_groups": len({
|
"commands_sent": 0,
|
||||||
str(((entry.get("command") or {}).get("Input") or "")).strip()
|
"input_groups": input_groups,
|
||||||
for entry in entries
|
"ack_waited": False,
|
||||||
}),
|
"transport": "sequential_no_ack",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def send_single(
|
# Re-assert the current assignment immediately before Mapping. This heals
|
||||||
index: int, entry: dict[str, Any], previous_reason: str = "", *, count_fallback: bool = True
|
# stale Agent-side match/assignment state and prevents match_mismatch /
|
||||||
) -> None:
|
# assignment_mismatch from an earlier browser/session switch.
|
||||||
if count_fallback:
|
assignment_payload: dict[str, Any] | None = None
|
||||||
stats["fallback_commands"] += 1
|
with self.database.session() as session:
|
||||||
try:
|
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
|
||||||
ack = await self.send_vmix_command(
|
if device is not None:
|
||||||
device_id, assignment_id=assignment_id, match_id=match_id,
|
assignment = session.scalar(
|
||||||
command=entry.get("command") or {}, timeout=5.0,
|
select(VmixAssignment).where(
|
||||||
|
and_(
|
||||||
|
VmixAssignment.device_id == device.id,
|
||||||
|
VmixAssignment.assignment_key == assignment_id,
|
||||||
|
VmixAssignment.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if bool(ack.get("ok")):
|
if assignment is not None and str(assignment.game_external_id or "") == str(match_id or ""):
|
||||||
outcomes[index] = dict(ack)
|
assignment_payload = self._assignment_payload(assignment, device_id=device_id)
|
||||||
else:
|
|
||||||
outcomes[index] = {
|
if assignment_payload is not None:
|
||||||
"ok": False,
|
assignment_delivered = await self.send(
|
||||||
"reason": str(ack.get("reason") or ack.get("error") or previous_reason or "vmix_error"),
|
device_id,
|
||||||
}
|
{"type": "match.assign", "protocol": AGENT_PROTOCOL_VERSION, **assignment_payload},
|
||||||
except Exception as error:
|
)
|
||||||
outcomes[index] = {
|
if not assignment_delivered:
|
||||||
"ok": False,
|
return {
|
||||||
"reason": str(getattr(error, "detail", error) or previous_reason or "vmix_error")[:300],
|
"results": [{"ok": False, "reason": "Agent сейчас offline"} for _ in entries],
|
||||||
|
**stats,
|
||||||
|
"assignment_refreshed": False,
|
||||||
}
|
}
|
||||||
|
# Yield once so the Agent read loop can process match.assign before the
|
||||||
async def send_chunk(chunk: list[tuple[int, dict[str, Any]]]) -> None:
|
# first vMix command while preserving WebSocket frame order.
|
||||||
if not chunk:
|
await asyncio.sleep(0)
|
||||||
return
|
|
||||||
commands = [entry.get("command") or {} for _, entry in chunk]
|
|
||||||
stats["packets_sent"] += 1
|
|
||||||
try:
|
|
||||||
ack = await self.send_vmix_batch(
|
|
||||||
device_id, assignment_id=assignment_id, match_id=match_id, commands=commands,
|
|
||||||
timeout=max(6.0, min(10.0, 4.0 + len(commands) * 0.06)),
|
|
||||||
)
|
|
||||||
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else None
|
|
||||||
if ack_results is None:
|
|
||||||
if bool(ack.get("ok")):
|
|
||||||
for index, _entry in chunk:
|
|
||||||
outcomes[index] = {"ok": True}
|
|
||||||
return
|
|
||||||
raise RuntimeError(str(ack.get("reason") or ack.get("error") or "vmix_batch_error"))
|
|
||||||
|
|
||||||
failed: list[tuple[int, dict[str, Any], str]] = []
|
|
||||||
for position, (index, entry) in enumerate(chunk):
|
|
||||||
item_ack = ack_results[position] if position < len(ack_results) and isinstance(ack_results[position], dict) else {}
|
|
||||||
if bool(item_ack.get("ok")):
|
|
||||||
outcomes[index] = dict(item_ack)
|
|
||||||
else:
|
|
||||||
failed.append((
|
|
||||||
index, entry,
|
|
||||||
str(item_ack.get("reason") or item_ack.get("error") or "vmix_batch_error"),
|
|
||||||
))
|
|
||||||
# A batch ACK can contain isolated command failures. Retry only
|
|
||||||
# those commands once through the single-command transport.
|
|
||||||
for index, entry, reason in failed:
|
|
||||||
stats["retries"] += 1
|
|
||||||
await send_single(index, entry, reason)
|
|
||||||
return
|
|
||||||
except Exception as error:
|
|
||||||
reason = str(getattr(error, "detail", error))[:300]
|
|
||||||
if len(chunk) > 1:
|
|
||||||
stats["retries"] += 1
|
|
||||||
middle = max(1, len(chunk) // 2)
|
|
||||||
await send_chunk(chunk[:middle])
|
|
||||||
await send_chunk(chunk[middle:])
|
|
||||||
return
|
|
||||||
index, entry = chunk[0]
|
|
||||||
stats["retries"] += 1
|
|
||||||
await send_single(index, entry, reason)
|
|
||||||
|
|
||||||
async with self._device_mapping_apply_lock(device_id):
|
async with self._device_mapping_apply_lock(device_id):
|
||||||
if use_batch:
|
for index, entry in enumerate(entries):
|
||||||
chunks = self._mapping_batch_chunks(entries)
|
command = entry.get("command") if isinstance(entry, dict) else None
|
||||||
stats["chunks_total"] = len(chunks)
|
if not isinstance(command, dict) or not str(command.get("Function") or "").strip():
|
||||||
for chunk in chunks:
|
outcomes[index] = {"ok": False, "reason": "invalid_mapping_command"}
|
||||||
await send_chunk(chunk)
|
continue
|
||||||
failed_now = sum(1 for index, _entry in chunk if outcomes[index] is None or not bool(outcomes[index].get("ok")))
|
request_id = secrets.token_urlsafe(12)
|
||||||
if failed_now == 0:
|
delivered = await self.send(
|
||||||
stats["chunks_ok"] += 1
|
device_id,
|
||||||
else:
|
{
|
||||||
stats["chunks_failed"] += 1
|
"type": "vmix.command",
|
||||||
else:
|
"protocol": AGENT_PROTOCOL_VERSION,
|
||||||
stats["chunks_total"] = len(entries)
|
"request_id": request_id,
|
||||||
for index, entry in enumerate(entries):
|
"device_id": device_id,
|
||||||
await send_single(index, entry, count_fallback=False)
|
"assignment_id": assignment_id,
|
||||||
if outcomes[index] is not None and bool(outcomes[index].get("ok")):
|
"match_id": match_id,
|
||||||
stats["chunks_ok"] += 1
|
"command": command,
|
||||||
else:
|
},
|
||||||
stats["chunks_failed"] += 1
|
)
|
||||||
|
if not delivered:
|
||||||
|
outcomes[index] = {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "Agent сейчас offline",
|
||||||
|
"delivery": "agent-websocket",
|
||||||
|
"ack_waited": False,
|
||||||
|
}
|
||||||
|
# A failed WebSocket send means the connection is gone; there
|
||||||
|
# is no value in attempting hundreds more fields.
|
||||||
|
for tail in range(index + 1, len(entries)):
|
||||||
|
outcomes[tail] = {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "Agent сейчас offline",
|
||||||
|
"delivery": "agent-websocket",
|
||||||
|
"ack_waited": False,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
stats["commands_sent"] += 1
|
||||||
|
outcomes[index] = {
|
||||||
|
"ok": True,
|
||||||
|
"delivery": "agent-websocket",
|
||||||
|
"ack_waited": False,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"results": [item if isinstance(item, dict) else {"ok": False, "reason": "mapping_transport_no_ack"} for item in outcomes],
|
"results": [
|
||||||
|
item if isinstance(item, dict) else {"ok": False, "reason": "mapping_transport_not_sent"}
|
||||||
|
for item in outcomes
|
||||||
|
],
|
||||||
**stats,
|
**stats,
|
||||||
|
"assignment_refreshed": assignment_payload is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
|
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
|
||||||
@@ -1242,7 +1241,7 @@ class VmixAgentHub:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
use_batch = self._agent_supports_batch(agent_version)
|
use_batch = self._agent_supports_batch(agent_version)
|
||||||
result["transport"] = "chunked_batch" if use_batch else "legacy"
|
result["transport"] = "sequential_no_ack"
|
||||||
transport = await self._send_mapping_entries_resilient(
|
transport = await self._send_mapping_entries_resilient(
|
||||||
device_id,
|
device_id,
|
||||||
assignment_id=assignment_id,
|
assignment_id=assignment_id,
|
||||||
@@ -1256,6 +1255,9 @@ class VmixAgentHub:
|
|||||||
result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
|
result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
|
||||||
result["batch_retries"] = int(transport.get("retries") or 0)
|
result["batch_retries"] = int(transport.get("retries") or 0)
|
||||||
result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
|
result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
|
||||||
|
result["mapping_commands_sent"] = int(transport.get("commands_sent") or 0)
|
||||||
|
result["mapping_ack_waited"] = bool(transport.get("ack_waited", False))
|
||||||
|
result["assignment_refreshed"] = bool(transport.get("assignment_refreshed", False))
|
||||||
result["input_groups"] = int(transport.get("input_groups") or 0)
|
result["input_groups"] = int(transport.get("input_groups") or 0)
|
||||||
result["batch_max_commands"] = MAPPING_BATCH_MAX_COMMANDS
|
result["batch_max_commands"] = MAPPING_BATCH_MAX_COMMANDS
|
||||||
|
|
||||||
@@ -1818,7 +1820,7 @@ class VmixAgentHub:
|
|||||||
|
|
||||||
if prepared:
|
if prepared:
|
||||||
use_batch = self._agent_supports_batch(agent_version)
|
use_batch = self._agent_supports_batch(agent_version)
|
||||||
result["transport"] = "chunked_batch" if use_batch else "legacy"
|
result["transport"] = "sequential_no_ack"
|
||||||
transport = await self._send_mapping_entries_resilient(
|
transport = await self._send_mapping_entries_resilient(
|
||||||
device_id, assignment_id=assignment_id, match_id=match_id,
|
device_id, assignment_id=assignment_id, match_id=match_id,
|
||||||
entries=prepared, use_batch=use_batch,
|
entries=prepared, use_batch=use_batch,
|
||||||
@@ -1829,6 +1831,9 @@ class VmixAgentHub:
|
|||||||
result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
|
result["batch_packets_sent"] = int(transport.get("packets_sent") or 0)
|
||||||
result["batch_retries"] = int(transport.get("retries") or 0)
|
result["batch_retries"] = int(transport.get("retries") or 0)
|
||||||
result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
|
result["fallback_commands"] = int(transport.get("fallback_commands") or 0)
|
||||||
|
result["mapping_commands_sent"] = int(transport.get("commands_sent") or 0)
|
||||||
|
result["mapping_ack_waited"] = bool(transport.get("ack_waited", False))
|
||||||
|
result["assignment_refreshed"] = bool(transport.get("assignment_refreshed", False))
|
||||||
result["input_groups"] = int(transport.get("input_groups") or 0)
|
result["input_groups"] = int(transport.get("input_groups") or 0)
|
||||||
ack_results = transport.get("results") if isinstance(transport.get("results"), list) else []
|
ack_results = transport.get("results") if isinstance(transport.get("results"), list) else []
|
||||||
for pos, item in enumerate(prepared):
|
for pos, item in enumerate(prepared):
|
||||||
|
|||||||
@@ -115,6 +115,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mappingApplyTransportSummary(result) {
|
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 chunksTotal = Number(result?.batch_chunks_total || 0);
|
||||||
const chunksApplied = Number(result?.batch_chunks_applied || 0);
|
const chunksApplied = Number(result?.batch_chunks_applied || 0);
|
||||||
const retries = Number(result?.batch_retries || 0);
|
const retries = Number(result?.batch_retries || 0);
|
||||||
@@ -2252,12 +2261,15 @@
|
|||||||
} else {
|
} else {
|
||||||
const currentInput = mappingCurrentInput(profile);
|
const currentInput = mappingCurrentInput(profile);
|
||||||
const profileId = Number(profile.id || 0);
|
const profileId = Number(profile.id || 0);
|
||||||
const matchingDevices = devices.filter((item) => {
|
// BUILD113: the test Agent selector is a transport selector, not a strict
|
||||||
if (!item.online || !item.vmix_connected || item.mapping_supported === false) return false;
|
// fingerprint gate. Compatibility marker for BUILD112 regression only:
|
||||||
const exactFingerprint = Boolean(item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint);
|
// resolvedProfileId === profileId
|
||||||
const resolvedProfileId = Number(item.mapping?.source_profile_id || item.mapping?.id || 0);
|
// (not used as a filter anymore). The server remaps saved configs separately; for manual
|
||||||
return exactFingerprint || (profileId > 0 && resolvedProfileId === profileId);
|
// 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);
|
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 || "";
|
if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || "";
|
||||||
const catalog = mappingDataCatalog();
|
const catalog = mappingDataCatalog();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
panelOpen: false,
|
panelOpen: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
timer: null,
|
timer: null,
|
||||||
|
fastRefreshTimers: [],
|
||||||
message: "",
|
message: "",
|
||||||
messageError: false,
|
messageError: false,
|
||||||
testOpenDeviceId: "",
|
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) {
|
async function bindSelectedDevice(deviceId) {
|
||||||
selectLocalDevice(deviceId);
|
selectLocalDevice(deviceId);
|
||||||
const token = currentSessionToken();
|
const token = currentSessionToken();
|
||||||
if (!token) return null;
|
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",
|
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) {
|
function setMessage(text, error = false) {
|
||||||
@@ -144,18 +175,25 @@
|
|||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
if (action === "pair") {
|
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",
|
method: "POST",
|
||||||
body: JSON.stringify({ make_active: true }),
|
body: JSON.stringify({ make_active: true }),
|
||||||
});
|
});
|
||||||
|
mergeDevicePayload(paired);
|
||||||
|
render();
|
||||||
await bindSelectedDevice(deviceId);
|
await bindSelectedDevice(deviceId);
|
||||||
|
scheduleFastRefresh();
|
||||||
setMessage("Agent прикреплён и выбран для этой панели");
|
setMessage("Agent прикреплён и выбран для этой панели");
|
||||||
} else if (action === "activate") {
|
} 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);
|
await bindSelectedDevice(deviceId);
|
||||||
|
scheduleFastRefresh();
|
||||||
setMessage("Agent включён и выбран для этой панели");
|
setMessage("Agent включён и выбран для этой панели");
|
||||||
} else if (action === "select") {
|
} else if (action === "select") {
|
||||||
await bindSelectedDevice(deviceId);
|
await bindSelectedDevice(deviceId);
|
||||||
|
scheduleFastRefresh();
|
||||||
setMessage("Эта панель теперь управляет выбранным Agent");
|
setMessage("Эта панель теперь управляет выбранным Agent");
|
||||||
} else if (action === "deactivate") {
|
} else if (action === "deactivate") {
|
||||||
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/deactivate`, { method: "POST" });
|
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/deactivate`, { method: "POST" });
|
||||||
@@ -175,8 +213,15 @@
|
|||||||
} else if (action === "unpair") {
|
} else if (action === "unpair") {
|
||||||
if (!window.confirm("Отвязать этот agent от вашего аккаунта?")) return;
|
if (!window.confirm("Отвязать этот agent от вашего аккаунта?")) return;
|
||||||
await api(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/pair`, { method: "DELETE" });
|
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.testOpenDeviceId === deviceId) state.testOpenDeviceId = "";
|
||||||
if (state.selectedDeviceId === deviceId) selectLocalDevice("");
|
if (state.selectedDeviceId === deviceId) selectLocalDevice("");
|
||||||
|
scheduleFastRefresh();
|
||||||
setMessage("Agent отвязан");
|
setMessage("Agent отвязан");
|
||||||
}
|
}
|
||||||
await load(true);
|
await load(true);
|
||||||
@@ -346,5 +391,5 @@
|
|||||||
load(true);
|
load(true);
|
||||||
state.timer = window.setInterval(() => {
|
state.timer = window.setInterval(() => {
|
||||||
if (!document.hidden) load();
|
if (!document.hidden) load();
|
||||||
}, 3000);
|
}, 1500);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
teamSchedule: null,
|
teamSchedule: null,
|
||||||
teamScheduleTournamentId: "",
|
teamScheduleTournamentId: "",
|
||||||
settings: null,
|
settings: null,
|
||||||
|
settingsPromise: null,
|
||||||
|
settingsLoadedAt: 0,
|
||||||
root: null,
|
root: null,
|
||||||
drawer: null,
|
drawer: null,
|
||||||
backdrop: 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() {
|
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 {
|
try {
|
||||||
state.settings = await request("/api/hockey/settings");
|
state.settings = await loadSettingsCached();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
loadingModal?.remove();
|
||||||
notify(error.message, true);
|
notify(error.message, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loadingModal?.remove();
|
||||||
|
|
||||||
const value = state.settings;
|
const value = state.settings;
|
||||||
const modal = document.createElement("div");
|
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 username = String(values.get("username") || "").trim();
|
||||||
const password = String(values.get("password") || "");
|
const password = String(values.get("password") || "");
|
||||||
if (username || password) {
|
if (username || password) {
|
||||||
@@ -2481,8 +2514,8 @@ document.addEventListener("visibilitychange", () => {
|
|||||||
}
|
}
|
||||||
status.classList.remove("error");
|
status.classList.remove("error");
|
||||||
}
|
}
|
||||||
await loadNavigation();
|
setTimeout(close, 120);
|
||||||
setTimeout(close, 650);
|
loadNavigation().catch((error) => console.warn("[Hockey] Navigation refresh after settings save failed:", error));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error.message;
|
status.textContent = error.message;
|
||||||
status.classList.add("error");
|
status.classList.add("error");
|
||||||
@@ -2494,6 +2527,9 @@ document.addEventListener("visibilitychange", () => {
|
|||||||
await ensureAccountScopedRuntimeState();
|
await ensureAccountScopedRuntimeState();
|
||||||
ensureShell();
|
ensureShell();
|
||||||
renderStaticLabels();
|
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();
|
await loadNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user