"use strict"; const state = { token: "", siteId: "", config: { page_size: 16, max_active_previews: 4, webrtc_base_url: "" }, items: [], quota: null, cursors: [null], pageIndex: 0, nextCursor: null, selected: new Map(), previewing: new Set(), loading: false, }; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => Array.from(document.querySelectorAll(selector)); const labels = { modality: { video: "视频", radar: "雷达", contact: "门磁", button: "按钮", wearable: "可穿戴", other: "其他" }, desired: { enabled: "启用", disabled: "停用" }, actual: { online: "在线", offline: "离线", failed: "失败", pending: "收敛中" }, adapter: { ready: "适配器就绪", pending: "适配器处理中", adapter_not_ready: "适配器未交付", authentication_failed: "认证失败", unavailable: "适配器不可用" }, capability: { video_capture: "成像", audio_capture: "音频", spatial_rule: "空间配置", telemetry: "遥测" }, }; function showToast(message) { const toast = $("#toast"); toast.textContent = message; toast.hidden = false; window.clearTimeout(showToast.timer); showToast.timer = window.setTimeout(() => { toast.hidden = true; }, 4200); } function setText(selector, value) { const target = $(selector); if (target) target.textContent = String(value); } function formatTime(value) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).format(date); } function isPreviewEligible(device) { return Array.isArray(device.capabilities) && device.capabilities.includes("video_capture") && device.desired_state === "enabled" && device.actual_state === "online" && device.converged === true; } function attentionNeeded(device) { return device.converged !== true || device.actual_state === "offline" || device.actual_state === "failed" || device.adapter_status === "authentication_failed" || device.adapter_status === "unavailable"; } function statusFor(device) { if (device.actual_state === "online" && device.converged) return { text: "在线 · 已收敛", tone: "success" }; if (device.actual_state === "failed" || device.adapter_status === "authentication_failed") return { text: labels.adapter[device.adapter_status] || "失败", tone: "danger" }; if (device.actual_state === "offline") return { text: "离线", tone: "danger" }; return { text: device.converged ? (labels.actual[device.actual_state] || "未知") : "等待收敛", tone: "warning" }; } function createStatusBadge(device) { const value = statusFor(device); const badge = document.createElement("span"); badge.className = `status-badge ${value.tone}`; badge.textContent = value.text; return badge; } function setConnected(connected) { const pill = $("#connectionPill"); const foot = $(".sidebar-foot"); pill.classList.toggle("connected", connected); foot.classList.toggle("connected", connected); setText("#connectionText", connected ? "已连接" : "未连接"); setText("#sessionState", connected ? "会话仅驻留当前页面" : "尚未建立会话"); } function setLoading(loading) { state.loading = loading; $("#refreshDevices").disabled = loading || !state.token; $("#refreshOperations").disabled = loading || !state.token; $("#retryLoad").disabled = loading || !state.token; $("#prevPage").disabled = loading || state.pageIndex === 0; $("#nextPage").disabled = loading || !state.nextCursor; setText("#deviceTableStatus", loading ? "正在读取最新状态…" : state.token ? "已读取当前页真实设备状态。" : "请先建立会话。"); } function showDegraded(title, message) { setText("#degradedTitle", title); setText("#degradedMessage", message); $("#degradedBanner").hidden = false; } function clearDegraded() { $("#degradedBanner").hidden = true; } async function apiFetch(path) { const response = await fetch(path, { headers: { Authorization: `Bearer ${state.token}`, Accept: "application/json" }, cache: "no-store", }); let payload = null; try { payload = await response.json(); } catch (_) { payload = null; } if (!response.ok) { const error = new Error(payload && payload.code ? payload.code : `http_${response.status}`); error.status = response.status; error.payload = payload; throw error; } return payload; } function buildDevicePath(cursor) { const params = new URLSearchParams(); params.set("limit", String(state.config.page_size)); const modality = $("#modalityFilter").value; const desired = $("#desiredFilter").value; const actual = $("#actualFilter").value; if (modality) params.set("modality", modality); if (desired) params.set("desired_state", desired); if (actual) params.set("actual_state", actual); if (cursor) params.set("cursor", cursor); return `/api/v1/sites/${encodeURIComponent(state.siteId)}/devices?${params.toString()}`; } async function loadDevices(options = {}) { if (!state.token || state.loading) return; const cursor = state.cursors[state.pageIndex] || null; setLoading(true); clearDegraded(); try { const payload = await apiFetch(buildDevicePath(cursor)); state.items = Array.isArray(payload.items) ? payload.items : []; state.quota = payload.quota || null; state.nextCursor = payload.page && payload.page.has_more ? payload.page.next_cursor : null; for (const device of state.items) { if (state.selected.has(device.id)) { if (isPreviewEligible(device)) state.selected.set(device.id, device); else state.selected.delete(device.id); } } setConnected(true); renderAll(); if (options.announce !== false) showToast(`已刷新第 ${state.pageIndex + 1} 页,共 ${state.items.length} 台设备`); return true; } catch (error) { handleLoadError(error); throw error; } finally { setLoading(false); } } function handleLoadError(error) { let title = "无法读取最新状态"; let message = "未知不会显示为在线。请检查 Sense 进程、网络和依赖后重试。"; if (error.status === 401) { state.token = ""; setConnected(false); title = "会话无效或已过期"; message = "Bearer token 已从页面内存清除,请重新建立会话。"; } else if (error.status === 403) { title = "当前角色没有设备读取权限"; message = "页面不会尝试绕过权限;请使用具备 devices:read 的会话。"; } else if (error.status === 404) { title = "无法访问该站点"; message = "目标不存在或当前会话无权访问,页面不会区分这两种情况。"; } else if (error.payload && ["quota_projection_unavailable", "area_policy_unavailable"].includes(error.payload.code)) { title = "策略投影暂时不可用"; message = "已有链路不会因此被静默停用;相关新写入应保持禁用。"; } showDegraded(title, message); state.items = []; state.quota = null; renderAll(); } function visibleItems() { const query = $("#deviceSearch").value.trim().toLocaleLowerCase("zh-CN"); if (!query) return state.items; return state.items.filter((device) => `${device.name} ${device.serial_number}`.toLocaleLowerCase("zh-CN").includes(query)); } function renderAll() { renderDevices(); renderMetrics(); renderHealth(); renderOperations(); renderSelection(); } function renderMetrics() { const used = state.quota && Number.isInteger(state.quota.used_video_channels) ? state.quota.used_video_channels : null; const max = state.quota && Number.isInteger(state.quota.max_video_channels) ? state.quota.max_video_channels : null; setText("#quotaMetric", used === null || max === null ? "—" : `${used} / ${max}`); setText("#quotaHint", state.quota ? `投影状态:${state.quota.status || "未知"}` : "连接后读取 Bell 投影"); setText("#pageMetric", state.items.length); setText("#convergedMetric", state.items.filter((device) => device.converged === true).length); const attention = state.items.filter(attentionNeeded).length; setText("#attentionMetric", attention); setText("#deviceCount", state.items.length); setText("#issueCount", attention); } function addTextCell(row, primary, secondary) { const cell = document.createElement("td"); const strong = document.createElement("strong"); strong.textContent = primary || "—"; cell.appendChild(strong); if (secondary) { const small = document.createElement("small"); small.textContent = secondary; cell.appendChild(small); } row.appendChild(cell); } function createPreviewCheckbox(device) { const label = document.createElement("label"); label.className = "select-preview"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = state.selected.has(device.id); checkbox.disabled = !isPreviewEligible(device); checkbox.setAttribute("aria-label", checkbox.disabled ? `${device.name} 当前不可预览` : `选择 ${device.name} 进行预览`); checkbox.addEventListener("change", () => toggleSelection(device, checkbox)); label.appendChild(checkbox); return label; } function renderDevices() { const rows = $("#deviceRows"); const cards = $("#deviceCards"); rows.replaceChildren(); cards.replaceChildren(); const items = visibleItems(); for (const device of items) { const row = document.createElement("tr"); const selectCell = document.createElement("td"); selectCell.appendChild(createPreviewCheckbox(device)); row.appendChild(selectCell); addTextCell(row, device.name, device.serial_number); addTextCell(row, labels.modality[device.modality] || device.modality, (device.capabilities || []).map((value) => labels.capability[value] || value).join(" / ")); const statusCell = document.createElement("td"); statusCell.appendChild(createStatusBadge(device)); row.appendChild(statusCell); addTextCell(row, labels.desired[device.desired_state] || device.desired_state, labels.actual[device.actual_state] || device.actual_state); addTextCell(row, device.area_id || "—", device.projection_versions && device.projection_versions.area_policy_source_version ? `策略 v${device.projection_versions.area_policy_source_version}` : "策略版本未知"); addTextCell(row, formatTime(device.updated_at), device.next_attempt_at ? `重试 ${formatTime(device.next_attempt_at)}` : ""); rows.appendChild(row); const card = document.createElement("article"); card.className = "device-card"; const cardHead = document.createElement("div"); cardHead.className = "device-card-head"; const identity = document.createElement("div"); const name = document.createElement("strong"); const serial = document.createElement("small"); name.textContent = device.name; serial.textContent = device.serial_number; identity.append(name, serial); cardHead.append(identity, createPreviewCheckbox(device)); const status = createStatusBadge(device); const details = document.createElement("dl"); const pairs = [ ["状态", "", status], ["模态", labels.modality[device.modality] || device.modality], ["期望 / 实际", `${labels.desired[device.desired_state] || device.desired_state} / ${labels.actual[device.actual_state] || device.actual_state}`], ["Area", device.area_id || "—"], ]; for (const [term, value, node] of pairs) { const wrapper = document.createElement("div"); const dt = document.createElement("dt"); const dd = document.createElement("dd"); dt.textContent = term; if (node) dd.appendChild(node); else dd.textContent = value; wrapper.append(dt, dd); details.appendChild(wrapper); } card.append(cardHead, details); cards.appendChild(card); } if (items.length === 0) { const empty = document.createElement("div"); empty.className = "empty-compact"; empty.textContent = state.token ? "当前页没有符合条件的设备。" : "请先建立会话。"; cards.appendChild(empty.cloneNode(true)); const cell = document.createElement("td"); cell.colSpan = 7; cell.appendChild(empty); const row = document.createElement("tr"); row.appendChild(cell); rows.appendChild(row); } setText("#pageLabel", `第 ${state.pageIndex + 1} 页 · 每页 ${state.config.page_size} · 当前显示 ${items.length} 项`); $("#prevPage").disabled = state.loading || state.pageIndex === 0; $("#nextPage").disabled = state.loading || !state.nextCursor; } function toggleSelection(device, checkbox) { if (checkbox.checked) { if (!isPreviewEligible(device)) { checkbox.checked = false; showToast("只有在线、已启用且已收敛的视频设备可以预览"); return; } if (!state.selected.has(device.id) && state.selected.size >= state.config.max_active_previews) { checkbox.checked = false; showToast(`最多同时选择 ${state.config.max_active_previews} 路预览`); return; } state.selected.set(device.id, device); } else { state.selected.delete(device.id); if (state.previewing.has(device.id)) stopPreview(device.id); } renderSelection(); renderDevices(); } function renderSelection() { setText("#selectionCount", state.selected.size); setText("#previewCount", `${state.previewing.size}/${state.config.max_active_previews}`); $("#startPreview").disabled = state.selected.size === 0; $("#stopAll").disabled = state.previewing.size === 0; } function previewURL(deviceID) { const url = new URL(`${state.config.webrtc_base_url}/devices/${encodeURIComponent(deviceID)}`); url.searchParams.set("controls", "true"); url.searchParams.set("muted", "true"); url.searchParams.set("autoplay", "true"); url.searchParams.set("playsInline", "true"); return url.toString(); } function startSelectedPreviews() { if (state.selected.size === 0) return; state.previewing = new Set(state.selected.keys()); const grid = $("#previewGrid"); grid.replaceChildren(); for (const device of state.selected.values()) { const card = document.createElement("article"); card.className = "preview-card"; card.dataset.deviceId = device.id; const frame = document.createElement("div"); frame.className = "preview-frame"; const iframe = document.createElement("iframe"); iframe.title = `${device.name} 实时预览`; iframe.loading = "eager"; iframe.allow = "autoplay; fullscreen"; iframe.sandbox = "allow-scripts allow-same-origin"; iframe.referrerPolicy = "no-referrer"; iframe.src = previewURL(device.id); frame.appendChild(iframe); const meta = document.createElement("div"); meta.className = "preview-meta"; const identity = document.createElement("div"); const name = document.createElement("strong"); const note = document.createElement("small"); name.textContent = device.name; note.textContent = "MediaMTX WebRTC · 按需读取"; identity.append(name, note); const stop = document.createElement("button"); stop.type = "button"; stop.className = "button secondary"; stop.textContent = "停止"; stop.addEventListener("click", () => stopPreview(device.id)); meta.append(identity, stop); card.append(frame, meta); grid.appendChild(card); } switchView("monitor"); renderSelection(); showToast(`已按需启动 ${state.previewing.size} 路预览`); } function stopPreview(deviceID) { state.previewing.delete(deviceID); const card = $(`[data-device-id="${CSS.escape(deviceID)}"]`); if (card) { const iframe = card.querySelector("iframe"); if (iframe) iframe.removeAttribute("src"); card.remove(); } if (state.previewing.size === 0) renderPreviewEmpty(); renderSelection(); } function stopAllPreviews() { for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src"); state.previewing.clear(); renderPreviewEmpty(); renderSelection(); showToast("全部预览已停止"); } function renderPreviewEmpty() { const grid = $("#previewGrid"); grid.replaceChildren(); const empty = document.createElement("div"); empty.className = "preview-empty"; const mark = document.createElement("span"); mark.setAttribute("aria-hidden", "true"); mark.textContent = "▷"; const title = document.createElement("strong"); title.textContent = "尚未启动预览"; const note = document.createElement("p"); note.textContent = "前往设备页选择最多 4 路运行中的视频设备。"; empty.append(mark, title, note); grid.appendChild(empty); } function renderHealth() { const container = $("#overviewHealth"); container.replaceChildren(); if (!state.token || state.items.length === 0) { const empty = document.createElement("div"); empty.className = "empty-compact"; empty.textContent = state.token ? "当前页没有设备事实。" : "建立会话后显示真实状态。"; container.appendChild(empty); return; } const groups = [ ["在线且已收敛", state.items.filter((device) => device.actual_state === "online" && device.converged).length, "设备可用于按需预览"], ["等待收敛", state.items.filter((device) => !device.converged).length, "期望 generation 尚未被完整观察"], ["离线或失败", state.items.filter((device) => ["offline", "failed"].includes(device.actual_state)).length, "查看重试时间和稳定错误码"], ]; for (const [title, count, note] of groups) container.appendChild(createHealthItem(title, count, note)); } function createHealthItem(title, count, note) { const item = document.createElement("div"); item.className = "health-item"; const text = document.createElement("div"); const strong = document.createElement("strong"); const small = document.createElement("small"); strong.textContent = title; small.textContent = note; text.append(strong, small); const value = document.createElement("span"); value.className = "value"; value.textContent = String(count); item.append(text, value); return item; } function renderOperations() { const container = $("#operationIssues"); container.replaceChildren(); const issues = state.items.filter(attentionNeeded); if (issues.length === 0) { const empty = document.createElement("div"); empty.className = "empty-compact"; empty.textContent = state.token ? "当前页没有已知对账差异;这不代表未加载设备或其他基础设施正常。" : "建立会话后显示。"; container.appendChild(empty); return; } for (const device of issues) { const message = device.last_error_code ? `错误 ${device.last_error_code}` : `generation ${device.observed_generation || 0} / ${device.generation || 0}`; container.appendChild(createHealthItem(device.name, device.failure_count || 0, device.next_attempt_at ? `${message} · 下次 ${formatTime(device.next_attempt_at)}` : message)); } } function switchView(name) { $$(".view").forEach((view) => { const active = view.dataset.page === name; view.hidden = !active; view.classList.toggle("active", active); }); $$('[data-view]').forEach((button) => { const active = button.dataset.view === name; button.toggleAttribute("aria-current", active); }); $("#main-content").focus({ preventScroll: true }); window.scrollTo({ top: 0, behavior: "auto" }); } function resetPaging() { state.cursors = [null]; state.pageIndex = 0; state.nextCursor = null; } async function connect(event) { event.preventDefault(); const siteInput = $("#siteInput"); const tokenInput = $("#tokenInput"); const site = siteInput.value.trim(); const token = tokenInput.value; const errorBox = $("#contextError"); errorBox.hidden = true; if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(site)) { errorBox.textContent = "Site ID 格式无效:只能使用 1~64 位字母、数字、点、下划线、冒号或连字符。"; errorBox.hidden = false; siteInput.focus(); return; } if (!token || token.length > 4096) { errorBox.textContent = "请输入有效的 Bearer token。"; errorBox.hidden = false; tokenInput.focus(); return; } state.siteId = site; state.token = token; tokenInput.value = ""; setText("#siteContext", site); resetPaging(); try { await loadDevices({ announce: false }); $("#contextDialog").close(); showToast("会话已建立;token 仅驻留当前页面内存"); } catch (error) { errorBox.textContent = error.status === 401 ? "认证失败,请检查 token 后重试。" : "无法读取该站点,请按页面提示检查权限或服务状态。"; errorBox.hidden = false; $("#contextDialog").showModal(); } } async function initialize() { try { const response = await fetch("/sense-console/config", { cache: "no-store" }); if (!response.ok) throw new Error("config unavailable"); state.config = await response.json(); if (state.config.page_size !== 16 || state.config.max_active_previews !== 4 || state.config.recording_and_playback !== false) { throw new Error("unsafe console bounds"); } } catch (_) { showDegraded("控制台配置不可用", "页面保持只读且不会尝试连接视频,请检查 Sense 启动配置。"); $$("button, input, select").forEach((control) => { control.disabled = true; }); return; } setConnected(false); renderAll(); $("#contextDialog").showModal(); } $$('[data-view]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view))); $$('[data-view-target]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewTarget))); $("#changeContext").addEventListener("click", () => $("#contextDialog").showModal()); $("#closeContext").addEventListener("click", () => $("#contextDialog").close()); $("#cancelContext").addEventListener("click", () => $("#contextDialog").close()); $("#contextForm").addEventListener("submit", connect); $("#refreshDevices").addEventListener("click", () => loadDevices()); $("#refreshOperations").addEventListener("click", () => loadDevices()); $("#retryLoad").addEventListener("click", () => state.token ? loadDevices() : $("#contextDialog").showModal()); $("#startPreview").addEventListener("click", startSelectedPreviews); $("#stopAll").addEventListener("click", stopAllPreviews); $("#deviceSearch").addEventListener("input", renderDevices); for (const selector of ["#modalityFilter", "#desiredFilter", "#actualFilter"]) { $(selector).addEventListener("change", () => { resetPaging(); if (state.token) loadDevices(); }); } $("#clearFilters").addEventListener("click", () => { $("#deviceSearch").value = ""; $("#modalityFilter").value = ""; $("#desiredFilter").value = ""; $("#actualFilter").value = ""; resetPaging(); if (state.token) loadDevices(); else renderDevices(); }); $("#nextPage").addEventListener("click", async () => { if (!state.nextCursor) return; state.cursors[state.pageIndex + 1] = state.nextCursor; state.pageIndex += 1; try { await loadDevices(); } catch (_) { state.pageIndex -= 1; } }); $("#prevPage").addEventListener("click", async () => { if (state.pageIndex === 0) return; state.pageIndex -= 1; try { await loadDevices(); } catch (_) { state.pageIndex += 1; } }); window.addEventListener("beforeunload", () => { state.token = ""; for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src"); }); initialize();