f34817d5b6
FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/ scan_gui_triple desktop tools into one web app.
139 lines
5.6 KiB
JavaScript
139 lines
5.6 KiB
JavaScript
// 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;
|
||
}
|
||
}
|