Feature: F10 Web Dashboard
Branch: 010-web-dashboard
Prerequisites: Rust 1.87+, Nexus codebase cloned
This guide helps developers implement and test the embedded web dashboard. The dashboard displays backend health, model availability, and request history with real-time WebSocket updates.
git checkout -b 010-web-dashboardFor CSS preprocessing (one-time setup):
# Install Tailwind CSS standalone CLI (optional, for CSS changes)
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
mv tailwindcss-linux-x64 /usr/local/bin/tailwindcssNote: Tailwind CSS is precompiled and committed. No build step required for most developers.
mkdir -p dashboardnexus/
├── src/
│ ├── api/mod.rs # Add dashboard routes
│ ├── dashboard/ # NEW module
│ │ ├── mod.rs
│ │ ├── handler.rs # HTTP handlers
│ │ ├── websocket.rs # WebSocket logic
│ │ ├── history.rs # Ring buffer
│ │ └── types.rs # Data types
│ └── lib.rs # Register module
├── dashboard/ # Static assets (embedded)
│ ├── index.html
│ ├── dashboard.js
│ ├── styles.css
│ └── favicon.ico
└── tests/
└── contract/dashboard_websocket_test.rs
-
Contract Tests (
tests/contract/dashboard_websocket_test.rs):cargo test --test dashboard_websocket_test # Expected: FAIL (no types exist yet)
-
Integration Tests (
tests/integration/dashboard_test.rs):cargo test --test dashboard_test # Expected: FAIL (no handlers exist yet)
-
Unit Tests (
src/dashboard/history.rswith#[cfg(test)]):cargo test dashboard::history::tests # Expected: FAIL (no module exists yet)
# Create module structure
mkdir -p src/dashboard
touch src/dashboard/{mod.rs,handler.rs,websocket.rs,history.rs,types.rs}src/dashboard/mod.rs:
pub mod handler;
pub mod websocket;
pub mod history;
pub mod types;
pub use handler::{dashboard_handler, assets_handler};
pub use websocket::websocket_handler;
pub use history::RequestHistory;
pub use types::{HistoryEntry, RequestStatus, WebSocketUpdate, UpdateType};Register in src/lib.rs:
pub mod dashboard;use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub timestamp: DateTime<Utc>,
pub model: String,
pub backend_id: String,
pub duration_ms: u64,
pub status: RequestStatus,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RequestStatus {
Success,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSocketUpdate {
pub update_type: UpdateType,
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateType {
BackendStatus,
RequestComplete,
ModelChange,
}Run tests:
cargo test dashboard::types::tests
# Expected: PASS (if tests exist)See data-model.md for full implementation.
cargo test dashboard::history::tests
# Expected: PASSAdd new fields:
use tokio::sync::broadcast;
use crate::dashboard::RequestHistory;
pub struct AppState {
// ... existing fields
pub request_history: Arc<RequestHistory>,
pub ws_broadcast: broadcast::Sender<WebSocketUpdate>,
}Update AppState::new():
let request_history = RequestHistory::new();
let (ws_broadcast, _) = broadcast::channel(1000);use axum::{
extract::State,
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
};
use rust_embed::RustEmbed;
use std::sync::Arc;
#[derive(RustEmbed)]
#[folder = "dashboard/"]
struct DashboardAssets;
pub async fn dashboard_handler(State(state): State<Arc<AppState>>) -> Response {
match DashboardAssets::get("index.html") {
Some(content) => {
let html = String::from_utf8_lossy(&content.data);
Html(html.to_string()).into_response()
}
None => (StatusCode::NOT_FOUND, "Dashboard not found").into_response(),
}
}
pub async fn assets_handler(
axum::extract::Path(path): axum::extract::Path<String>,
) -> Response {
match DashboardAssets::get(&path) {
Some(content) => {
let mime = mime_guess::from_path(&path).first_or_octet_stream();
(
StatusCode::OK,
[(header::CONTENT_TYPE, mime.as_ref())],
content.data,
).into_response()
}
None => (StatusCode::NOT_FOUND, "Asset not found").into_response(),
}
}use axum::extract::ws::{WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::Response;
use std::sync::Arc;
pub async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> Response {
ws.on_upgrade(|socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>) {
let mut rx = state.ws_broadcast.subscribe();
let (mut sender, mut receiver) = socket.split();
// Send updates to client
tokio::spawn(async move {
while let Ok(update) = rx.recv().await {
let json = serde_json::to_string(&update).unwrap();
if sender.send(Message::Text(json)).await.is_err() {
break;
}
}
});
// Handle incoming messages (ping/pong only)
while let Some(Ok(msg)) = receiver.next().await {
if msg.is_close() {
break;
}
}
}use crate::dashboard::{dashboard_handler, assets_handler, websocket_handler};
pub fn create_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(dashboard_handler))
.route("/assets/*path", get(assets_handler))
.route("/ws", get(websocket_handler))
// ... existing routes
.with_state(state)
}dashboard/index.html (minimal version):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Dashboard</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<div id="app">
<h1>Nexus Dashboard</h1>
<div id="backend-status"></div>
<div id="request-history"></div>
</div>
<script src="/assets/dashboard.js"></script>
</body>
</html>dashboard/dashboard.js (minimal version):
let ws;
function connectWebSocket() {
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
handleUpdate(update);
};
ws.onerror = () => {
console.log('WebSocket error, falling back to polling');
startPolling();
};
}
function handleUpdate(update) {
console.log('Received update:', update);
// TODO: Update DOM based on update.update_type
}
connectWebSocket();dashboard/styles.css (precompiled Tailwind - placeholder):
/* Tailwind CSS output will be here */
body {
font-family: system-ui, sans-serif;
}# All tests
cargo test
# Specific test suites
cargo test dashboard
cargo test --test dashboard_websocket_test
cargo test --test dashboard_testExpected: All tests PASS ✓
cargo run -- serveOpen browser: http://localhost:8000/ (or your configured port)
Dashboard Features:
- System Summary: Shows uptime, total requests, active backends, and available models
- Backend Status: Real-time health indicators for each backend with metrics (pending requests, latency)
- Model Availability Matrix: Grid showing which models are available on which backends with capabilities (vision, tools, JSON mode)
- Request History: Last 100 requests with timestamps, models, backends, durations, and error details (click error rows to expand)
- Connection Status: Indicator showing WebSocket connection status (connected/polling/disconnected)
Open browser DevTools → Network → WS tab → Verify WebSocket connection to /ws
You should see:
- Initial connection message
- Periodic backend status updates (every 5 seconds)
- Model change updates when backends are added/removed
- Request complete updates when requests finish
In another terminal:
# Trigger backend health check (will send backend_status update)
curl http://localhost:8000/v1/models
# Send a request (will send request_complete update)
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"llama3:70b","messages":[{"role":"user","content":"hello"}]}'Verify dashboard updates in real-time.
Disable JavaScript in browser settings → Reload dashboard → Verify:
- Yellow banner appears at top: "JavaScript is disabled"
- Page auto-refreshes every 5 seconds
- "Refresh Now" button works
- Initial data embedded in page loads correctly
Open DevTools → Toggle device toolbar → Select mobile device → Verify:
- Backend cards stack vertically on mobile (320px, 375px widths)
- Model matrix scrolls horizontally
- Request history table remains readable
- Touch targets are at least 44x44px
- Font size is 16px minimum (prevents iOS zoom)
- Dark mode works based on system preference
- Kill Nexus server while dashboard is open
- Observe connection status changes to "Disconnected"
- Restart Nexus
- Verify dashboard reconnects within 3-60 seconds (exponential backoff)
- If reconnection fails 5 times, verify status changes to "Polling Mode"
No Backends:
- Start Nexus without configured backends
- Verify "No backends configured" message appears
No History:
- Fresh Nexus instance with no requests
- Verify "No requests recorded yet" message appears
Long Model Names:
- Add backend with model name > 50 characters
- Verify name truncates with ellipsis, hover shows full name
Null Latency:
- Backend with no recorded latency
- Verify "N/A" displays instead of error
- Check console for errors:
ws.readyStateshould be 1 (OPEN) - Verify route registered:
GET /wsin Axum router - Check firewall: WebSocket uses same port as HTTP
- Verify
rust-embedinCargo.toml:[dependencies] rust-embed = "8.0"
- Check
dashboard/directory exists with files - Rebuild:
cargo clean && cargo build
- Verify broadcast channel created in AppState
- Check broadcast sender is called when state changes
- Verify WebSocket client subscribed to receiver
Follow Nexus coding standards:
- Run
cargo fmtbefore committing - Run
cargo clippyand fix warnings - Add doc comments to public functions
- Write tests before implementation (TDD)
After implementation:
- Run verification checklist:
.specify/templates/implementation-verification.md - Generate tasks:
speckit.taskscommand - Create PR with tests passing
- Update constitution if new patterns emerge
- Feature Spec:
specs/010-web-dashboard/spec.md - Data Model:
specs/010-web-dashboard/data-model.md - WebSocket Protocol:
specs/010-web-dashboard/contracts/websocket.md - Axum WebSocket Docs: https://docs.rs/axum/latest/axum/extract/ws/
- rust-embed Docs: https://docs.rs/rust-embed/latest/rust_embed/