// Renders the top-bar health badges + startup/recording state lines from /ws/status. import { formatBytes, formatElapsed } from "/js/format.js"; function badgeClass(health) { if (health === "OK") return "badge badge-ok"; if (health === "STALE") return "badge badge-stale"; return "badge badge-down"; } // NBSP padding, not CSS min-width: regular spaces collapse in rendered // text, so a guessed ch-width can still drift. Padding to a fixed character // count (monospace font) makes each badge's pixel width identical on every // tick regardless of health word or Hz digit count — nothing to reflow the // row on mobile. function padHealth(health) { return health.padEnd(5, " "); } function formatHz(hz) { return hz.toFixed(1).padStart(5, " "); } function makeBadge(label, health, hz) { const b = document.createElement("span"); // badge-sensor: fixed CSS width (see style.css) — the NBSP padding above // keeps internal jitter down, but the box's own size being fixed is what // actually guarantees the position never moves, regardless of icon glyph // width or font-metric rounding. b.className = `${badgeClass(health)} badge-sensor`; b.textContent = `${label} ${padHealth(health)}`; const hzSpan = document.createElement("span"); hzSpan.className = "badge-hz"; hzSpan.textContent = health !== "DOWN" && hz > 0 ? `${formatHz(hz)}Hz` : `${" ".repeat(5)}Hz`; b.appendChild(hzSpan); return b; } export function startStatusSocket(onStatus) { const proto = location.protocol === "https:" ? "wss:" : "ws:"; let ws; function connect() { ws = new WebSocket(`${proto}//${location.host}/ws/status`); ws.onmessage = (ev) => { const status = JSON.parse(ev.data); render(status); if (onStatus) onStatus(status); }; ws.onclose = () => setTimeout(connect, 1500); ws.onerror = () => ws.close(); } connect(); } function render(status) { const badges = document.getElementById("status-badges"); badges.innerHTML = ""; badges.appendChild(makeBadge("LiDAR", status.lidar.health, status.lidar.hz)); badges.appendChild(makeBadge("IMU", status.lidar.imu_health, status.lidar.imu_hz)); badges.appendChild(makeBadge("MAP", status.map.health, status.map.hz)); for (const cam of status.cameras) { badges.appendChild(makeBadge(cam.id, cam.health, cam.hz)); } if (status.recording.active) { const b = document.createElement("span"); b.className = "badge badge-rec"; b.textContent = "REC"; badges.appendChild(b); } const lidarState = document.getElementById("lidar-state"); if (lidarState) { lidarState.textContent = status.lidar.running ? "● 실행 중" : "● 정지"; lidarState.className = "state-line " + (status.lidar.running ? "on" : "off"); } const recPath = document.getElementById("rec-path"); if (recPath) { recPath.textContent = status.recording.path ? `저장: ${status.recording.path}` : ""; } const recMeter = document.getElementById("rec-meter"); if (recMeter) { if (status.recording.active) { recMeter.textContent = `${formatElapsed(status.recording.elapsed_s)} · ${formatBytes(status.recording.size_bytes)}`; recMeter.classList.remove("hidden"); } else { recMeter.classList.add("hidden"); } } // Always-on compact readout in the HUD — the backend already resolves the // right path (DEFAULT_SAVE_DIR when idle, the live recording target while // active), so this can just mirror status.disk unconditionally. const diskEl = document.getElementById("disk-free"); if (diskEl && status.disk) { const freeGb = status.disk.free_gb; diskEl.textContent = `여유 ${freeGb.toFixed(1)}GB`; diskEl.classList.remove("warning", "danger"); if (freeGb < status.disk.low_danger_gb) diskEl.classList.add("danger"); else if (freeGb < status.disk.low_warning_gb) diskEl.classList.add("warning"); } const mapPointsEl = document.getElementById("map-points"); if (mapPointsEl && status.map) { mapPointsEl.textContent = status.map.points > 0 ? `${status.map.points.toLocaleString()}pt` : ""; mapPointsEl.classList.toggle("hidden", status.map.points === 0); } document.getElementById("btn-lidar-start").disabled = status.lidar.running; document.getElementById("btn-lidar-stop").disabled = !status.lidar.running; // The record button swaps for a stop button once recording is live, // rather than just disabling both — HUD real estate is tight on a phone. const recOpenBtn = document.getElementById("btn-rec-open"); const recStopBtn = document.getElementById("btn-rec-stop"); recOpenBtn.classList.toggle("hidden", status.recording.active); recStopBtn.classList.toggle("hidden", !status.recording.active); recOpenBtn.disabled = !status.lidar.running; const fastlioTestState = document.getElementById("fastlio-test-state"); const fastlioTestStart = document.getElementById("btn-fastlio-test-start"); const fastlioTestStop = document.getElementById("btn-fastlio-test-stop"); if (fastlioTestState && status.map) { const running = !!(status.process_status && status.process_status.fastlio && status.process_status.fastlio.running); fastlioTestState.textContent = running ? `● 실행 중 (${status.map.health} · ${status.map.hz.toFixed(1)}Hz · ${status.map.points.toLocaleString()}pt)` : "● 정지"; fastlioTestState.className = "state-line " + (running ? "on" : "off"); fastlioTestStart.disabled = !status.lidar.running || running; // Stopping while a real recording owns fastlio is rejected server-side // (409) — disable client-side too so the test button can't fire that. fastlioTestStop.disabled = !running || status.recording.active; } }