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 = `
${scene.name}
${scene.view} | ${scene.url}
${scene.description ?? ""}
`;
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(`输出节点${nodes.length}/${syncSnapshot.target_tiles.length}
`);
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(`${node.tile_id} ${node.node_id.slice(0, 16)}${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}
`);
}
for (const command of commands) {
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
rows.push(`${command.command_type} ${command.command_id.slice(0, 8)}${command.complete ? "完成" : "进行中"} | ${ackText}
`);
}
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);