/** * Operation Room Monitor — Demo 联调台 */ (function () { "use strict"; const $ = (id) => document.getElementById(id); const DEF_CAMS = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"]; const DEF_RP = ["demo1", "demo2", "demo3", "demo4"]; let activeMode = "live-rtsp"; let allLabels = []; let selectedConsumables = new Set(); let lastVideoBatchDoctorDisplay = ""; let videoVisToken = 0; const webcamSlotState = {}; const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, ""); const surgeryId = () => $("surgery-id").value.trim(); function inferDefaultApiBase() { try { const host = location.hostname || "127.0.0.1"; const scheme = location.protocol === "https:" ? "https" : "http"; return `${scheme}://${host}:38080`; } catch { return "http://127.0.0.1:38080"; } } function isLoopbackOrWildcardHost(hostname) { const h = String(hostname || "").trim().toLowerCase(); return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0"; } function applyLanDefaultApiBase() { const input = $("base-url"); const pageHost = location.hostname || ""; try { const current = new URL(input.value); if (!isLoopbackOrWildcardHost(pageHost) && isLoopbackOrWildcardHost(current.hostname)) { input.value = inferDefaultApiBase(); } } catch { input.value = inferDefaultApiBase(); } } function getRunMode() { return activeMode; } function showBanner(msg, type) { const el = $("banner"); if (!el) return; el.textContent = msg; el.className = "banner show " + (type || "info"); } function setLoading(btn, on) { if (!btn) return; btn.disabled = on; btn.classList.toggle("loading", on); } function formatDetail(body) { if (body == null) return "错误"; const detail = body.detail !== undefined ? body.detail : body; if (typeof detail === "object" && detail !== null) { return JSON.stringify(detail, null, 2); } return String(detail || body); } const logEl = $("log-scroll"); function addLog(method, url, status, body, { error = false, hint = "" } = {}) { const item = document.createElement("div"); item.className = "log-item"; const time = new Date().toLocaleTimeString(); const statusClass = error || (typeof status === "number" && status >= 400) ? "err" : "ok"; const statusText = typeof status === "number" ? status : status || (error ? "ERROR" : "—"); item.innerHTML = `
${method} ${url} ${statusText} ${time}
`; const bodyEl = document.createElement("div"); bodyEl.className = "log-body"; if (body == null || body === "") bodyEl.textContent = "(empty)"; else if (typeof body === "string") bodyEl.textContent = body; else { try { bodyEl.textContent = JSON.stringify(body, null, 2); } catch { bodyEl.textContent = String(body); } } item.appendChild(bodyEl); if (hint) { const h = document.createElement("div"); h.className = "log-hint"; h.textContent = hint; item.appendChild(h); } if (logEl) logEl.insertBefore(item, logEl.firstChild); } async function apiJson(method, path, payload) { const url = baseUrl() + path; let res; try { res = await fetch(url, { method, headers: payload ? { "Content-Type": "application/json" } : undefined, body: payload ? JSON.stringify(payload) : undefined, }); } catch (e) { addLog(method, url, "NETWORK", String(e), { error: true }); throw e; } const text = await res.text(); let parsed; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } addLog(method, url, res.status, parsed, { error: !res.ok }); return { res, body: parsed }; } async function apiMultipart(path, formData, logLabel) { const url = baseUrl() + path; const label = logLabel || "multipart"; let res; try { res = await fetch(url, { method: "POST", body: formData }); } catch (e) { const hint = "无法连接 API,请确认 Base URL 指向主服务(默认 :38080)。"; addLog("POST (" + label + ")", url, "NETWORK", String(e), { error: true, hint }); throw e; } const text = await res.text(); let parsed; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } let hint = ""; if (res.status === 404) { hint = "Demo 录制模式未注册,请设 DEMO_ORCHESTRATOR_ENABLED=true 并重启。"; } else if ( res.status === 400 && parsed && String(parsed.detail || "").indexOf("OR_SITE_CONFIG") >= 0 ) { hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE。"; } addLog("POST (" + label + ")", url, res.status, parsed, { error: !res.ok, hint }); return { res, body: parsed }; } async function apiOfflineBatch(formData) { return apiMultipart("/internal/demo/offline-batch", formData, "offline-batch"); } function ensureSurgeryId() { const sid = surgeryId(); if (!/^\d{6}$/.test(sid)) { showBanner("手术号必须是 6 位数字", "err"); return null; } return sid; } function formatConsumablesJson(arr) { return JSON.stringify(arr, null, 2); } function parseConsumablesFromJsonText(raw) { if (!raw.trim()) return []; let parsed; try { parsed = JSON.parse(raw); } catch (e) { throw new Error("JSON 格式错误:" + (e.message || String(e))); } if (!Array.isArray(parsed)) throw new Error("必须是 JSON 数组"); const out = []; for (let i = 0; i < parsed.length; i++) { const x = parsed[i]; if (typeof x === "string") { const s = x.trim(); if (s) out.push(s); continue; } if (x !== null && typeof x === "object" && !Array.isArray(x)) { const name = x["名称"] != null ? x["名称"] : x["name"]; if (typeof name === "string") { const s = name.trim(); if (s) out.push(s); } } } return out; } function getSelectedConsumables() { const ta = $("candidate-consumables-json"); const advanced = ta?.closest("details.advanced"); if (ta && (advanced?.open || document.activeElement === ta)) { return parseConsumablesFromJsonText(ta.value); } return [...selectedConsumables]; } function syncJsonFromChips() { const ta = $("candidate-consumables-json"); if (ta) ta.value = formatConsumablesJson(getSelectedConsumables()); } function syncChipsFromJson() { try { const arr = parseConsumablesFromJsonText($("candidate-consumables-json").value); selectedConsumables = new Set(arr); renderConsumableChips(); } catch { /* ignore while typing invalid json */ } } function renderConsumableChips() { const container = $("chips"); const q = ($("chip-search")?.value || "").trim().toLowerCase(); if (!container) return; container.innerHTML = ""; const filtered = allLabels.filter((l) => !q || l.toLowerCase().includes(q)); filtered.forEach((label) => { const chip = document.createElement("button"); chip.type = "button"; chip.className = "chip" + (selectedConsumables.has(label) ? " selected" : ""); chip.textContent = label; chip.onclick = () => { if (selectedConsumables.has(label)) selectedConsumables.delete(label); else selectedConsumables.add(label); renderConsumableChips(); syncJsonFromChips(); }; container.appendChild(chip); }); const meta = $("labels-meta"); if (meta) { meta.textContent = `已选 ${selectedConsumables.size} / 共 ${allLabels.length} 个标签`; } } async function loadLabels() { try { const res = await fetch("/labels.json"); if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); allLabels = Array.isArray(data.labels) ? data.labels : []; selectedConsumables = new Set(allLabels.slice(0, 5)); renderConsumableChips(); syncJsonFromChips(); } catch (e) { allLabels = []; selectedConsumables = new Set(); renderConsumableChips(); $("labels-meta").textContent = "标签加载失败"; console.warn(e); } } function setActiveMode(mode) { activeMode = mode; document.querySelectorAll(".mode-card").forEach((card) => { card.classList.toggle("active", card.dataset.mode === mode); }); document.querySelectorAll(".mode-panel").forEach((panel) => { panel.classList.toggle("active", panel.dataset.mode === mode); }); const voice = $("voice-callout"); if (voice) voice.classList.toggle("hidden", mode === "offline-batch"); const btnStart = $("btn-start"); const btnEnd = $("btn-end"); if (btnStart) { btnStart.textContent = mode === "offline-batch" ? "上传并处理" : mode === "live-simulated" ? "开始模拟开录" : "开始手术"; } if (btnEnd) btnEnd.disabled = mode === "offline-batch"; $("pill-mode").textContent = mode === "live-rtsp" ? "链路 1 · 真摄像头" : mode === "live-simulated" ? "链路 2 · 模拟实时" : "链路 3 · 离线精确"; if (mode !== "offline-batch") hideVideoBatchVisualization(); refreshRecordingModesStatus(); } async function refreshHealth() { const pill = $("pill-health"); try { const { res } = await apiJson("GET", "/health"); if (pill) { pill.textContent = res.ok ? "API 正常" : "API 异常 " + res.status; pill.className = "pill " + (res.ok ? "ok" : "err"); } } catch { if (pill) { pill.textContent = "API 不可达"; pill.className = "pill err"; } } } async function refreshRecordingModesStatus() { const pill = $("pill-demo-modes"); if (activeMode === "live-rtsp") { if (pill) { pill.textContent = "链路 2/3 未检测"; pill.className = "pill"; } return; } const url = baseUrl() + "/internal/demo/recording-modes-status"; try { const res = await fetch(url); const data = await res.json(); addLog("GET", url, res.status, data, { error: !res.ok }); if (!pill) return; const on = data.demo_recording_modes_enabled === true || data.orchestrator_enabled === true; const fset = data.or_site_config_json_file_set === true; if (!res.ok) { pill.textContent = "状态拉取失败"; pill.className = "pill err"; return; } if (on && fset) { pill.textContent = "链路 2/3 已就绪"; pill.className = "pill ok"; } else if (on) { pill.textContent = "缺 OR_SITE_CONFIG"; pill.className = "pill warn"; } else { pill.textContent = "DEMO 未开启"; pill.className = "pill err"; } } catch (e) { if (pill) { pill.textContent = "状态失败"; pill.className = "pill err"; } } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function cancelVideoBatchVisualization() { videoVisToken += 1; const wrap = $("video-batch-vis"); const player = $("video-batch-vis-player"); const hint = $("video-batch-vis-hint"); if (wrap) wrap.classList.add("hidden"); if (player) { player.onerror = null; player.onloadeddata = null; player.removeAttribute("src"); player.load(); } if (hint) hint.textContent = ""; } function hideVideoBatchVisualization() { cancelVideoBatchVisualization(); } function revealVideoBatchVisualization() { const wrap = $("video-batch-vis"); if (wrap) wrap.classList.remove("hidden"); } function loadVideoWhenReady(player, src, token, timeoutMs = 15000) { return new Promise((resolve) => { if (token !== videoVisToken) { resolve(false); return; } let settled = false; const finish = (ok) => { if (settled) return; settled = true; clearTimeout(timer); player.onerror = null; player.onloadeddata = null; if (!ok) { player.removeAttribute("src"); player.load(); } resolve(ok); }; const timer = setTimeout(() => finish(false), timeoutMs); player.onerror = () => finish(false); player.onloadeddata = () => finish(true); player.src = src; player.load(); }); } async function waitForVideoBatchVisualization(sid, urlPath, doctorDisplay) { const wrap = $("video-batch-vis"); const player = $("video-batch-vis-player"); const hint = $("video-batch-vis-hint"); if (!wrap || !player) return; const token = ++videoVisToken; const path = urlPath || `/internal/demo/offline-batch/${sid}/visualization`; const base = baseUrl() + path; const pollIntervalMs = 3000; const maxAttempts = 120; wrap.classList.add("hidden"); showVideoBatchDoctorInfo(doctorDisplay || lastVideoBatchDoctorDisplay); if (hint) hint.textContent = ""; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (token !== videoVisToken) return; const src = base + "?t=" + Date.now(); const loaded = await loadVideoWhenReady(player, src, token); if (token !== videoVisToken) return; if (loaded) { revealVideoBatchVisualization(); if (hint) hint.textContent = "标注视频已加载"; player.onerror = () => { if (hint) { hint.textContent = "视频加载失败,请稍后重试或新标签页打开链接。"; } }; showBanner("标注视频已就绪", "ok"); return; } if (attempt === 0) { showBanner("标注视频生成中,就绪后自动展示…", "info"); } await sleep(pollIntervalMs); } if (token === videoVisToken) { showBanner("标注视频尚未就绪,请稍后点击「查询结果」重试", "warn"); } } function pickDoctorBannerText(details, fallbackDisplay) { const rowDoctor = (details[0]?.doctor_id || "").trim(); const apiDisplay = (fallbackDisplay || "").trim(); if (apiDisplay && (!rowDoctor || rowDoctor === "vision")) return apiDisplay; return rowDoctor || apiDisplay; } function showVideoBatchDoctorInfo(displayText) { const el = $("video-batch-doctor-info"); if (!el) return; const text = (displayText || "").trim(); if (!text) { el.textContent = ""; el.style.display = "none"; return; } el.textContent = "识别医生:" + text; el.style.display = "block"; } function showVideoBatchVisualization(sid, urlPath, doctorDisplay) { void waitForVideoBatchVisualization(sid, urlPath, doctorDisplay); } function getDebugStreamCount() { const sel = $("debug-stream-count"); const v = sel ? parseInt(sel.value, 10) : 1; return v >= 1 && v <= 4 ? v : 1; } function applyDebugStreamVisibility() { const n = getDebugStreamCount(); for (let i = 1; i <= 4; i++) { const el = $("sim-stream-" + i); if (el) el.classList.toggle("hidden", i > n); } } function assignFileToInput(inputEl, file) { const dt = new DataTransfer(); dt.items.add(file); inputEl.files = dt.files; } function setSlotFile(slot, file, source) { const vfile = $("sim-vfile-" + slot); const hint = $("sim-hint-" + slot); const fname = $("sim-fname-" + slot); if (vfile && file) assignFileToInput(vfile, file); if (fname) fname.textContent = file ? file.name : ""; if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : ""; } function setOfflineFile(file, source) { const vfile = $("offline-vfile"); const fname = $("offline-fname"); const hint = $("offline-hint"); if (vfile && file) assignFileToInput(vfile, file); if (fname) fname.textContent = file ? file.name : "点击或拖放 MP4 到此处"; if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : ""; } function bindDropZone(zoneEl, onFile) { if (!zoneEl) return; zoneEl.addEventListener("dragover", (ev) => { ev.preventDefault(); zoneEl.classList.add("drag-over"); }); zoneEl.addEventListener("dragleave", () => zoneEl.classList.remove("drag-over")); zoneEl.addEventListener("drop", (ev) => { ev.preventDefault(); zoneEl.classList.remove("drag-over"); const f = ev.dataTransfer?.files?.[0]; if (f && looksLikeVideo(f)) onFile(f, "拖放"); else showBanner("请拖入视频文件(MP4 等)", "warn"); }); } function looksLikeVideo(f) { return ( /^video\//.test(f.type || "") || /\.(mp4|mov|mkv|avi|webm|m4v)$/i.test(f.name || "") ); } function pickMediaRecorderMime() { if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return ""; for (const m of [ "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm", "video/mp4", ]) { if (MediaRecorder.isTypeSupported(m)) return m; } return ""; } async function toggleWebcamSlot(slot) { const st = webcamSlotState[slot] || (webcamSlotState[slot] = {}); const btn = $("sim-webcam-" + slot); const hint = $("sim-hint-" + slot); if (!st.recording) { if (!navigator.mediaDevices?.getUserMedia) { showBanner("需要 HTTPS 或 localhost 才能使用摄像头", "warn"); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: true, }); const mime = pickMediaRecorderMime(); st.chunks = []; const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream); rec.ondataavailable = (ev) => { if (ev.data?.size) st.chunks.push(ev.data); }; rec.start(250); st.recording = true; st.stream = stream; st.recorder = rec; if (btn) { btn.textContent = "停止录制"; btn.classList.add("warn"); } if (hint) hint.textContent = "录制中…"; } catch (e) { showBanner("无法打开摄像头:" + (e.message || String(e)), "err"); } return; } const rec = st.recorder; const stream = st.stream; st.recording = false; st.recorder = null; st.stream = null; if (btn) { btn.textContent = "摄像头"; btn.classList.remove("warn"); } await new Promise((resolve) => { rec.addEventListener("stop", resolve, { once: true }); rec.stop(); }); stream?.getTracks().forEach((t) => t.stop()); const blob = new Blob(st.chunks, { type: rec.mimeType || "video/webm" }); st.chunks = []; if (!blob.size) { if (hint) hint.textContent = "未录到数据"; return; } const ext = (rec.mimeType || "").includes("mp4") ? ".mp4" : ".webm"; const file = new File([blob], "webcam-" + slot + "-" + Date.now() + ext, { type: blob.type }); setSlotFile(slot, file, "摄像头"); } async function handleStart() { const sid = ensureSurgeryId(); if (!sid) return; let candidateConsumables; try { candidateConsumables = getSelectedConsumables(); } catch (e) { showBanner(e.message || String(e), "err"); return; } const btn = $("btn-start"); setLoading(btn, true); showBanner("请求进行中…", "info"); try { const mode = getRunMode(); if (mode === "offline-batch") { const f = $("offline-vfile")?.files?.[0]; if (!f) { showBanner("请先选择完整 MP4 文件", "err"); return; } const fd = new FormData(); fd.append("surgery_id", sid); fd.append("video1", f, f.name); fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables)); fd.append( "include_visualization", $("offline-batch-include-vis")?.checked ? "true" : "false", ); const { res, body } = await apiOfflineBatch(fd); if (!res.ok) { showBanner("离线处理失败:" + formatDetail(body), "err"); return; } lastVideoBatchDoctorDisplay = body?.doctor_display || ""; if ($("offline-batch-include-vis")?.checked && body?.visualization_url) { showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay); } else { hideVideoBatchVisualization(); showVideoBatchDoctorInfo(lastVideoBatchDoctorDisplay); } showBanner("离线处理完成,正在查询结果…", "ok"); await handleResult({ skipVisualization: true }); return; } if (mode === "live-simulated") { const n = getDebugStreamCount(); const files = []; for (let i = 1; i <= n; i++) { files.push($("sim-vfile-" + i)?.files?.[0]); } if (files.length !== n || !files.every(Boolean)) { showBanner("请为路 1…" + n + " 全部选择视频文件", "err"); return; } const fd = new FormData(); fd.append("surgery_id", sid); fd.append("video1", files[0], files[0].name); if (n >= 2) fd.append("video2", files[1], files[1].name); if (n >= 3) fd.append("video3", files[2], files[2].name); if (n >= 4) fd.append("video4", files[3], files[3].name); for (let i = 1; i <= 4; i++) { fd.append( "camera_" + i, ($("adv-cam-" + i)?.value || DEF_CAMS[i - 1]).trim() || DEF_CAMS[i - 1], ); fd.append( "rtsp_path_" + i, ($("adv-rpath-" + i)?.value || DEF_RP[i - 1]).trim() || DEF_RP[i - 1], ); } fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables)); const { res, body } = await apiMultipart( "/internal/demo/simulated-start", fd, "simulated-start", ); if (!res.ok) { showBanner("模拟开录失败:" + formatDetail(body), "err"); return; } showBanner("模拟开录已接受,请打开语音终端", "ok"); return; } const camera_ids = $("camera-ids") .value.split(",") .map((s) => s.trim()) .filter(Boolean); if (!camera_ids.length) { showBanner("请填写至少一个 camera_id", "err"); return; } const { res, body } = await apiJson("POST", "/client/surgeries/start", { surgery_id: sid, camera_ids, candidate_consumables: candidateConsumables, }); if (!res.ok) { showBanner("开录失败:" + formatDetail(body), "err"); return; } showBanner("开录已接受,请打开语音终端", "ok"); } catch (e) { showBanner("网络错误:" + String(e), "err"); } finally { setLoading(btn, false); } } async function handleEnd() { const sid = ensureSurgeryId(); if (!sid) return; const btn = $("btn-end"); setLoading(btn, true); try { const { res, body } = await apiJson("POST", "/client/surgeries/end", { surgery_id: sid }); showBanner(res.ok ? "手术已结束" : "结束失败:" + formatDetail(body), res.ok ? "ok" : "err"); } finally { setLoading(btn, false); } } async function handleResult(options = {}) { const sid = ensureSurgeryId(); if (!sid) return; if ( !options.skipVisualization && getRunMode() === "offline-batch" && $("offline-batch-include-vis")?.checked ) { showVideoBatchVisualization(sid, null); } else if (getRunMode() !== "offline-batch") { hideVideoBatchVisualization(); } const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`); const target = $("result-render"); if (!target) return; target.innerHTML = ""; if (!res.ok || !body || typeof body !== "object") { showBanner(res.ok ? "无结果数据" : "查询失败:" + formatDetail(body), "err"); return; } const { details = [], summary = [] } = body; if (getRunMode() === "offline-batch") { showVideoBatchDoctorInfo(pickDoctorBannerText(details, lastVideoBatchDoctorDisplay)); } const renderTable = (title, rows, cols) => { const h = document.createElement("h3"); h.textContent = title; target.appendChild(h); const t = document.createElement("table"); t.innerHTML = "" + cols.map((c) => `${c.label}`).join("") + ""; const tbody = document.createElement("tbody"); if (!rows.length) { tbody.innerHTML = `(空)`; } else { rows.forEach((row) => { const tr = document.createElement("tr"); tr.innerHTML = cols.map((c) => `${row[c.key] ?? ""}`).join(""); tbody.appendChild(tr); }); } t.appendChild(tbody); target.appendChild(t); }; renderTable("消耗明细", details, [ { key: "timestamp", label: "时间" }, { key: "item_id", label: "耗材 ID" }, { key: "item_name", label: "耗材" }, { key: "qty", label: "数量" }, { key: "doctor_id", label: "医生(姓名+ID)" }, ]); renderTable("汇总", summary, [ { key: "item_id", label: "耗材 ID" }, { key: "item_name", label: "耗材" }, { key: "total_quantity", label: "合计" }, ]); showBanner("结果已加载", "ok"); } function initModeCards() { document.querySelectorAll(".mode-card").forEach((card) => { card.addEventListener("click", () => setActiveMode(card.dataset.mode)); }); setActiveMode("live-rtsp"); } function initSimulatedUploads() { $("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility); applyDebugStreamVisibility(); for (let i = 1; i <= 4; i++) { const pick = $("sim-pick-" + i); const vfile = $("sim-vfile-" + i); const zone = $("sim-zone-" + i); if (pick && vfile) { pick.onclick = () => vfile.click(); vfile.onchange = () => { const f = vfile.files?.[0]; if (f) setSlotFile(i, f, "选择"); }; } bindDropZone(zone, (f) => setSlotFile(i, f, "拖放")); zone?.addEventListener("click", (ev) => { if (ev.target.closest("button")) return; vfile?.click(); }); $("sim-webcam-" + i)?.addEventListener("click", () => toggleWebcamSlot(i)); } } function initOfflineUpload() { const zone = $("offline-zone"); const vfile = $("offline-vfile"); $("offline-pick")?.addEventListener("click", () => vfile?.click()); vfile?.addEventListener("change", () => { const f = vfile.files?.[0]; if (f) setOfflineFile(f, "选择"); }); bindDropZone(zone, (f) => setOfflineFile(f, "拖放")); zone?.addEventListener("click", (ev) => { if (ev.target.closest("button")) return; vfile?.click(); }); } function initConsumables() { $("chip-search")?.addEventListener("input", renderConsumableChips); $("btn-chips-all")?.addEventListener("click", () => { allLabels.forEach((l) => selectedConsumables.add(l)); renderConsumableChips(); syncJsonFromChips(); }); $("btn-chips-clear")?.addEventListener("click", () => { selectedConsumables.clear(); renderConsumableChips(); syncJsonFromChips(); }); $("candidate-consumables-json")?.addEventListener("change", syncChipsFromJson); $("candidate-consumables-json")?.addEventListener("blur", syncChipsFromJson); } function init() { applyLanDefaultApiBase(); initModeCards(); initSimulatedUploads(); initOfflineUpload(); initConsumables(); loadLabels(); $("btn-start")?.addEventListener("click", handleStart); $("btn-end")?.addEventListener("click", handleEnd); $("btn-result")?.addEventListener("click", handleResult); $("btn-refresh-status")?.addEventListener("click", () => { refreshHealth(); refreshRecordingModesStatus(); }); $("btn-clear-log")?.addEventListener("click", () => { if (logEl) logEl.innerHTML = ""; }); $("base-url")?.addEventListener("change", refreshRecordingModesStatus); refreshHealth(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();