Initial commit: scan_web integrated recording UI

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.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { startStatusSocket } from "/js/status-panel.js";
import { buildCameraGrid, setPreviewActive } from "/js/camera-preview.js";
import { buildCameraTabs } from "/js/camera-settings.js";
import { initRecordingPanel } from "/js/recording-panel.js";
import { initLogPanel } from "/js/log-panel.js";
import { initPointcloudView } from "/js/pointcloud-view.js";
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
const OVERLAY_IDS = ["overlay-settings", "overlay-recording", "overlay-log"];
let pointcloudView = null;
function anyOverlayOpen() {
return OVERLAY_IDS.some((id) => !document.getElementById(id).classList.contains("hidden"));
}
// The point-cloud render loop keeps running (cheap-ish) but there's no
// reason to keep painting frames the operator can't see behind a full-screen
// overlay — pause on open, resume once every overlay is closed.
function syncPointcloudPaused() {
if (pointcloudView) pointcloudView.setPaused(anyOverlayOpen());
}
function openOverlay(id) {
document.getElementById(id).classList.remove("hidden");
syncPointcloudPaused();
}
function closeOverlay(id) {
document.getElementById(id).classList.add("hidden");
syncPointcloudPaused();
}
// Camera MJPEG only needs to stream while its tab is actually visible inside
// the open Settings overlay — otherwise it's wasted phone battery/bandwidth
// behind a closed overlay (same reasoning M3's point-cloud stream will need
// for the scan view itself).
function syncPreviewActive() {
const overlaySettings = document.getElementById("overlay-settings");
const previewPane = document.getElementById("pane-camera-preview");
const active = !overlaySettings.classList.contains("hidden") && previewPane.classList.contains("active");
setPreviewActive(active);
}
function setupOverlays() {
document.querySelectorAll("[data-close]").forEach((el) => {
el.onclick = () => {
closeOverlay(el.dataset.close);
syncPreviewActive();
};
});
document.getElementById("btn-settings").onclick = () => {
openOverlay("overlay-settings");
syncPreviewActive();
};
document.getElementById("btn-rec-open").onclick = () => openOverlay("overlay-recording");
document.getElementById("btn-open-log").onclick = () => openOverlay("overlay-log");
}
function setupPaneTabs() {
const btns = document.querySelectorAll(".pane-tab-btn[data-pane]");
btns.forEach((btn) => {
btn.onclick = () => {
btns.forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".pane-tab").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById(btn.dataset.pane).classList.add("active");
syncPreviewActive();
};
});
}
async function main() {
setupOverlays();
setupPaneTabs();
pointcloudView = initPointcloudView();
const status = await getJSON("/api/status");
await buildCameraGrid(status.cameras);
await buildCameraTabs(status.cameras);
initRecordingPanel(status.default_save_dir);
initLogPanel();
document.getElementById("btn-lidar-start").onclick = async () => {
await getJSON("/api/lidar/start", { method: "POST" });
};
document.getElementById("btn-lidar-stop").onclick = async () => {
await getJSON("/api/lidar/stop", { method: "POST" });
};
document.getElementById("btn-estop").onclick = async () => {
if (!confirm("모든 프로세스(LiDAR/카메라/녹화)를 즉시 정지합니다. 계속할까요?")) return;
await getJSON("/api/system/estop", { method: "POST" });
};
document.getElementById("btn-fastlio-test-start").onclick = async () => {
try {
await getJSON("/api/fastlio/start", { method: "POST" });
} catch (e) {
alert(String(e));
}
};
document.getElementById("btn-fastlio-test-stop").onclick = async () => {
try {
await getJSON("/api/fastlio/stop", { method: "POST" });
} catch (e) {
alert(String(e));
}
};
const btnViewOrbit = document.getElementById("btn-view-orbit");
const btnViewLidar = document.getElementById("btn-view-lidar");
btnViewOrbit.onclick = () => {
pointcloudView.setViewMode("orbit");
btnViewOrbit.className = "btn btn-blue";
btnViewLidar.className = "btn btn-gray";
};
btnViewLidar.onclick = () => {
pointcloudView.setViewMode("lidar");
btnViewLidar.className = "btn btn-blue";
btnViewOrbit.className = "btn btn-gray";
};
startStatusSocket();
}
main().catch((e) => {
console.error(e);
alert("초기화 실패: " + e);
});
+33
View File
@@ -0,0 +1,33 @@
// Builds the MJPEG camera preview grid. One <img> per camera, no client JS
// decoding needed — the browser natively renders multipart MJPEG streams.
export function buildCameraGrid(cameras) {
const grid = document.getElementById("camera-grid");
grid.innerHTML = "";
for (const cam of cameras) {
const pane = document.createElement("div");
pane.className = "camera-pane";
const title = document.createElement("div");
title.className = "cam-title";
title.textContent = `${cam.id} (${cam.topic})`;
pane.appendChild(title);
const img = document.createElement("img");
img.dataset.camId = cam.id;
img.alt = cam.id;
pane.appendChild(img);
grid.appendChild(pane);
}
}
export function setPreviewActive(active) {
document.querySelectorAll("#camera-grid img").forEach((img) => {
if (active) {
if (!img.src) img.src = `/api/camera/${img.dataset.camId}/stream.mjpg?t=${Date.now()}`;
} else {
img.removeAttribute("src");
}
});
}
+148
View File
@@ -0,0 +1,148 @@
// Per-camera exposure/gain settings tabs — mirrors the QTabWidget layout in
// scan_gui_triple.py (auto-exposure toggle, brightness/exp_min/exp_max vs.
// exp_time mutually exclusive groups, gain; Apply = live ros2 param set,
// Save = persist into the base YAML).
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
function field(labelText, id, type, step) {
const wrap = document.createDocumentFragment();
const label = document.createElement("label");
label.textContent = labelText;
label.htmlFor = id;
const input = document.createElement("input");
input.type = type;
input.id = id;
if (step) input.step = step;
wrap.appendChild(label);
wrap.appendChild(input);
return { wrap, input };
}
export async function buildCameraTabs(cameras) {
const tabBar = document.getElementById("camera-tabs");
const panelsRoot = document.getElementById("camera-tab-panels");
tabBar.innerHTML = "";
panelsRoot.innerHTML = "";
for (let i = 0; i < cameras.length; i++) {
const cam = cameras[i];
const tabBtn = document.createElement("button");
tabBtn.className = "tab-btn" + (i === 0 ? " active" : "");
tabBtn.textContent = cam.id;
tabBtn.onclick = () => {
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
tabBtn.classList.add("active");
document.getElementById(`cam-panel-${cam.id}`).classList.add("active");
};
tabBar.appendChild(tabBtn);
const panel = document.createElement("div");
panel.className = "tab-panel" + (i === 0 ? " active" : "");
panel.id = `cam-panel-${cam.id}`;
panelsRoot.appendChild(panel);
await buildCameraPanel(panel, cam.id);
}
}
async function buildCameraPanel(panel, camId) {
const values = await getJSON(`/api/camera/${camId}/params`);
const autoLabel = document.createElement("label");
const autoChk = document.createElement("input");
autoChk.type = "checkbox";
autoChk.checked = values.exposure_auto;
autoLabel.appendChild(autoChk);
autoLabel.appendChild(document.createTextNode(" 자동 노출"));
panel.appendChild(autoLabel);
const grid = document.createElement("div");
grid.className = "cam-field-grid";
panel.appendChild(grid);
const fBrightness = field("목표 밝기:", `${camId}-brightness`, "number");
fBrightness.input.min = 0; fBrightness.input.max = 255; fBrightness.input.value = values.exposure_auto_target_brightness;
const fExpMin = field("노출 하한 (us):", `${camId}-exp-min`, "number");
fExpMin.input.min = 10; fExpMin.input.max = 1000000; fExpMin.input.step = 100; fExpMin.input.value = values.exposure_auto_min;
const fExpMax = field("노출 상한 (us):", `${camId}-exp-max`, "number");
fExpMax.input.min = 10; fExpMax.input.max = 1000000; fExpMax.input.step = 1000; fExpMax.input.value = values.exposure_auto_max;
const fExpTime = field("노출 시간 (us):", `${camId}-exp-time`, "number");
fExpTime.input.min = 10; fExpTime.input.max = 1000000; fExpTime.input.value = values.exposure_time;
const fGain = field("게인 (dB):", `${camId}-gain`, "number");
fGain.input.min = 0; fGain.input.max = 16.9; fGain.input.step = 0.5; fGain.input.value = values.gain;
for (const f of [fBrightness, fExpMin, fExpMax, fExpTime, fGain]) grid.appendChild(f.wrap);
const autoWidgets = [fBrightness.input, fExpMin.input, fExpMax.input];
const manualWidgets = [fExpTime.input];
function applyToggleState(checked) {
autoWidgets.forEach((w) => (w.disabled = !checked));
manualWidgets.forEach((w) => (w.disabled = checked));
}
autoChk.onchange = () => applyToggleState(autoChk.checked);
applyToggleState(values.exposure_auto);
const btnRow = document.createElement("div");
btnRow.className = "btn-row";
const btnApply = document.createElement("button");
btnApply.className = "btn btn-blue";
btnApply.textContent = "적용";
const btnSave = document.createElement("button");
btnSave.className = "btn btn-green";
btnSave.textContent = "저장";
btnRow.appendChild(btnApply);
btnRow.appendChild(btnSave);
panel.appendChild(btnRow);
const result = document.createElement("div");
result.className = "cam-result";
panel.appendChild(result);
function collect() {
return {
exposure_auto: autoChk.checked,
exposure_auto_target_brightness: parseInt(fBrightness.input.value, 10),
exposure_auto_min: parseFloat(fExpMin.input.value),
exposure_auto_max: parseFloat(fExpMax.input.value),
exposure_time: parseInt(fExpTime.input.value, 10),
gain: parseFloat(fGain.input.value),
};
}
btnApply.onclick = async () => {
result.textContent = "적용 중…";
try {
await getJSON(`/api/camera/${camId}/params`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
});
const r = await getJSON(`/api/camera/${camId}/params/apply`, { method: "POST" });
result.textContent = r.ok ? "적용 완료" : r.results.filter(x => !x.ok).map(x => `${x.name}: ${x.message}`).join(" / ");
result.style.color = r.ok ? "var(--color-success)" : "var(--color-danger)";
} catch (e) {
result.textContent = String(e);
result.style.color = "var(--color-danger)";
}
};
btnSave.onclick = async () => {
result.textContent = "저장 중…";
try {
await getJSON(`/api/camera/${camId}/params`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
});
const r = await getJSON(`/api/camera/${camId}/params/save`, { method: "POST" });
result.textContent = `저장됨 → ${r.path}`;
result.style.color = "var(--color-success)";
} catch (e) {
result.textContent = String(e);
result.style.color = "var(--color-danger)";
}
};
}
+21
View File
@@ -0,0 +1,21 @@
export function formatBytes(bytes) {
if (bytes == null) return "";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function formatElapsed(seconds) {
if (seconds == null) return "";
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m % 60)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;
}
export function formatDuration(seconds) {
if (seconds == null) return "—";
if (seconds < 60) return `${seconds.toFixed(1)}s`;
return formatElapsed(seconds);
}
+45
View File
@@ -0,0 +1,45 @@
// Unified [tag] log scrollback — consumes /ws/logs (backlog once, then new
// lines as they're appended to lidar.log/camera.log/recording.log).
const MAX_RENDERED_LINES = 2000;
export function initLogPanel() {
const body = document.getElementById("log-body");
const autoscrollChk = document.getElementById("log-autoscroll");
const clearBtn = document.getElementById("btn-log-clear");
function appendLine({ tag, line }) {
const row = document.createElement("div");
row.className = `log-line tag-${tag}`;
const tagSpan = document.createElement("span");
tagSpan.className = "log-tag";
tagSpan.textContent = `[${tag}]`;
const textSpan = document.createElement("span");
textSpan.className = "log-text";
textSpan.textContent = line;
row.appendChild(tagSpan);
row.appendChild(textSpan);
body.appendChild(row);
while (body.childElementCount > MAX_RENDERED_LINES) {
body.removeChild(body.firstChild);
}
if (autoscrollChk.checked) {
body.scrollTop = body.scrollHeight;
}
}
clearBtn.onclick = () => { body.innerHTML = ""; };
const proto = location.protocol === "https:" ? "wss:" : "ws:";
function connect() {
const ws = new WebSocket(`${proto}//${location.host}/ws/logs`);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.backlog) msg.backlog.forEach(appendLine);
if (msg.lines) msg.lines.forEach(appendLine);
};
ws.onclose = () => setTimeout(connect, 1500);
ws.onerror = () => ws.close();
}
connect();
}
+169
View File
@@ -0,0 +1,169 @@
// M3 live scan view — consumes /ws/pointcloud (see backend/pointcloud_codec.py
// for the exact binary layout) and renders into #pointcloud-canvas with
// three.js. One reused BufferGeometry with pre-allocated typed arrays,
// overwritten in place per frame (needsUpdate=true) to avoid GC churn at
// the stream's 3-5Hz.
import * as THREE from "three";
import { OrbitControls } from "/vendor/three/OrbitControls.js";
const MAGIC = "PCF1";
// Must match backend config.py POINTCLOUD_PROFILES max_points — sizes the
// pre-allocated buffers, so it can't just read the count off the wire.
const PROFILE_MAX_POINTS = { desktop: 20000, phone: 7000 };
function pickProfile() {
return window.innerWidth < 768 ? "phone" : "desktop";
}
export function initPointcloudView() {
const canvas = document.getElementById("pointcloud-canvas");
const emptyState = document.getElementById("scan-empty");
const profile = pickProfile();
const maxPoints = PROFILE_MAX_POINTS[profile];
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e14);
const camera = new THREE.PerspectiveCamera(60, 1, 0.05, 500);
// FAST-LIO's "camera_init" world frame is z-up (LiDAR convention), not
// three.js's default y-up — set the camera's up vector to match so
// OrbitControls orbits around the right axis.
camera.up.set(0, 0, 1);
camera.position.set(0, -8, 5);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
const grid = new THREE.GridHelper(40, 40, 0x2a3040, 0x1a1f28);
grid.rotation.x = Math.PI / 2; // GridHelper defaults to the XZ plane (y-up); rotate into XY (z-up)
scene.add(grid);
const positions = new Float32Array(maxPoints * 3);
const colors = new Float32Array(maxPoints * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.setDrawRange(0, 0);
const material = new THREE.PointsMaterial({ size: 0.035, vertexColors: true, sizeAttenuation: true });
scene.add(new THREE.Points(geometry, material));
// Small marker at the current sensor pose (position + heading).
const poseGizmo = new THREE.Mesh(
new THREE.ConeGeometry(0.15, 0.4, 12),
new THREE.MeshBasicMaterial({ color: 0x5b8cff }),
);
poseGizmo.rotation.x = Math.PI / 2; // ConeGeometry points +y by default; point it along +x (forward) instead
poseGizmo.visible = false;
scene.add(poseGizmo);
let hasCentered = false;
let running = true;
let paused = false;
let viewMode = "orbit"; // or "lidar"
const latestPose = { position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false };
const lookTarget = new THREE.Vector3();
const FORWARD = new THREE.Vector3(1, 0, 0); // body-frame +x — matches the poseGizmo's forward convention below
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(canvas);
resize();
function updateCameraForLidarView() {
if (!latestPose.valid) return;
camera.position.copy(latestPose.position);
lookTarget.copy(FORWARD).applyQuaternion(latestPose.quaternion).add(latestPose.position);
camera.up.set(0, 0, 1);
camera.lookAt(lookTarget);
}
function animate() {
if (!running) return;
requestAnimationFrame(animate);
if (paused) return;
if (viewMode === "lidar") {
updateCameraForLidarView();
} else {
controls.update();
}
renderer.render(scene, camera);
}
animate();
function applyFrame(buf) {
const view = new DataView(buf);
if (String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)) !== MAGIC) return;
let off = 4;
off += 4; // frame_seq — unused client-side for now
off += 8; // timestamp
const poseFlag = view.getUint32(off, true); off += 4;
if (poseFlag) {
const x = view.getFloat32(off, true); const y = view.getFloat32(off + 4, true); const z = view.getFloat32(off + 8, true);
const qx = view.getFloat32(off + 12, true), qy = view.getFloat32(off + 16, true),
qz = view.getFloat32(off + 20, true), qw = view.getFloat32(off + 24, true);
off += 28;
poseGizmo.position.set(x, y, z);
poseGizmo.quaternion.set(qx, qy, qz, qw);
poseGizmo.visible = true;
latestPose.position.set(x, y, z);
latestPose.quaternion.set(qx, qy, qz, qw);
latestPose.valid = true;
if (!hasCentered) {
controls.target.set(x, y, z);
camera.position.set(x, y - 8, z + 5);
hasCentered = true;
}
}
const n = view.getUint32(off, true); off += 4;
const count = Math.min(n, maxPoints);
positions.set(new Float32Array(buf, off, count * 3));
const intensity = new Uint8Array(buf, off + n * 3 * 4, count);
for (let i = 0; i < count; i++) {
const t = intensity[i] / 255;
// Cool-to-warm ramp by intensity — no camera colour to draw from
// (LiDAR-only), so intensity is the only per-point signal worth encoding.
colors[i * 3] = 0.15 + 0.65 * t;
colors[i * 3 + 1] = 0.35 + 0.35 * (1 - Math.abs(t - 0.5) * 2);
colors[i * 3 + 2] = 0.75 - 0.55 * t;
}
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
geometry.setDrawRange(0, count);
emptyState.classList.toggle("hidden", count > 0);
}
function connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws/pointcloud?profile=${profile}`);
ws.binaryType = "arraybuffer";
ws.onmessage = (ev) => applyFrame(ev.data);
ws.onclose = () => { if (running) setTimeout(connect, 1500); };
ws.onerror = () => ws.close();
}
connect();
return {
setPaused(p) { paused = p; },
// "orbit" — free OrbitControls camera (default). "lidar" — camera locked
// to the sensor's live position/heading, first-person. Disabling
// OrbitControls in lidar mode (not just skipping controls.update()) stops
// it from silently accumulating drag input it never gets to apply, which
// would otherwise jump the view on switching back to orbit.
setViewMode(mode) {
viewMode = mode;
controls.enabled = mode === "orbit";
},
};
}
+123
View File
@@ -0,0 +1,123 @@
import { formatBytes, formatDuration } from "/js/format.js";
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
return res.json();
}
export function initRecordingPanel(defaultSaveDir) {
const saveDirInput = document.getElementById("rec-save-dir");
saveDirInput.value = defaultSaveDir;
const browseBtn = document.getElementById("btn-browse");
const browsePanel = document.getElementById("browse-panel");
const filenameInput = document.getElementById("rec-filename");
const confirmBtn = document.getElementById("btn-rec-confirm");
const stopBtn = document.getElementById("btn-rec-stop");
const bagList = document.getElementById("bag-list");
browseBtn.onclick = async () => {
if (!browsePanel.classList.contains("hidden")) {
browsePanel.classList.add("hidden");
return;
}
await renderBrowse(saveDirInput.value || defaultSaveDir);
browsePanel.classList.remove("hidden");
};
async function renderBrowse(path) {
const data = await getJSON(`/api/fs/browse?path=${encodeURIComponent(path)}`);
browsePanel.innerHTML = "";
const cur = document.createElement("div");
cur.className = "dir-entry";
cur.style.fontWeight = "bold";
cur.textContent = `✓ 선택: ${data.path}`;
cur.onclick = () => {
saveDirInput.value = data.path;
browsePanel.classList.add("hidden");
refreshBags();
};
browsePanel.appendChild(cur);
if (data.parent) {
const up = document.createElement("div");
up.className = "dir-entry";
up.textContent = "..";
up.onclick = () => renderBrowse(data.parent);
browsePanel.appendChild(up);
}
for (const d of data.dirs) {
const el = document.createElement("div");
el.className = "dir-entry";
el.textContent = d.name;
el.onclick = () => renderBrowse(d.path);
browsePanel.appendChild(el);
}
}
saveDirInput.addEventListener("change", refreshBags);
confirmBtn.onclick = async () => {
try {
await getJSON("/api/recording/start", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: filenameInput.value, save_dir: saveDirInput.value }),
});
refreshBags();
document.getElementById("overlay-recording").classList.add("hidden");
} catch (e) {
alert(String(e));
}
};
stopBtn.onclick = async () => {
await getJSON("/api/recording/stop", { method: "POST" });
// rosbag2 flushes metadata.yaml on SIGINT — give it a moment before
// reading the bag back, otherwise it still shows as "incomplete".
setTimeout(refreshBags, 1200);
};
function renderBagItem(bag) {
const li = document.createElement("li");
li.className = "bag-item";
const nameRow = document.createElement("div");
nameRow.className = "bag-item-name";
nameRow.textContent = bag.name;
if (!bag.complete) {
const badge = document.createElement("span");
badge.className = "bag-item-incomplete";
badge.textContent = "(녹화 중 / 미완료)";
nameRow.appendChild(badge);
}
li.appendChild(nameRow);
const meta = document.createElement("div");
meta.className = "bag-item-meta";
const parts = [formatDuration(bag.duration_s), formatBytes(bag.size_bytes)];
if (bag.message_count != null) parts.push(`msg ${bag.message_count.toLocaleString()}`);
if (bag.topics && bag.topics.length) parts.push(`topic ${bag.topics.length}`);
meta.textContent = parts.filter(Boolean).join(" · ");
li.appendChild(meta);
return li;
}
// Disk-free is owned entirely by status-panel.js now (HUD strip, driven by
// /ws/status) — this panel only needs the bag list.
async function refreshBags() {
const dir = saveDirInput.value || defaultSaveDir;
try {
const bagsData = await getJSON(`/api/bags?save_dir=${encodeURIComponent(dir)}`);
bagList.innerHTML = "";
for (const bag of bagsData.bags) {
bagList.appendChild(renderBagItem(bag));
}
} catch (e) {
// Directory may not exist yet (new save path) — not an error worth alarming over.
bagList.innerHTML = "";
}
}
refreshBags();
setInterval(refreshBags, 15000);
}
+138
View File
@@ -0,0 +1,138 @@
// 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;
}
}