feat: add native Svelte UI for robot recording, teleop, and eval

Adds a generic web dashboard at ui/ that works with any robot/teleop
type in lerobot. Calls lerobot-record, lerobot-teleoperate, and
lerobot-eval as background subprocesses.

Features:
- Setup tab: robot type/port/id, dynamic camera configuration
- Record tab: dataset config, teleop device, start/stop recording
- Teleop tab: start/stop teleoperation
- Evaluate tab: policy path, env type, device config
- Live MJPEG camera preview (paused during subprocess, auto-restarts)
- Terminal-style log panel streaming subprocess stdout/stderr
- Hardware discovery: serial ports, CAN interfaces, OpenCV cameras
- Config persistence via localStorage
- Dark theme, responsive camera grid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Pepijn
2026-03-13 22:37:19 -07:00
parent 86e7302e10
commit f97f57e950
10 changed files with 3680 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LeRobot UI</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Launch LeRobot UI — backend (FastAPI) + frontend (Svelte/Vite)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ---- Python dependencies ----
if ! python -c "import fastapi" 2>/dev/null; then
echo "Installing Python dependencies..."
pip install -r requirements.txt
fi
# ---- Node dependencies ----
if [ ! -d "node_modules" ]; then
echo "Installing Node dependencies..."
npm install
fi
# ---- Start backend ----
echo "Starting backend server on :8000 ..."
python server.py &
BACKEND_PID=$!
# Give the backend a moment to bind its port
sleep 1
# ---- Start frontend ----
echo "Starting frontend dev server on :5173 ..."
npm run dev &
FRONTEND_PID=$!
# ---- Cleanup on exit ----
cleanup() {
echo ""
echo "Shutting down..."
kill "$BACKEND_PID" 2>/dev/null || true
kill "$FRONTEND_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo ""
echo "================================================"
echo " LeRobot UI → http://localhost:5173"
echo " Backend API → http://localhost:8000"
echo " Press Ctrl+C to stop"
echo "================================================"
echo ""
wait
+1299
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "lerobot-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.2.0",
"vite": "^5.0.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
opencv-python>=4.9.0
pydantic>=2.0.0
+647
View File
@@ -0,0 +1,647 @@
"""
LeRobot UI — FastAPI Backend
Manages subprocess lifecycle for lerobot CLI tools, camera streaming via
OpenCV MJPEG, hardware discovery, and a circular log buffer.
"""
from __future__ import annotations
import asyncio
import glob
import json
import os
import platform
import signal
import subprocess
import threading
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Any
import cv2
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
app = FastAPI(title="LeRobot UI Server", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Global state
# ---------------------------------------------------------------------------
MAX_LOG_LINES = 500
_state: dict[str, Any] = {
"mode": "idle", # idle | recording | teleoperation | evaluating
"message": "Ready",
"error": None,
"episode_count": 0,
"subprocess": None, # active Popen
"subprocess_lock": threading.Lock(),
"logs": deque(maxlen=MAX_LOG_LINES),
"log_lock": threading.Lock(),
"cameras": {}, # name -> {cap, thread, frame, running}
"camera_lock": threading.Lock(),
"pending_cameras": [], # list of camera dicts to restart after subprocess ends
}
# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------
def _ts() -> str:
return datetime.now().strftime("%H:%M:%S")
def _log(msg: str) -> None:
with _state["log_lock"]:
_state["logs"].append({"ts": _ts(), "msg": msg})
# ---------------------------------------------------------------------------
# Camera streaming
# ---------------------------------------------------------------------------
def _camera_reader(name: str, index_or_path: str | int) -> None:
"""Background thread: reads frames from a camera and stores the latest."""
# Attempt numeric index first, then string path
try:
src: int | str = int(index_or_path)
except (ValueError, TypeError):
src = str(index_or_path)
cap = cv2.VideoCapture(src)
if not cap.isOpened():
_log(f"[camera/{name}] Could not open {index_or_path}")
with _state["camera_lock"]:
if name in _state["cameras"]:
_state["cameras"][name]["running"] = False
return
_log(f"[camera/{name}] Opened {index_or_path}")
with _state["camera_lock"]:
if name in _state["cameras"]:
_state["cameras"][name]["cap"] = cap
while True:
with _state["camera_lock"]:
if name not in _state["cameras"] or not _state["cameras"][name].get("running"):
break
ret, frame = cap.read()
if not ret:
time.sleep(0.05)
continue
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 75])
with _state["camera_lock"]:
if name in _state["cameras"]:
_state["cameras"][name]["frame"] = buf.tobytes()
cap.release()
_log(f"[camera/{name}] Stopped")
def _start_cameras(cameras: list[dict]) -> None:
"""Start MJPEG reader threads for a list of camera dicts."""
with _state["camera_lock"]:
# Stop any existing camera with same name
for cam in cameras:
name = cam["name"]
if name in _state["cameras"]:
_state["cameras"][name]["running"] = False
time.sleep(0.1) # let threads notice the stop flag
with _state["camera_lock"]:
for cam in cameras:
name = cam["name"]
entry: dict[str, Any] = {
"cap": None,
"thread": None,
"frame": None,
"running": True,
"index_or_path": cam.get("index_or_path", 0),
}
t = threading.Thread(
target=_camera_reader,
args=(name, cam.get("index_or_path", 0)),
daemon=True,
)
entry["thread"] = t
_state["cameras"][name] = entry
t.start()
def _stop_all_cameras() -> None:
with _state["camera_lock"]:
for name in list(_state["cameras"].keys()):
_state["cameras"][name]["running"] = False
_log("[cameras] All camera streams stopped")
def _mjpeg_generator(name: str):
"""Yield MJPEG frames for a given camera name."""
while True:
frame = None
with _state["camera_lock"]:
cam = _state["cameras"].get(name)
if cam:
frame = cam.get("frame")
if frame:
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n"
)
time.sleep(0.033) # ~30 fps ceiling
# ---------------------------------------------------------------------------
# Subprocess management
# ---------------------------------------------------------------------------
def _read_output(proc: subprocess.Popen, label: str) -> None:
"""Read stdout/stderr from a subprocess and feed into log buffer."""
streams = []
if proc.stdout:
streams.append(proc.stdout)
if proc.stderr:
streams.append(proc.stderr)
import select as _select
while True:
if proc.poll() is not None:
# Drain remaining output
for s in streams:
for line in s:
_log(f"[{label}] {line.rstrip()}")
break
try:
readable, _, _ = _select.select(streams, [], [], 0.1)
for s in readable:
line = s.readline()
if line:
_log(f"[{label}] {line.rstrip()}")
except Exception:
break
def _watch_subprocess(label: str) -> None:
"""Watch the active subprocess; clean up state when it exits."""
with _state["subprocess_lock"]:
proc = _state["subprocess"]
if proc is None:
return
proc.wait()
rc = proc.returncode
_log(f"[{label}] Process exited with code {rc}")
with _state["subprocess_lock"]:
if _state["subprocess"] is proc:
_state["subprocess"] = None
_state["mode"] = "idle"
_state["message"] = f"Finished (exit {rc})"
if rc not in (0, -15, -2): # not clean exit / SIGTERM / SIGINT
_state["error"] = f"Process exited with code {rc}"
# Restart camera preview if cameras were pending
pending = _state.get("pending_cameras", [])
if pending:
_log("[cameras] Restarting camera preview after subprocess ended")
_start_cameras(pending)
def _launch(cmd: list[str], mode: str, label: str) -> None:
"""Launch a subprocess, stop cameras first, set state."""
with _state["subprocess_lock"]:
if _state["subprocess"] is not None and _state["subprocess"].poll() is None:
raise RuntimeError("A process is already running. Stop it first.")
# Stop cameras so the subprocess can access them
_stop_all_cameras()
_log(f"[server] Launching: {' '.join(cmd)}")
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
with _state["subprocess_lock"]:
_state["subprocess"] = proc
_state["mode"] = mode
_state["message"] = f"Running {label}..."
_state["error"] = None
# Pipe output
t_out = threading.Thread(target=_read_output, args=(proc, label), daemon=True)
t_out.start()
# Watch for completion
t_watch = threading.Thread(target=_watch_subprocess, args=(label,), daemon=True)
t_watch.start()
# ---------------------------------------------------------------------------
# Hardware discovery
# ---------------------------------------------------------------------------
def _discover_robot_types() -> list[str]:
try:
from importlib.util import find_spec
spec = find_spec("lerobot")
if spec is None or spec.origin is None:
return []
robots_dir = Path(spec.origin).parent / "robots"
return sorted(
d.name
for d in robots_dir.iterdir()
if d.is_dir() and not d.name.startswith("_")
)
except Exception:
return []
def _discover_teleop_types() -> list[str]:
try:
from importlib.util import find_spec
spec = find_spec("lerobot")
if spec is None or spec.origin is None:
return []
teleops_dir = Path(spec.origin).parent / "teleoperators"
return sorted(
d.name
for d in teleops_dir.iterdir()
if d.is_dir() and not d.name.startswith("_")
)
except Exception:
return []
def _discover_serial_ports() -> list[str]:
patterns = [
"/dev/ttyUSB*",
"/dev/ttyACM*",
"/dev/cu.usbmodem*",
"/dev/cu.usbserial*",
]
ports: list[str] = []
for pat in patterns:
ports.extend(glob.glob(pat))
return sorted(set(ports))
def _discover_can_interfaces() -> list[str]:
try:
result = subprocess.run(
["ip", "link", "show"],
capture_output=True,
text=True,
timeout=5,
)
interfaces = []
for line in result.stdout.splitlines():
# Lines like: "3: can0: <...>"
parts = line.strip().split(":")
if len(parts) >= 2:
iface = parts[1].strip()
if iface.startswith("can"):
interfaces.append(iface)
return sorted(interfaces)
except Exception:
return []
def _discover_cameras() -> list[dict]:
"""Scan OpenCV indices 0-9 and /dev/video* paths."""
found: list[dict] = []
checked: set = set()
# Numeric indices
for idx in range(10):
cap = cv2.VideoCapture(idx)
if cap.isOpened():
found.append({"index_or_path": idx, "label": f"Camera {idx} (index {idx})"})
checked.add(idx)
cap.release()
# /dev/video* paths on Linux
for path in sorted(glob.glob("/dev/video*")):
cap = cv2.VideoCapture(path)
if cap.isOpened():
# Avoid duplicate if already found by index
label = f"Camera ({path})"
found.append({"index_or_path": path, "label": label})
cap.release()
return found
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class CameraEntry(BaseModel):
name: str
type: str = "opencv"
index_or_path: str | int = 0
width: int = 640
height: int = 480
fps: int = 30
class PreviewRequest(BaseModel):
cameras: list[CameraEntry]
class RecordRequest(BaseModel):
robot_type: str
robot_port: str = ""
robot_id: str = ""
robot_cameras: list[CameraEntry] = []
teleop_type: str
teleop_port: str = ""
teleop_id: str = ""
repo_id: str
single_task: str
num_episodes: int = 10
fps: int = 30
episode_time_s: int = 60
reset_time_s: int = 5
push_to_hub: bool = True
private: bool = False
display_data: bool = False
class TeleopRequest(BaseModel):
robot_type: str
robot_port: str = ""
robot_cameras: list[CameraEntry] = []
teleop_type: str
teleop_port: str = ""
display_data: bool = False
class EvalRequest(BaseModel):
policy_path: str
env_type: str
n_episodes: int = 10
batch_size: int = 1
device: str = "cpu"
# ---------------------------------------------------------------------------
# Helper: build cameras JSON arg
# ---------------------------------------------------------------------------
def _cameras_arg(cameras: list[CameraEntry]) -> str:
d: dict[str, dict] = {}
for cam in cameras:
d[cam.name] = {
"type": cam.type,
"index_or_path": cam.index_or_path,
"width": cam.width,
"height": cam.height,
"fps": cam.fps,
}
return json.dumps(d)
# ---------------------------------------------------------------------------
# API routes
# ---------------------------------------------------------------------------
@app.get("/api/status")
def get_status() -> dict:
with _state["subprocess_lock"]:
proc = _state["subprocess"]
proc_running = proc is not None and proc.poll() is None
with _state["camera_lock"]:
active_cams = [
name
for name, cam in _state["cameras"].items()
if cam.get("running") and cam.get("frame") is not None
]
return {
"mode": _state["mode"],
"message": _state["message"],
"error": _state["error"],
"episode_count": _state["episode_count"],
"process_running": proc_running,
"active_cameras": active_cams,
}
@app.get("/api/robots")
def list_robots() -> dict:
return {"types": _discover_robot_types()}
@app.get("/api/teleops")
def list_teleops() -> dict:
return {"types": _discover_teleop_types()}
@app.get("/api/hardware/serial-ports")
def serial_ports() -> dict:
return {"ports": _discover_serial_ports()}
@app.get("/api/hardware/can-interfaces")
def can_interfaces() -> dict:
return {"interfaces": _discover_can_interfaces()}
@app.get("/api/hardware/cameras")
def hardware_cameras() -> dict:
return {"cameras": _discover_cameras()}
@app.post("/api/cameras/preview")
def start_preview(req: PreviewRequest) -> dict:
cams = [c.model_dump() for c in req.cameras]
_state["pending_cameras"] = cams
_start_cameras(cams)
return {"status": "ok", "started": len(cams)}
@app.post("/api/cameras/stop")
def stop_preview() -> dict:
_stop_all_cameras()
_state["pending_cameras"] = []
return {"status": "ok"}
@app.get("/api/camera/stream/{name}")
def camera_stream(name: str):
with _state["camera_lock"]:
if name not in _state["cameras"]:
raise HTTPException(status_code=404, detail=f"Camera '{name}' not active")
return StreamingResponse(
_mjpeg_generator(name),
media_type="multipart/x-mixed-replace; boundary=frame",
)
@app.post("/api/record/start")
def start_record(req: RecordRequest) -> dict:
cmd = ["lerobot-record"]
cmd += [f"--robot.type={req.robot_type}"]
if req.robot_port:
cmd += [f"--robot.port={req.robot_port}"]
if req.robot_id:
cmd += [f"--robot.id={req.robot_id}"]
if req.robot_cameras:
cmd += [f"--robot.cameras={_cameras_arg(req.robot_cameras)}"]
cmd += [f"--teleop.type={req.teleop_type}"]
if req.teleop_port:
cmd += [f"--teleop.port={req.teleop_port}"]
if req.teleop_id:
cmd += [f"--teleop.id={req.teleop_id}"]
cmd += [
f"--dataset.repo_id={req.repo_id}",
f"--dataset.single_task={req.single_task}",
f"--dataset.num_episodes={req.num_episodes}",
f"--dataset.fps={req.fps}",
f"--dataset.episode_time_s={req.episode_time_s}",
f"--dataset.reset_time_s={req.reset_time_s}",
f"--dataset.push_to_hub={'true' if req.push_to_hub else 'false'}",
f"--dataset.private={'true' if req.private else 'false'}",
f"--display_data={'true' if req.display_data else 'false'}",
]
_state["pending_cameras"] = [c.model_dump() for c in req.robot_cameras]
try:
_launch(cmd, "recording", "lerobot-record")
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
return {"status": "ok"}
@app.post("/api/teleop/start")
def start_teleop(req: TeleopRequest) -> dict:
cmd = ["lerobot-teleoperate"]
cmd += [f"--robot.type={req.robot_type}"]
if req.robot_port:
cmd += [f"--robot.port={req.robot_port}"]
if req.robot_cameras:
cmd += [f"--robot.cameras={_cameras_arg(req.robot_cameras)}"]
cmd += [f"--teleop.type={req.teleop_type}"]
if req.teleop_port:
cmd += [f"--teleop.port={req.teleop_port}"]
cmd += [f"--display_data={'true' if req.display_data else 'false'}"]
_state["pending_cameras"] = [c.model_dump() for c in req.robot_cameras]
try:
_launch(cmd, "teleoperation", "lerobot-teleoperate")
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
return {"status": "ok"}
@app.post("/api/eval/start")
def start_eval(req: EvalRequest) -> dict:
cmd = [
"lerobot-eval",
f"--policy.path={req.policy_path}",
f"--env.type={req.env_type}",
f"--eval.n_episodes={req.n_episodes}",
f"--eval.batch_size={req.batch_size}",
f"--policy.device={req.device}",
]
try:
_launch(cmd, "evaluating", "lerobot-eval")
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
return {"status": "ok"}
@app.post("/api/process/stop")
def stop_process() -> dict:
with _state["subprocess_lock"]:
proc = _state["subprocess"]
if proc is None or proc.poll() is not None:
return {"status": "no_process"}
try:
proc.send_signal(signal.SIGTERM)
except ProcessLookupError:
pass
_log("[server] Sent SIGTERM to active process")
return {"status": "ok"}
@app.post("/api/process/kill")
def kill_process() -> dict:
with _state["subprocess_lock"]:
proc = _state["subprocess"]
if proc is None or proc.poll() is not None:
return {"status": "no_process"}
try:
proc.send_signal(signal.SIGKILL)
except ProcessLookupError:
pass
_log("[server] Sent SIGKILL to active process")
return {"status": "ok"}
@app.get("/api/logs")
def get_logs() -> dict:
with _state["log_lock"]:
return {"logs": list(_state["logs"])}
@app.post("/api/counter/reset")
def reset_counter() -> dict:
_state["episode_count"] = 0
return {"status": "ok"}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
_log("[server] LeRobot UI backend starting on :8000")
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
+1128
View File
File diff suppressed because it is too large Load Diff
+504
View File
@@ -0,0 +1,504 @@
/* =========================================================
LeRobot UI Global Styles (Dark Theme)
========================================================= */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* ---- CSS Variables ---- */
:root {
--bg: #0f1117;
--panel: #1a1d27;
--panel-hover: #1f2333;
--border: #30363d;
--border-light: #21262d;
--text: #e1e4e8;
--text-muted: #8b949e;
--text-faint: #484f58;
--accent: #6e56cf;
--accent-hover: #7c3aed;
--accent-dim: rgba(110, 86, 207, 0.15);
--error: #f85149;
--error-dim: rgba(248, 81, 73, 0.15);
--success: #3fb950;
--success-dim: rgba(63, 185, 80, 0.15);
--warning: #e3b341;
--warning-dim: rgba(227, 179, 65, 0.15);
--info: #58a6ff;
--info-dim: rgba(88, 166, 255, 0.15);
--red: #f85149;
--red-dim: rgba(248, 81, 73, 0.2);
--blue: #58a6ff;
--blue-dim: rgba(88, 166, 255, 0.15);
--purple: #bc8cff;
--purple-dim: rgba(188, 140, 255, 0.15);
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
--shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
/* ---- Reset ---- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
}
/* ---- Scrollbar ---- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-faint);
}
/* ---- Typography ---- */
h1 { font-size: 1.25rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 600; }
h3 { font-size: 0.875rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
/* ---- Cards / Panels ---- */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-light);
}
/* ---- Buttons ---- */
button {
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: var(--radius);
padding: 0.5rem 1rem;
cursor: pointer;
transition: background 0.15s, opacity 0.15s, transform 0.1s;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
button:active:not(:disabled) {
transform: scale(0.97);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--panel-hover);
}
.btn-danger {
background: var(--error);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #e03b31;
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 0.4rem 0.75rem;
}
.btn-ghost:hover:not(:disabled) {
background: var(--panel-hover);
color: var(--text);
}
.btn-sm {
font-size: 0.75rem;
padding: 0.3rem 0.65rem;
}
.btn-lg {
font-size: 1rem;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-lg);
font-weight: 600;
width: 100%;
justify-content: center;
}
.btn-icon {
padding: 0.4rem;
width: 2rem;
height: 2rem;
justify-content: center;
}
/* ---- Status Badges ---- */
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.6rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-idle { background: var(--success-dim); color: var(--success); }
.badge-recording { background: var(--red-dim); color: var(--red); }
.badge-teleoperation { background: var(--blue-dim); color: var(--blue); }
.badge-evaluating { background: var(--purple-dim); color: var(--purple); }
/* ---- Inputs ---- */
input[type="text"],
input[type="number"],
input[type="password"],
textarea,
select {
font-family: inherit;
font-size: 0.875rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
width: 100%;
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
appearance: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
input::placeholder, textarea::placeholder {
color: var(--text-faint);
}
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.form-row {
display: grid;
gap: 0.75rem;
}
.form-row-2 { grid-template-columns: 1fr 1fr; }
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
/* ---- Toggle / Switch ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.toggle-label {
font-size: 0.875rem;
color: var(--text);
}
.toggle-sublabel {
font-size: 0.75rem;
color: var(--text-muted);
}
.toggle {
position: relative;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 11px;
cursor: pointer;
transition: background 0.2s;
}
.toggle input:checked + .toggle-track {
background: var(--accent);
}
.toggle-track::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
top: 3px;
left: 3px;
transition: transform 0.2s;
}
.toggle input:checked + .toggle-track::after {
transform: translateX(18px);
}
/* ---- Section divider ---- */
.section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-light);
}
/* ---- Status / Alert boxes ---- */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.875rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.alert-error { background: var(--error-dim); border: 1px solid var(--error); color: var(--error); }
.alert-success { background: var(--success-dim); border: 1px solid var(--success); color: var(--success); }
.alert-warning { background: var(--warning-dim); border: 1px solid var(--warning); color: var(--warning); }
.alert-info { background: var(--info-dim); border: 1px solid var(--info); color: var(--info); }
/* ---- Recording pulse indicator ---- */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.rec-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--red);
animation: pulse 1s infinite;
flex-shrink: 0;
}
/* ---- Spinner ---- */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
/* ---- Log terminal ---- */
.log-terminal {
background: #0d1117;
border: 1px solid var(--border-light);
border-radius: var(--radius);
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 0.75rem;
line-height: 1.6;
overflow-y: auto;
padding: 0.75rem;
color: #c9d1d9;
}
.log-line {
display: flex;
gap: 0.75rem;
}
.log-ts {
color: var(--text-faint);
flex-shrink: 0;
user-select: none;
}
.log-msg {
white-space: pre-wrap;
word-break: break-all;
}
/* ---- Camera grid ---- */
.camera-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: 1fr;
}
.camera-grid.cams-2 { grid-template-columns: 1fr 1fr; }
.camera-grid.cams-3 { grid-template-columns: 1fr 1fr; }
.camera-grid.cams-4 { grid-template-columns: 1fr 1fr; }
.camera-feed {
background: #0d1117;
border: 1px solid var(--border-light);
border-radius: var(--radius);
overflow: hidden;
aspect-ratio: 4/3;
position: relative;
}
.camera-feed img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 0.7rem;
padding: 0.3rem 0.5rem;
backdrop-filter: blur(4px);
}
.camera-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
color: var(--text-faint);
text-align: center;
border: 1px dashed var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
}
/* ---- Tabs ---- */
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.25rem;
}
.tab-btn {
background: transparent;
color: var(--text-muted);
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.6rem 1rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
transform: none;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ---- Misc utilities ---- */
.flex { display: flex; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 0.8rem; }
.text-xs { font-size: 0.72rem; }
.font-mono { font-family: 'Menlo', 'Monaco', monospace; }
+8
View File
@@ -0,0 +1,8 @@
import './app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app'),
});
export default app;
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
},
},
});