mirror of
https://github.com/huggingface/lerobot.git
synced 2026-05-26 22:20:06 +00:00
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:
@@ -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
@@ -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
|
||||||
Generated
+1299
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.110.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
opencv-python>=4.9.0
|
||||||
|
pydantic>=2.0.0
|
||||||
+647
@@ -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
File diff suppressed because it is too large
Load Diff
+504
@@ -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; }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import './app.css';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user