156 lines
5.2 KiB
JavaScript
156 lines
5.2 KiB
JavaScript
const sceneList = document.querySelector("#sceneList");
|
|
const stateText = document.querySelector("#stateText");
|
|
const wsDot = document.querySelector("#wsDot");
|
|
const wsText = document.querySelector("#wsText");
|
|
const refreshBtn = document.querySelector("#refreshBtn");
|
|
const runActionBtn = document.querySelector("#runActionBtn");
|
|
const actionInput = document.querySelector("#actionInput");
|
|
const syncStatus = document.querySelector("#syncStatus");
|
|
|
|
let scenes = [];
|
|
let state = null;
|
|
let syncSnapshot = null;
|
|
|
|
async function loadScenes() {
|
|
const res = await fetch("/api/scenes");
|
|
const data = await res.json();
|
|
scenes = data.scenes;
|
|
state = data.state;
|
|
renderScenes();
|
|
}
|
|
|
|
async function loadSyncStatus() {
|
|
const res = await fetch("/api/sync/status");
|
|
syncSnapshot = await res.json();
|
|
renderSyncStatus();
|
|
}
|
|
|
|
function renderScenes() {
|
|
if (!state) return;
|
|
stateText.textContent = `当前场景:${state.active_scene_id},版本 ${state.version}`;
|
|
sceneList.innerHTML = "";
|
|
for (const scene of scenes) {
|
|
const item = document.createElement("article");
|
|
item.className = `scene ${scene.id === state.active_scene_id ? "active" : ""}`;
|
|
item.innerHTML = `
|
|
<div>
|
|
<h3>${scene.name}</h3>
|
|
<p>${scene.view} | ${scene.url}</p>
|
|
<p>${scene.description ?? ""}</p>
|
|
</div>
|
|
<button data-scene="${scene.id}">切换</button>
|
|
`;
|
|
item.querySelector("button").addEventListener("click", () => switchScene(scene.id));
|
|
sceneList.append(item);
|
|
}
|
|
}
|
|
|
|
function renderSyncStatus() {
|
|
if (!syncSnapshot) return;
|
|
const nodes = syncSnapshot.nodes || [];
|
|
const commands = (syncSnapshot.commands || []).slice(-5).reverse();
|
|
const rows = [];
|
|
rows.push(`<div class="syncRow"><span>输出节点</span><strong>${nodes.length}/${syncSnapshot.target_tiles.length}</strong></div>`);
|
|
|
|
for (const node of nodes) {
|
|
const fps = node.fps == null ? "--" : node.fps.toFixed(1);
|
|
const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`;
|
|
const rtt = node.rtt_ms == null ? "--" : `${Math.round(node.rtt_ms)}ms`;
|
|
const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`;
|
|
rows.push(`<div class="syncRow"><span>${node.tile_id} ${node.node_id.slice(0, 16)}</span><span>${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}</span></div>`);
|
|
}
|
|
|
|
for (const command of commands) {
|
|
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
|
|
rows.push(`<div class="syncRow"><span>${command.command_type} ${command.command_id.slice(0, 8)}</span><span>${command.complete ? "完成" : "进行中"} | ${ackText}</span></div>`);
|
|
}
|
|
|
|
syncStatus.innerHTML = rows.join("");
|
|
}
|
|
|
|
async function switchScene(sceneId) {
|
|
const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" });
|
|
if (!res.ok) {
|
|
alert(await res.text());
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
state = data.state;
|
|
const time = new Date(data.apply_at_ms).toLocaleTimeString("zh-CN", { hour12: false });
|
|
stateText.textContent = `计划切换:${state.active_scene_id},提交时间 ${time}`;
|
|
renderScenes();
|
|
loadSyncStatus();
|
|
}
|
|
|
|
async function postAction(action, args = {}, target = "wall") {
|
|
const res = await fetch("/api/actions", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ target, action, args }),
|
|
});
|
|
if (!res.ok) {
|
|
alert(await res.text());
|
|
return;
|
|
}
|
|
loadSyncStatus();
|
|
}
|
|
|
|
async function runCustomAction() {
|
|
const raw = actionInput.value.trim();
|
|
if (!raw) return;
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(raw);
|
|
} catch {
|
|
payload = { action: raw, args: {} };
|
|
}
|
|
await postAction(payload.action, payload.args || {}, payload.target || "wall");
|
|
}
|
|
|
|
function connectWs() {
|
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
|
const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`);
|
|
ws.addEventListener("open", () => {
|
|
wsDot.classList.add("ok");
|
|
wsText.textContent = "WebSocket 已连接";
|
|
});
|
|
ws.addEventListener("close", () => {
|
|
wsDot.classList.remove("ok");
|
|
wsText.textContent = "WebSocket 重连中";
|
|
setTimeout(connectWs, 1200);
|
|
});
|
|
ws.addEventListener("message", (event) => {
|
|
const message = JSON.parse(event.data);
|
|
if (message.type === "state") {
|
|
state = message.payload.state || message.payload;
|
|
renderScenes();
|
|
}
|
|
if (["prepare_scene", "commit_scene"].includes(message.type)) {
|
|
state = message.payload.state;
|
|
renderScenes();
|
|
loadSyncStatus();
|
|
}
|
|
if (message.type === "ack") {
|
|
wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`;
|
|
loadSyncStatus();
|
|
}
|
|
if (message.type === "heartbeat") {
|
|
loadSyncStatus();
|
|
}
|
|
});
|
|
}
|
|
|
|
refreshBtn.addEventListener("click", loadScenes);
|
|
runActionBtn.addEventListener("click", runCustomAction);
|
|
document.querySelectorAll("[data-action]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const args = button.dataset.args ? JSON.parse(button.dataset.args) : {};
|
|
postAction(button.dataset.action, args);
|
|
});
|
|
});
|
|
|
|
loadScenes();
|
|
loadSyncStatus();
|
|
connectWs();
|
|
setInterval(loadSyncStatus, 2500);
|