Browse Source
feat(dashboard): implement GUI orchestrator and fix simulation I/O pipelines
feat(dashboard): implement GUI orchestrator and fix simulation I/O pipelines
This commit introduces a Flask-based web dashboard for the BATL CARLA orchestrator and hardens the underlying data processing pipeline to support real-time UI tracking. Dashboard & GUI: - Added `dashboard/` directory containing the Flask backend (`app.py`), HTML UI, and static assets (CSS/JS) for a seamless browser-based simulation controller. - Created `dashboard.bat` to launch the web dashboard environment. Pipeline Stability (Unbuffered Streaming): - `run.bat`: Forced `PYTHONUNBUFFERED=1` to ensure subprocess stdout/stderr is immediately available. - `dashboard/app.py`: Injected the unbuffered environment flag into `subprocess.Popen` to prevent the UI console from freezing during heavy post-processing tasks. - `src/recorder.py` & `data_to_mcap.py`: Appended `flush=True` to stdout prints to guarantee immediate log propagation to the web dashboard text stream. Foxglove Data Integrity: - `data_to_mcap.py`: Hardened internal JSON schemas (`foxglove.Pose`, `foxglove.CompressedImage`, `foxglove.PointCloud`) by adding `$schema`, `$id`, and `title` metadata tags over draft-2020-12. This prevents Foxglove Studio from attempting internet CDN lookups, fully resolving the "Unable to parse ok response body as json" crashes when importing local MCAP sets.1843_integration
10 changed files with 1092 additions and 7 deletions
-
41dashboard.bat
-
138dashboard/app.py
-
217dashboard/static/app.js
-
479dashboard/static/style.css
-
117dashboard/templates/index.html
-
19data_to_mcap.py
-
13intel/context.md
-
68intel/dashboard.md
-
3run.bat
-
4src/recorder.py
@ -0,0 +1,41 @@ |
|||||
|
@echo off |
||||
|
setlocal |
||||
|
|
||||
|
:: ----------------------------------------------------------------- |
||||
|
:: dashboard.bat — BATL CARLA Orchestrator launcher |
||||
|
:: Place this file anywhere in the project root. |
||||
|
:: It activates the carla312 conda env and starts dashboard/app.py |
||||
|
:: ----------------------------------------------------------------- |
||||
|
|
||||
|
:: Resolve the project root to the directory containing THIS .bat file |
||||
|
set "PROJECT_ROOT=%~dp0" |
||||
|
:: Remove trailing backslash |
||||
|
if "%PROJECT_ROOT:~-1%"=="\" set "PROJECT_ROOT=%PROJECT_ROOT:~0,-1%" |
||||
|
|
||||
|
echo ------------------------------------------------- |
||||
|
echo BATL CARLA Orchestrator |
||||
|
echo Project root: %PROJECT_ROOT% |
||||
|
echo ------------------------------------------------- |
||||
|
|
||||
|
:: Activate conda environment |
||||
|
call C:\ProgramData\miniconda3\Scripts\activate.bat carla312 |
||||
|
if %errorlevel% neq 0 ( |
||||
|
echo [ERROR] Failed to activate conda environment 'carla312'. |
||||
|
pause |
||||
|
exit /b 1 |
||||
|
) |
||||
|
|
||||
|
:: Open browser to dashboard (slight delay lets Flask start first) |
||||
|
timeout /t 1 /nobreak >nul |
||||
|
start "" "http://127.0.0.1:5000" |
||||
|
|
||||
|
:: Launch the Flask app — always from PROJECT_ROOT so run.bat is reachable |
||||
|
cd /d "%PROJECT_ROOT%" |
||||
|
python dashboard\app.py |
||||
|
|
||||
|
if %errorlevel% neq 0 ( |
||||
|
echo [ERROR] Application crashed or failed to start. |
||||
|
pause |
||||
|
) |
||||
|
|
||||
|
endlocal |
||||
@ -0,0 +1,138 @@ |
|||||
|
""" |
||||
|
dashboard/app.py |
||||
|
---------------- |
||||
|
Flask backend for the BATL CARLA Orchestrator Dashboard. |
||||
|
Serves the HTML UI, exposes REST endpoints, and streams |
||||
|
real-time simulation output via Server-Sent Events (SSE). |
||||
|
|
||||
|
NOTE: This file lives inside the /dashboard sub-folder. |
||||
|
PROJECT_ROOT is therefore TWO levels up: dashboard/app.py → dashboard/ → Fox/ |
||||
|
""" |
||||
|
|
||||
|
import os |
||||
|
import sys |
||||
|
from flask import Flask, render_template, request, jsonify, Response |
||||
|
import subprocess |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# Path bootstrapping — make the project root importable regardless |
||||
|
# of which directory the user launches from. |
||||
|
# ------------------------------------------------------------------ |
||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
||||
|
if PROJECT_ROOT not in sys.path: |
||||
|
sys.path.insert(0, PROJECT_ROOT) |
||||
|
|
||||
|
import config |
||||
|
from src.scenario_loader import list_scenarios |
||||
|
|
||||
|
# ------------------------------------------------------------------ |
||||
|
# Flask app — templates and static assets are siblings of this file. |
||||
|
# ------------------------------------------------------------------ |
||||
|
DASHBOARD_DIR = os.path.dirname(os.path.abspath(__file__)) |
||||
|
|
||||
|
app = Flask( |
||||
|
__name__, |
||||
|
template_folder=os.path.join(DASHBOARD_DIR, "templates"), |
||||
|
static_folder=os.path.join(DASHBOARD_DIR, "static"), |
||||
|
) |
||||
|
|
||||
|
|
||||
|
# ── Routes ──────────────────────────────────────────────────────── |
||||
|
|
||||
|
@app.route("/") |
||||
|
def index(): |
||||
|
return render_template("index.html") |
||||
|
|
||||
|
|
||||
|
@app.route("/api/config", methods=["GET"]) |
||||
|
def get_config(): |
||||
|
scenarios = list_scenarios() |
||||
|
return jsonify({ |
||||
|
"scenarios": scenarios, |
||||
|
"default_scenario": getattr(config, "DEFAULT_SCENARIO", "braking"), |
||||
|
"default_frames": getattr(config, "MAX_FRAMES", 200), |
||||
|
"default_weather": getattr(config, "DEFAULT_WEATHER", "ClearNoon"), |
||||
|
"weather_options": [ |
||||
|
"ClearNoon", "CloudyNoon", "WetNoon", |
||||
|
"SoftRainNoon", "HardRainNoon", "ClearSunset", "Night" |
||||
|
], |
||||
|
}) |
||||
|
|
||||
|
|
||||
|
@app.route("/api/scenario_params/<scenario_name>", methods=["GET"]) |
||||
|
def get_scenario_params(scenario_name): |
||||
|
from src.scenario_loader import load_scenario |
||||
|
try: |
||||
|
scenario = load_scenario(scenario_name) |
||||
|
tunable_params = {} |
||||
|
# Convention: tunable constants are UPPERCASE class-level attributes |
||||
|
for attr in dir(scenario): |
||||
|
if attr.isupper() and not attr.startswith("_"): |
||||
|
val = getattr(scenario, attr) |
||||
|
if isinstance(val, (int, float, str, bool)): |
||||
|
tunable_params[attr] = val |
||||
|
return jsonify({"success": True, "params": tunable_params}) |
||||
|
except Exception as e: |
||||
|
return jsonify({"success": False, "error": str(e)}) |
||||
|
|
||||
|
|
||||
|
@app.route("/api/run", methods=["POST"]) |
||||
|
def run_simulation(): |
||||
|
data = request.json or {} |
||||
|
scenario = data.get("scenario") or "braking" |
||||
|
frames = data.get("frames") or "" |
||||
|
weather = data.get("weather") or "" |
||||
|
no_record = bool(data.get("no_record")) |
||||
|
params = data.get("params") or "" |
||||
|
|
||||
|
# Build the command — run.bat lives in PROJECT_ROOT |
||||
|
cmd = ["cmd.exe", "/c", "run.bat", scenario] |
||||
|
if frames: |
||||
|
cmd.extend(["--frames", str(frames)]) |
||||
|
if weather: |
||||
|
cmd.extend(["--weather", weather]) |
||||
|
if no_record: |
||||
|
cmd.append("--no-record") |
||||
|
if params: |
||||
|
cmd.extend(["--params", f'"{params}"']) |
||||
|
|
||||
|
def generate(): |
||||
|
try: |
||||
|
# cwd = project root so that run.bat is reachable |
||||
|
env = os.environ.copy() |
||||
|
env["PYTHONUNBUFFERED"] = "1" |
||||
|
|
||||
|
process = subprocess.Popen( |
||||
|
cmd, |
||||
|
stdout=subprocess.PIPE, |
||||
|
stderr=subprocess.STDOUT, |
||||
|
text=True, |
||||
|
bufsize=1, |
||||
|
cwd=PROJECT_ROOT, |
||||
|
env=env, |
||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, |
||||
|
) |
||||
|
|
||||
|
yield f"data: [INFO] Starting command: {' '.join(cmd)}\n\n" |
||||
|
|
||||
|
for line in iter(process.stdout.readline, ""): |
||||
|
if line: |
||||
|
yield f"data: {line.rstrip()}\n\n" |
||||
|
|
||||
|
process.stdout.close() |
||||
|
return_code = process.wait() |
||||
|
yield f"data: [PROCESS_COMPLETED] Exit Code: {return_code}\n\n" |
||||
|
|
||||
|
except Exception as e: |
||||
|
yield f"data: [ERROR] Failed to start process: {str(e)}\n\n" |
||||
|
|
||||
|
return Response(generate(), mimetype="text/event-stream") |
||||
|
|
||||
|
|
||||
|
# ── Entry point ─────────────────────────────────────────────────── |
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
print("Starting BATL CARLA Dashboard orchestrator...") |
||||
|
print(f" Project root : {PROJECT_ROOT}") |
||||
|
print(f" Dashboard dir: {DASHBOARD_DIR}") |
||||
|
app.run(host="0.0.0.0", port=5000, debug=True) |
||||
@ -0,0 +1,217 @@ |
|||||
|
document.addEventListener('DOMContentLoaded', () => { |
||||
|
const scenarioList = document.getElementById('scenario-list'); |
||||
|
const weatherSelect = document.getElementById('weather'); |
||||
|
const framesInput = document.getElementById('frames'); |
||||
|
const launchForm = document.getElementById('launch-form'); |
||||
|
const launchBtn = document.getElementById('launch-btn'); |
||||
|
const launchText = document.getElementById('launch-text'); |
||||
|
const launchSpinner = document.getElementById('launch-spinner'); |
||||
|
const terminalOutput = document.getElementById('terminal-output'); |
||||
|
const clearTerminalBtn = document.getElementById('clear-terminal'); |
||||
|
|
||||
|
let currentEventSource = null; |
||||
|
|
||||
|
// Fetch Initial Config
|
||||
|
fetch('/api/config') |
||||
|
.then(response => response.json()) |
||||
|
.then(config => { |
||||
|
// Populate Scenarios
|
||||
|
config.scenarios.forEach((scenario, index) => { |
||||
|
const isChecked = scenario === config.default_scenario ? 'checked' : ''; |
||||
|
const cardHtml = `
|
||||
|
<label class="scenario-card"> |
||||
|
<input type="radio" name="scenario" value="${scenario}" ${isChecked}> |
||||
|
<div class="card-content"> |
||||
|
<span class="card-title">${scenario}</span> |
||||
|
</div> |
||||
|
</label> |
||||
|
`;
|
||||
|
scenarioList.insertAdjacentHTML('beforeend', cardHtml); |
||||
|
}); |
||||
|
|
||||
|
// Listen for scenario changes to load dynamic parameters
|
||||
|
const scenarioRadios = document.querySelectorAll('input[name="scenario"]'); |
||||
|
scenarioRadios.forEach(radio => { |
||||
|
radio.addEventListener('change', (e) => loadScenarioParams(e.target.value)); |
||||
|
}); |
||||
|
|
||||
|
// Load params for the initially selected scenario
|
||||
|
if (config.default_scenario) { |
||||
|
loadScenarioParams(config.default_scenario); |
||||
|
} |
||||
|
|
||||
|
// Populate Weather
|
||||
|
config.weather_options.forEach(weather => { |
||||
|
const isSelected = weather === config.default_weather ? 'selected' : ''; |
||||
|
const optionHtml = `<option value="${weather}" ${isSelected}>${weather}</option>`; |
||||
|
weatherSelect.insertAdjacentHTML('beforeend', optionHtml); |
||||
|
}); |
||||
|
|
||||
|
// Default Frames
|
||||
|
framesInput.value = config.default_frames; |
||||
|
|
||||
|
appendLog(`> Config loaded successfully. Found ${config.scenarios.length} scenarios.`, 'system'); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
appendLog(`> Error loading config: ${error.message}`, 'error'); |
||||
|
}); |
||||
|
|
||||
|
// Handle Launch
|
||||
|
launchForm.addEventListener('submit', (e) => { |
||||
|
e.preventDefault(); |
||||
|
|
||||
|
// UI Loading State
|
||||
|
setLoadingState(true); |
||||
|
appendLog(`\n> --- NEW SIMULATION RUN ---`, 'info'); |
||||
|
|
||||
|
// Close existing stream if any
|
||||
|
if (currentEventSource) { |
||||
|
currentEventSource.close(); |
||||
|
} |
||||
|
|
||||
|
const formData = new FormData(launchForm); |
||||
|
|
||||
|
// Scenario radions are in the sidebar, outside the form
|
||||
|
const scenarioRadio = document.querySelector('input[name="scenario"]:checked'); |
||||
|
const scenarioValue = scenarioRadio ? scenarioRadio.value : 'braking'; |
||||
|
|
||||
|
// Serialize dynamic parameters
|
||||
|
const paramInputs = document.querySelectorAll('#dynamic-params-container input'); |
||||
|
let paramsString = []; |
||||
|
paramInputs.forEach(input => { |
||||
|
if (input.value !== '') { |
||||
|
paramsString.push(`${input.name}=${input.value}`); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const payload = { |
||||
|
scenario: scenarioValue, |
||||
|
frames: formData.get('frames'), |
||||
|
weather: formData.get('weather'), |
||||
|
no_record: formData.get('no_record') === 'on', |
||||
|
params: paramsString.join(',') |
||||
|
}; |
||||
|
|
||||
|
// We use fetch API to trigger the run (which could take long), but Server Sent Events (SSE)
|
||||
|
// doesn't support POST natively without fetch streams. So we do a POST fetch that returns an SSE stream.
|
||||
|
startStream('/api/run', payload); |
||||
|
}); |
||||
|
|
||||
|
// Clear Terminal
|
||||
|
clearTerminalBtn.addEventListener('click', () => { |
||||
|
terminalOutput.innerHTML = ''; |
||||
|
appendLog('> Terminal cleared.', 'system'); |
||||
|
}); |
||||
|
|
||||
|
// Utility: Append log to terminal
|
||||
|
function appendLog(text, type = '') { |
||||
|
const line = document.createElement('div'); |
||||
|
line.className = `log-line ${type}`; |
||||
|
|
||||
|
// Simple heuristic for colored logs
|
||||
|
if (text.includes('[INFO]')) line.classList.add('info'); |
||||
|
if (text.includes('[WARN]')) line.classList.add('warn'); |
||||
|
if (text.includes('[ERROR]')) line.classList.add('error'); |
||||
|
if (text.includes('Traceback') || text.includes('Exception')) line.classList.add('error'); |
||||
|
|
||||
|
line.textContent = text; |
||||
|
terminalOutput.appendChild(line); |
||||
|
terminalOutput.scrollTop = terminalOutput.scrollHeight; |
||||
|
} |
||||
|
|
||||
|
// Utility: Post JSON and stream response
|
||||
|
async function startStream(url, payload) { |
||||
|
try { |
||||
|
const response = await fetch(url, { |
||||
|
method: 'POST', |
||||
|
headers: { 'Content-Type': 'application/json' }, |
||||
|
body: JSON.stringify(payload) |
||||
|
}); |
||||
|
|
||||
|
const reader = response.body.getReader(); |
||||
|
const decoder = new TextDecoder('utf-8'); |
||||
|
let buffer = ''; |
||||
|
|
||||
|
while (true) { |
||||
|
const { done, value } = await reader.read(); |
||||
|
if (done) break; |
||||
|
|
||||
|
buffer += decoder.decode(value, { stream: true }); |
||||
|
let boundary = buffer.indexOf('\n\n'); |
||||
|
|
||||
|
while (boundary !== -1) { |
||||
|
const chunk = buffer.substring(0, boundary); |
||||
|
buffer = buffer.substring(boundary + 2); |
||||
|
|
||||
|
if (chunk.startsWith('data: ')) { |
||||
|
const data = chunk.substring(6); |
||||
|
|
||||
|
// Check for completion marker
|
||||
|
if (data.startsWith('[PROCESS_COMPLETED]')) { |
||||
|
appendLog(`> Simulation Finished. ${data}`, 'info'); |
||||
|
setLoadingState(false); |
||||
|
} else { |
||||
|
// Only append if it's not a carriage return override (like tqdm progress bars)
|
||||
|
// We do a simple replacement to simulate carriage returns in HTML
|
||||
|
if (data.includes('\r')) { |
||||
|
const parts = data.split('\r'); |
||||
|
const lastPart = parts[parts.length - 1]; |
||||
|
// Replace the content of the very last line if it exists
|
||||
|
const lastLine = terminalOutput.lastElementChild; |
||||
|
if (lastLine && !lastLine.textContent.includes('\n')) { |
||||
|
lastLine.textContent = lastPart; |
||||
|
continue; |
||||
|
} |
||||
|
} |
||||
|
appendLog(data); |
||||
|
} |
||||
|
} |
||||
|
boundary = buffer.indexOf('\n\n'); |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
appendLog(`> Connection error: ${error.message}`, 'error'); |
||||
|
setLoadingState(false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function setLoadingState(isLoading) { |
||||
|
launchBtn.disabled = isLoading; |
||||
|
launchText.style.display = isLoading ? 'none' : 'block'; |
||||
|
launchSpinner.style.display = isLoading ? 'block' : 'none'; |
||||
|
|
||||
|
if (!isLoading) { |
||||
|
launchText.textContent = "Launch Another Simulation"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Utility: Fetch and render dynamic scenario parameters
|
||||
|
function loadScenarioParams(scenarioName) { |
||||
|
const container = document.getElementById('dynamic-params-container'); |
||||
|
container.innerHTML = '<div class="input-group full-width"><span class="helper">Loading parameters...</span></div>'; |
||||
|
|
||||
|
fetch(`/api/scenario_params/${scenarioName}`) |
||||
|
.then(res => res.json()) |
||||
|
.then(data => { |
||||
|
container.innerHTML = ''; |
||||
|
if (data.success && Object.keys(data.params).length > 0) { |
||||
|
Object.entries(data.params).forEach(([key, val]) => { |
||||
|
const inputHtml = `
|
||||
|
<div class="input-group"> |
||||
|
<label for="param_${key}">${key}</label> |
||||
|
<div class="input-wrapper"> |
||||
|
<input type="text" id="param_${key}" name="${key}" value="${val}"> |
||||
|
</div> |
||||
|
</div> |
||||
|
`;
|
||||
|
container.insertAdjacentHTML('beforeend', inputHtml); |
||||
|
}); |
||||
|
} else { |
||||
|
container.innerHTML = '<div class="input-group full-width"><span class="helper">No tunable constants found for this scenario.</span></div>'; |
||||
|
} |
||||
|
}) |
||||
|
.catch(err => { |
||||
|
container.innerHTML = '<div class="input-group full-width"><span class="helper" style="color:var(--danger)">Failed to load parameters.</span></div>'; |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
@ -0,0 +1,479 @@ |
|||||
|
:root { |
||||
|
--bg-color: #0d1117; |
||||
|
--panel-bg: rgba(22, 27, 34, 0.65); |
||||
|
--panel-border: rgba(255, 255, 255, 0.08); |
||||
|
--text-primary: #e6edf3; |
||||
|
--text-muted: #8b949e; |
||||
|
--accent-color: #58a6ff; |
||||
|
--accent-hover: #3182ce; |
||||
|
--input-bg: rgba(1, 4, 9, 0.6); |
||||
|
--input-border: rgba(255, 255, 255, 0.1); |
||||
|
--terminal-bg: #010409; |
||||
|
--terminal-text: #e6edf3; |
||||
|
--success: #2ea043; |
||||
|
--warning: #d29922; |
||||
|
--danger: #f85149; |
||||
|
--glass-blur: blur(16px); |
||||
|
} |
||||
|
|
||||
|
* { |
||||
|
box-sizing: border-box; |
||||
|
margin: 0; |
||||
|
padding: 0; |
||||
|
} |
||||
|
|
||||
|
body { |
||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; |
||||
|
background-color: var(--bg-color); |
||||
|
color: var(--text-primary); |
||||
|
min-height: 100vh; |
||||
|
display: flex; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
/* Dynamic Background */ |
||||
|
.app-background { |
||||
|
position: fixed; |
||||
|
top: 0; left: 0; width: 100%; height: 100%; |
||||
|
background: radial-gradient(circle at 15% 50%, rgba(88, 166, 255, 0.12), transparent 40%), |
||||
|
radial-gradient(circle at 85% 30%, rgba(46, 160, 67, 0.08), transparent 40%); |
||||
|
z-index: -1; |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
.app-container { |
||||
|
display: flex; |
||||
|
width: 100%; |
||||
|
max-width: 1600px; |
||||
|
margin: 0 auto; |
||||
|
padding: 1.5rem; |
||||
|
gap: 1.5rem; |
||||
|
height: 100vh; |
||||
|
} |
||||
|
|
||||
|
/* Glass Panels */ |
||||
|
.glass-panel { |
||||
|
background: var(--panel-bg); |
||||
|
backdrop-filter: var(--glass-blur); |
||||
|
-webkit-backdrop-filter: var(--glass-blur); |
||||
|
border: 1px solid var(--panel-border); |
||||
|
border-radius: 16px; |
||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); |
||||
|
} |
||||
|
|
||||
|
/* Sidebar */ |
||||
|
.sidebar { |
||||
|
width: 320px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
padding: 1.5rem; |
||||
|
overflow-y: auto; |
||||
|
} |
||||
|
|
||||
|
.brand { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 1rem; |
||||
|
margin-bottom: 2rem; |
||||
|
padding-bottom: 1.5rem; |
||||
|
border-bottom: 1px solid var(--panel-border); |
||||
|
} |
||||
|
|
||||
|
.brand-icon { |
||||
|
width: 40px; |
||||
|
height: 40px; |
||||
|
background: linear-gradient(135deg, var(--accent-color), #8a2be2); |
||||
|
border-radius: 10px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
color: white; |
||||
|
box-shadow: 0 4px 15px rgba(88, 166, 255, 0.4); |
||||
|
} |
||||
|
|
||||
|
.brand h1 { |
||||
|
font-size: 1.25rem; |
||||
|
font-weight: 700; |
||||
|
letter-spacing: -0.5px; |
||||
|
} |
||||
|
|
||||
|
.brand .subtitle { |
||||
|
font-size: 0.8rem; |
||||
|
color: var(--text-muted); |
||||
|
} |
||||
|
|
||||
|
.section-title { |
||||
|
font-size: 0.75rem; |
||||
|
font-weight: 600; |
||||
|
color: var(--text-muted); |
||||
|
letter-spacing: 1px; |
||||
|
margin-bottom: 1rem; |
||||
|
} |
||||
|
|
||||
|
/* Scenario List (Radio Cards) */ |
||||
|
.scenario-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.scenario-card { |
||||
|
position: relative; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.scenario-card input[type="radio"] { |
||||
|
position: absolute; |
||||
|
opacity: 0; |
||||
|
} |
||||
|
|
||||
|
.scenario-card .card-content { |
||||
|
padding: 1rem; |
||||
|
background: var(--input-bg); |
||||
|
border: 1px solid var(--input-border); |
||||
|
border-radius: 10px; |
||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.scenario-card .card-content::before { |
||||
|
content: ''; |
||||
|
width: 12px; |
||||
|
height: 12px; |
||||
|
border-radius: 50%; |
||||
|
border: 2px solid var(--text-muted); |
||||
|
transition: all 0.2s; |
||||
|
} |
||||
|
|
||||
|
.scenario-card:hover .card-content { |
||||
|
border-color: rgba(88, 166, 255, 0.5); |
||||
|
transform: translateY(-2px); |
||||
|
} |
||||
|
|
||||
|
.scenario-card input[type="radio"]:checked + .card-content { |
||||
|
background: rgba(88, 166, 255, 0.1); |
||||
|
border-color: var(--accent-color); |
||||
|
} |
||||
|
|
||||
|
.scenario-card input[type="radio"]:checked + .card-content::before { |
||||
|
border-color: var(--accent-color); |
||||
|
background-color: var(--accent-color); |
||||
|
box-shadow: 0 0 0 2px var(--input-bg), 0 0 0 4px var(--accent-color); |
||||
|
} |
||||
|
|
||||
|
.card-title { |
||||
|
font-weight: 500; |
||||
|
font-size: 0.95rem; |
||||
|
text-transform: capitalize; |
||||
|
} |
||||
|
|
||||
|
/* Main Content */ |
||||
|
.main-content { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
/* Controls Panel */ |
||||
|
.controls { |
||||
|
padding: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.panel-header { |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.panel-header h2 { |
||||
|
font-size: 1.15rem; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.form-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr 1fr; |
||||
|
gap: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.full-width { |
||||
|
grid-column: 1 / -1; |
||||
|
} |
||||
|
|
||||
|
/* Form Inputs */ |
||||
|
.input-group label { |
||||
|
display: block; |
||||
|
font-size: 0.85rem; |
||||
|
font-weight: 500; |
||||
|
margin-bottom: 0.5rem; |
||||
|
color: var(--text-muted); |
||||
|
} |
||||
|
|
||||
|
.input-wrapper, .select-wrapper { |
||||
|
position: relative; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.input-wrapper::after { |
||||
|
content: ''; |
||||
|
position: absolute; |
||||
|
bottom: 0; left: 0; right: 0; |
||||
|
height: 2px; |
||||
|
background: var(--accent-color); |
||||
|
transform: scaleX(0); |
||||
|
transform-origin: left; |
||||
|
transition: transform 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.input-wrapper:focus-within::after { |
||||
|
transform: scaleX(1); |
||||
|
} |
||||
|
|
||||
|
input[type="text"], |
||||
|
input[type="number"], |
||||
|
select { |
||||
|
width: 100%; |
||||
|
padding: 0.875rem 1rem; |
||||
|
background: var(--input-bg); |
||||
|
border: 1px solid var(--input-border); |
||||
|
border-radius: 8px; |
||||
|
color: var(--text-primary); |
||||
|
font-family: 'Inter', sans-serif; |
||||
|
font-size: 0.95rem; |
||||
|
outline: none; |
||||
|
transition: border-color 0.2s; |
||||
|
} |
||||
|
|
||||
|
input:focus, select:focus { |
||||
|
border-color: var(--text-muted); |
||||
|
} |
||||
|
|
||||
|
select { |
||||
|
appearance: none; |
||||
|
-webkit-appearance: none; |
||||
|
} |
||||
|
|
||||
|
.select-wrapper::before { |
||||
|
content: '▼'; |
||||
|
position: absolute; |
||||
|
right: 1rem; |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%); |
||||
|
font-size: 0.7rem; |
||||
|
color: var(--text-muted); |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
/* Toggles */ |
||||
|
.toggle-group { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
background: var(--input-bg); |
||||
|
border: 1px solid var(--input-border); |
||||
|
padding: 1rem; |
||||
|
border-radius: 8px; |
||||
|
} |
||||
|
|
||||
|
.toggle-group label { |
||||
|
margin-bottom: 0; |
||||
|
color: var(--text-primary); |
||||
|
} |
||||
|
|
||||
|
.toggle-text .helper { |
||||
|
display: block; |
||||
|
font-size: 0.75rem; |
||||
|
color: var(--text-muted); |
||||
|
margin-top: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.switch { |
||||
|
position: relative; |
||||
|
display: inline-block; |
||||
|
width: 44px; |
||||
|
height: 24px; |
||||
|
} |
||||
|
|
||||
|
.switch input { |
||||
|
opacity: 0; |
||||
|
width: 0; |
||||
|
height: 0; |
||||
|
} |
||||
|
|
||||
|
.slider { |
||||
|
position: absolute; |
||||
|
cursor: pointer; |
||||
|
top: 0; left: 0; right: 0; bottom: 0; |
||||
|
background-color: rgba(255, 255, 255, 0.1); |
||||
|
transition: .4s; |
||||
|
} |
||||
|
|
||||
|
.slider:before { |
||||
|
position: absolute; |
||||
|
content: ""; |
||||
|
height: 18px; |
||||
|
width: 18px; |
||||
|
left: 3px; |
||||
|
bottom: 3px; |
||||
|
background-color: var(--text-muted); |
||||
|
transition: .4s; |
||||
|
} |
||||
|
|
||||
|
input:checked + .slider { |
||||
|
background-color: var(--accent-color); |
||||
|
} |
||||
|
|
||||
|
input:checked + .slider:before { |
||||
|
background-color: white; |
||||
|
transform: translateX(20px); |
||||
|
} |
||||
|
|
||||
|
.slider.round { border-radius: 24px; } |
||||
|
.slider.round:before { border-radius: 50%; } |
||||
|
|
||||
|
/* Buttons */ |
||||
|
.btn { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
gap: 0.5rem; |
||||
|
padding: 1rem 2rem; |
||||
|
font-size: 1rem; |
||||
|
font-weight: 600; |
||||
|
border: none; |
||||
|
border-radius: 8px; |
||||
|
cursor: pointer; |
||||
|
font-family: inherit; |
||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); |
||||
|
} |
||||
|
|
||||
|
.btn-primary { |
||||
|
background: linear-gradient(135deg, #1f6feb, #238636); |
||||
|
background-size: 200% auto; |
||||
|
color: white; |
||||
|
box-shadow: 0 4px 14px rgba(31, 111, 235, 0.3); |
||||
|
} |
||||
|
|
||||
|
.btn-primary:hover { |
||||
|
box-shadow: 0 6px 20px rgba(31, 111, 235, 0.5); |
||||
|
background-position: right center; |
||||
|
transform: translateY(-2px); |
||||
|
} |
||||
|
|
||||
|
.btn-primary:active { |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
|
||||
|
.btn-primary:disabled { |
||||
|
background: #21262d; |
||||
|
color: #484f58; |
||||
|
box-shadow: none; |
||||
|
cursor: not-allowed; |
||||
|
transform: none; |
||||
|
} |
||||
|
|
||||
|
/* Terminal */ |
||||
|
.terminal { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
background: var(--terminal-bg); |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.terminal-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
padding: 0.75rem 1rem; |
||||
|
background: rgba(255, 255, 255, 0.05); |
||||
|
border-bottom: 1px solid var(--input-border); |
||||
|
} |
||||
|
|
||||
|
.window-controls { |
||||
|
display: flex; |
||||
|
gap: 6px; |
||||
|
} |
||||
|
|
||||
|
.dot { |
||||
|
width: 12px; |
||||
|
height: 12px; |
||||
|
border-radius: 50%; |
||||
|
} |
||||
|
|
||||
|
.red { background: #ff5f56; } |
||||
|
.yellow { background: #ffbd2e; } |
||||
|
.green { background: #27c93f; } |
||||
|
|
||||
|
.terminal-title { |
||||
|
font-size: 0.8rem; |
||||
|
font-weight: 500; |
||||
|
color: var(--text-muted); |
||||
|
} |
||||
|
|
||||
|
.icon-btn { |
||||
|
background: transparent; |
||||
|
border: none; |
||||
|
color: var(--text-muted); |
||||
|
cursor: pointer; |
||||
|
transition: color 0.2s; |
||||
|
} |
||||
|
|
||||
|
.icon-btn:hover { |
||||
|
color: var(--text-primary); |
||||
|
} |
||||
|
|
||||
|
.terminal-body { |
||||
|
flex: 1; |
||||
|
padding: 1rem; |
||||
|
overflow-y: auto; |
||||
|
font-family: 'JetBrains Mono', Consolas, monospace; |
||||
|
font-size: 0.85rem; |
||||
|
line-height: 1.5; |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-all; |
||||
|
} |
||||
|
|
||||
|
.log-line { |
||||
|
margin-bottom: 0.2rem; |
||||
|
animation: fadeIn 0.1s ease-in; |
||||
|
} |
||||
|
|
||||
|
.log-line.system { color: var(--text-muted); } |
||||
|
.log-line.info { color: var(--accent-color); } |
||||
|
.log-line.warn { color: var(--warning); } |
||||
|
.log-line.error { color: var(--danger); font-weight: bold; } |
||||
|
|
||||
|
/* Loader Spinner */ |
||||
|
.loader-spinner { |
||||
|
border: 3px solid rgba(255,255,255,0.3); |
||||
|
border-radius: 50%; |
||||
|
border-top: 3px solid white; |
||||
|
width: 20px; |
||||
|
height: 20px; |
||||
|
animation: spin 1s linear infinite; |
||||
|
} |
||||
|
|
||||
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } } |
||||
|
|
||||
|
/* Initial Animations */ |
||||
|
.pop-in { |
||||
|
animation: popIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; |
||||
|
} |
||||
|
|
||||
|
.slide-up { |
||||
|
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; |
||||
|
} |
||||
|
|
||||
|
@keyframes popIn { |
||||
|
0% { opacity: 0; transform: scale(0.98); } |
||||
|
100% { opacity: 1; transform: scale(1); } |
||||
|
} |
||||
|
|
||||
|
@keyframes slideUp { |
||||
|
0% { opacity: 0; transform: translateY(20px); } |
||||
|
100% { opacity: 1; transform: translateY(0); } |
||||
|
} |
||||
@ -0,0 +1,117 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>BATL CARLA Orchestrator</title> |
||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> |
||||
|
<link rel="stylesheet" href="/static/style.css"> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="app-background"></div> |
||||
|
<div class="app-container"> |
||||
|
|
||||
|
<!-- Sidebar / Scenario Selection --> |
||||
|
<aside class="sidebar glass-panel"> |
||||
|
<div class="brand"> |
||||
|
<div class="brand-icon"> |
||||
|
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg> |
||||
|
</div> |
||||
|
<div> |
||||
|
<h1>BATL CARLA</h1> |
||||
|
<span class="subtitle">Orchestrator v1.0</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="nav-section"> |
||||
|
<div class="section-title">SCENARIOS</div> |
||||
|
<div id="scenario-list" class="scenario-list"> |
||||
|
<!-- Populated dynamically via JS --> |
||||
|
</div> |
||||
|
</div> |
||||
|
</aside> |
||||
|
|
||||
|
<!-- Main Workspace --> |
||||
|
<main class="main-content"> |
||||
|
|
||||
|
<!-- Parameters Configuration --> |
||||
|
<section class="controls glass-panel pop-in"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>Configuration parameters</h2> |
||||
|
</div> |
||||
|
|
||||
|
<form id="launch-form" class="form-grid"> |
||||
|
|
||||
|
<div class="input-group"> |
||||
|
<label for="frames">Simulation Duration (Frames)</label> |
||||
|
<div class="input-wrapper"> |
||||
|
<input type="number" id="frames" name="frames" placeholder="e.g. 200"> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="input-group"> |
||||
|
<label for="weather">Weather Preset</label> |
||||
|
<div class="select-wrapper"> |
||||
|
<select id="weather" name="weather"> |
||||
|
<!-- Populated dynamically --> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="input-group toggle-group"> |
||||
|
<div class="toggle-text"> |
||||
|
<label for="no_record">Dry Run Mode</label> |
||||
|
<span class="helper">Disable multi-sensor data recording to disk</span> |
||||
|
</div> |
||||
|
<label class="switch"> |
||||
|
<input type="checkbox" id="no_record" name="no_record"> |
||||
|
<span class="slider round"></span> |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div class="full-width"> |
||||
|
<div class="section-title" style="margin-top: 1rem;">DYNAMIC SCENARIO PARAMETERS</div> |
||||
|
<div id="dynamic-params-container" class="form-grid"> |
||||
|
<!-- Populated dynamically when a scenario is selected --> |
||||
|
<div class="input-group full-width"> |
||||
|
<span class="helper">Select a scenario to view its tunable parameters.</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="form-actions full-width"> |
||||
|
<button type="submit" id="launch-btn" class="btn btn-primary pulse-hover"> |
||||
|
<span class="btn-text" id="launch-text">Launch Simulation</span> |
||||
|
<div class="loader-spinner" id="launch-spinner" style="display: none;"></div> |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</section> |
||||
|
|
||||
|
<!-- Live Terminal Pane --> |
||||
|
<section class="terminal glass-panel slide-up"> |
||||
|
<div class="terminal-header"> |
||||
|
<div class="window-controls"> |
||||
|
<span class="dot red"></span> |
||||
|
<span class="dot yellow"></span> |
||||
|
<span class="dot green"></span> |
||||
|
</div> |
||||
|
<span class="terminal-title">carla312 — Simulation Standard Output</span> |
||||
|
<div class="terminal-actions"> |
||||
|
<button id="clear-terminal" class="icon-btn" title="Clear stdout"> |
||||
|
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="terminal-body" id="terminal-output"> |
||||
|
<div class="log-line system">> Application initialized. Fetching presets...</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
</main> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Application Logic --> |
||||
|
<script src="/static/app.js"></script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,68 @@ |
|||||
|
# BATL CARLA Dashboard Architecture |
||||
|
|
||||
|
This document outlines the architecture, data flow, and inner workings of the BATL CARLA Dashboard (GUI Orchestrator). It is designed to be easily digestible for both human maintainers and AI assistants iterating on the codebase. |
||||
|
|
||||
|
## 1. High-Level Overview |
||||
|
|
||||
|
The BATL CARLA Dashboard is a web-based GUI that replaces manual CLI interactions. It allows users to: |
||||
|
1. Select scenarios intuitively. |
||||
|
2. Override configuration parameters (frames, weather, etc.). |
||||
|
3. Spawn the simulation as a background process. |
||||
|
4. Stream real-time standard output (stdout) directly from the simulation to the browser. |
||||
|
|
||||
|
The system is split into three main layers: |
||||
|
- **Frontend (Browser):** HTML/CSS/JavaScript located in `dashboard/templates` and `dashboard/static`. |
||||
|
- **Backend (Web Server):** A Flask application defined in `dashboard/app.py`. |
||||
|
- **Execution Engine (CLI Pipeline):** The pre-existing `run.bat` wrapper and `src/main.py` Python script. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. Component Breakdown |
||||
|
|
||||
|
### A. The Launcher (`dashboard.bat`) |
||||
|
This simple batch script is the entry point for the user. It activates the appropriate Python environment and executes `dashboard/app.py`, launching the Flask development server on port 5000. |
||||
|
|
||||
|
### B. The Web Server (`dashboard/app.py`) |
||||
|
Flask acts as the bridge between the user's browser HTTP requests and the host operating system's command line. |
||||
|
- **API Endpoints:** It serves `/api/config` and `/api/scenario_params/<name>` to dynamically inform the UI about available scenarios, config limits, and tunable parameters. |
||||
|
- **Process Orchestration:** When a user clicks "Run" on the UI, the frontend sends a `POST` request to `/api/run` containing the selected options. Flask parses this JSON payload and heavily formats a CLI command array. |
||||
|
|
||||
|
### C. The Frontend (`dashboard/static/app.js`) |
||||
|
The JavaScript logic handles state management. It: |
||||
|
1. Fetches scenarios on load and populates dropdowns. |
||||
|
2. Intercepts the "Run" form submission to prevent page reloads. |
||||
|
3. Opens a standard **Server-Sent Events (SSE)** connection via the built-in `EventSource` API to receive real-time text streams from Flask. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. How the Dashboard Interacts with `run.bat` |
||||
|
|
||||
|
The most critical interface in this architecture is how Flask triggers and monitors the underlying CARLA simulation. |
||||
|
|
||||
|
When the `/api/run` endpoint is hit: |
||||
|
|
||||
|
1. **Command Construction:** |
||||
|
Flask builds the command array. A payload of `{"scenario": "showcase", "frames": 50}` translates to the equivalent of typing: |
||||
|
`cmd.exe /c run.bat showcase --frames 50` |
||||
|
|
||||
|
2. **Subprocess Spawning:** |
||||
|
Flask leverages Python's `subprocess.Popen` to spawn a detached child process running the constructed command. |
||||
|
- `cwd=PROJECT_ROOT`: Ensures `run.bat` is executed contextually from the root workspace so relative imports inside `src/main.py` do not break. |
||||
|
- `stdout=subprocess.PIPE` / `stderr=subprocess.STDOUT`: Captures both standard and error console outputs, merging them into a single pipe that Flask can read dynamically. |
||||
|
|
||||
|
3. **Stream Unbuffering (Crucial Step):** |
||||
|
By default, Python buffers stdout when piping it to another program, causing massive delays (the GUI appears to "freeze"). |
||||
|
To fix this, Flask injects `env["PYTHONUNBUFFERED"] = "1"` into the sub-environment, and `run.bat` also calls `set PYTHONUNBUFFERED=1`. This forces the downstream Python simulation (`src/main.py` and its dependencies) to emit logs instantly, allowing real-time progress bar renders in the GUI console. |
||||
|
|
||||
|
4. **Event Streaming (Yield):** |
||||
|
Instead of waiting for the process to return an exit code, Flask uses a specific Python generator (`def generate():`) paired with a standard `Response(mimetype="text/event-stream")`. |
||||
|
As `process.stdout.readline()` pulls new text from the unbuffered stream, Flask yields it to the browser wrapped in `data: ... \n\n` syntax. The JS EventSource catches these fragments and appends them to the `<div id="console">` log box. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. Extension Guidelines for AI |
||||
|
|
||||
|
When asked to extend the Dashboard, observe the following patterns: |
||||
|
- **Adding new CLI flags:** Update the `POST /api/run` payload parser in `app.py`, extend the `cmd.extend()` builder, and add the corresponding HTML `<input>` in `index.html`. |
||||
|
- **Adding new data streams:** The SSE generator currently pipes raw text. To parse structured data (like active ego speed) for live graphs, consider formatting the intermediate Python prints as JSON and having `app.js` parse the `event.data` payload conditionally. |
||||
|
- **Pathing:** Remember that `app.py` lives in a sub-folder. Operations manipulating the CARLA simulation *must* be rooted up two levels using the `PROJECT_ROOT` pointer established in the file. |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue