use axum::{
body::Body,
extract::State,
http::{header, HeaderValue, StatusCode, Uri},
response::Response,
routing::get,
Json, Router,
};
use rust_embed::RustEmbed;
use serde::Serialize;
use std::collections::BTreeMap;
use std::sync::Arc;
use xiaoo_shared::backend::{
backend_endpoint_str, backend_state_label, BackendInfo, BackendListFilter, BackendManager,
};
use xiaoo_shared::gateway::{SessionLifecycleStatus, SessionRecord, SessionStore};
#[derive(RustEmbed)]
#[folder = "static/"]
struct DashboardAssets;
#[derive(Clone)]
pub struct DashboardState {
session_store: Arc<dyn SessionStore>,
backend_manager: Arc<BackendManager>,
}
impl DashboardState {
pub fn new(session_store: Arc<dyn SessionStore>, backend_manager: Arc<BackendManager>) -> Self {
Self {
session_store,
backend_manager,
}
}
}
pub fn dashboard_router(state: DashboardState) -> Router {
Router::new()
.route("/", get(index_handler))
.route("/index.html", get(index_handler))
.route("/api/v1/dashboard/overview", get(handle_overview))
.route("/api/v1/dashboard/sessions", get(handle_sessions))
.route("/api/v1/dashboard/sandboxes", get(handle_sandboxes))
.fallback(static_handler)
.with_state(Arc::new(state))
}
async fn index_handler() -> Response {
serve_asset("index.html")
}
async fn static_handler(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let path = if path.is_empty() || path == "index.html" {
"index.html"
} else {
path
};
serve_asset(path)
}
fn serve_asset(path: &str) -> Response {
match DashboardAssets::get(path) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.expect("mime_guess output is always valid ASCII"),
)
.body(Body::from(content.data))
.expect("fresh Response builder with valid content-type")
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("404 Not Found"))
.expect("fresh Response builder with valid content-type"),
}
}
#[derive(Debug, Serialize)]
struct DashboardOverview {
sessions: SessionSummary,
sandboxes: SandboxSummary,
sessions_without_sandbox: usize,
orphan_sandboxes: usize,
server_time_ms: u64,
}
#[derive(Debug, Serialize)]
struct SessionSummary {
total: usize,
by_status: BTreeMap<String, usize>,
}
#[derive(Debug, Serialize)]
struct SandboxSummary {
total: usize,
by_provider: BTreeMap<String, usize>,
by_state: BTreeMap<String, usize>,
}
async fn handle_overview(State(state): State<Arc<DashboardState>>) -> Json<DashboardOverview> {
let sessions = state.session_store.list_all().await;
let sandboxes = state
.backend_manager
.list_backends(BackendListFilter::default())
.await;
let mut by_status: BTreeMap<String, usize> = BTreeMap::new();
for s in &sessions {
*by_status
.entry(session_status_label(s.status.clone()))
.or_default() += 1;
}
let mut by_provider: BTreeMap<String, usize> = BTreeMap::new();
let mut by_state: BTreeMap<String, usize> = BTreeMap::new();
for b in &sandboxes {
*by_provider.entry(b.provider.clone()).or_default() += 1;
*by_state
.entry(backend_state_label(b.state).to_string())
.or_default() += 1;
}
let sessions_without_sandbox = sessions
.iter()
.filter(|s| {
s.backend_instance.is_none()
&& matches!(
s.status,
SessionLifecycleStatus::Idle | SessionLifecycleStatus::Running
)
})
.count();
let orphan_sandboxes = sandboxes
.iter()
.filter(|b| b.session_ids.is_empty())
.count();
Json(DashboardOverview {
sessions: SessionSummary {
total: sessions.len(),
by_status,
},
sandboxes: SandboxSummary {
total: sandboxes.len(),
by_provider,
by_state,
},
sessions_without_sandbox,
orphan_sandboxes,
server_time_ms: current_time_ms(),
})
}
#[derive(Debug, Serialize)]
struct SessionCardDto {
session_id: String,
conversation_id: String,
sender_id: String,
channel: Option<String>,
channel_instance_id: Option<String>,
status: String,
agent_id: String,
model: String,
backend_id: Option<String>,
backend_state: Option<String>,
backend_provider: Option<String>,
backend_instance_id: Option<String>,
backend_endpoint: Option<String>,
parent_runtime_id: Option<String>,
forked_from_checkpoint_id: Option<String>,
last_error: Option<String>,
created_at_ms: u64,
updated_at_ms: u64,
}
async fn handle_sessions(State(state): State<Arc<DashboardState>>) -> Json<Vec<SessionCardDto>> {
let records = state.session_store.list_all().await;
let dtos = records.into_iter().map(map_session_to_card).collect();
Json(dtos)
}
fn map_session_to_card(record: SessionRecord) -> SessionCardDto {
let backend = record.backend_instance.as_ref();
SessionCardDto {
session_id: record.session_id,
conversation_id: record.conversation_id,
sender_id: record.sender_id,
channel: record.channel,
channel_instance_id: record.channel_instance_id,
status: session_status_label(record.status),
agent_id: record.runtime.agent_id.0,
model: record.runtime.model,
backend_id: backend.map(|b| b.backend_id.0.clone()),
backend_state: backend.map(|b| backend_state_label(b.state).to_string()),
backend_provider: backend.map(|b| b.provider.0.clone()),
backend_instance_id: backend.map(|b| b.instance_id.0.clone()),
backend_endpoint: backend.and_then(|b| backend_endpoint_str(b.endpoint.clone())),
parent_runtime_id: record.parent_runtime_id,
forked_from_checkpoint_id: record.forked_from_checkpoint_id,
last_error: record.last_error,
created_at_ms: record.created_at_ms,
updated_at_ms: record.updated_at_ms,
}
}
async fn handle_sandboxes(State(state): State<Arc<DashboardState>>) -> Json<Vec<BackendInfo>> {
let sandboxes = state
.backend_manager
.list_backends(BackendListFilter::default())
.await;
Json(sandboxes)
}
fn session_status_label(status: SessionLifecycleStatus) -> String {
match status {
SessionLifecycleStatus::Idle => "idle".to_string(),
SessionLifecycleStatus::Running => "running".to_string(),
SessionLifecycleStatus::Paused => "paused".to_string(),
SessionLifecycleStatus::Failed => "failed".to_string(),
SessionLifecycleStatus::Closed => "closed".to_string(),
}
}
fn current_time_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
use xiaoo_api::chat::{AgentId, FeatureFlags, TokenBudgetConfig};
use xiaoo_shared::gateway::{InMemorySessionStore, SessionLifecycleStatus};
fn fake_record(session_id: &str, status: SessionLifecycleStatus) -> SessionRecord {
SessionRecord {
session_id: session_id.to_string(),
conversation_id: format!("conv-{session_id}"),
sender_id: "tester".to_string(),
entry: Default::default(),
channel: Some("http".to_string()),
channel_instance_id: None,
status,
runtime: xiaoo_shared::gateway::SessionRuntimeSnapshot {
agent_id: AgentId("main".to_string()),
model: "test-model".to_string(),
llm: None,
system_prompt: String::new(),
feature_flags: FeatureFlags::default(),
token_budget: TokenBudgetConfig {
total_budget: 0,
reserved_for_output: 0,
reserved_for_system: 0,
hard_limit_ratio: 0.0,
},
workspace_root: PathBuf::from("/tmp"),
max_turns: None,
tool_manifest: None,
subagent_roles: Default::default(),
bootstrap_binding: None,
},
backend_instance: None,
paused_backend_checkpoint: None,
loop_state: None,
memory_snapshot: None,
agents: Default::default(),
subagent_state: Default::default(),
last_error: None,
parent_runtime_id: None,
forked_from_checkpoint_id: None,
created_at_ms: 1,
updated_at_ms: 2,
}
}
fn tmp_storage_dir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"xiaoo-dashboard-test-{}-{label}-{}",
std::process::id(),
current_time_ms()
))
}
#[tokio::test]
async fn overview_counts_sessions_by_status() {
let store = InMemorySessionStore::default();
store
.save(fake_record("s1", SessionLifecycleStatus::Running))
.await;
store
.save(fake_record("s2", SessionLifecycleStatus::Running))
.await;
store
.save(fake_record("s3", SessionLifecycleStatus::Paused))
.await;
let state = DashboardState::new(
Arc::new(store) as Arc<dyn SessionStore>,
Arc::new(BackendManager::new_with_storage_dir(
Default::default(),
tmp_storage_dir("overview"),
)),
);
let Json(overview) = handle_overview(State(Arc::new(state))).await;
assert_eq!(overview.sessions.total, 3);
assert_eq!(overview.sessions.by_status.get("running"), Some(&2));
assert_eq!(overview.sessions.by_status.get("paused"), Some(&1));
assert_eq!(overview.sessions_without_sandbox, 2);
}
#[tokio::test]
async fn sessions_endpoint_returns_cards_in_updated_order() {
let store = InMemorySessionStore::default();
let mut older = fake_record("older", SessionLifecycleStatus::Idle);
older.updated_at_ms = 10;
let mut newer = fake_record("newer", SessionLifecycleStatus::Running);
newer.updated_at_ms = 100;
store.save(older).await;
store.save(newer).await;
let state = DashboardState::new(
Arc::new(store) as Arc<dyn SessionStore>,
Arc::new(BackendManager::new_with_storage_dir(
Default::default(),
tmp_storage_dir("sessions"),
)),
);
let Json(cards) = handle_sessions(State(Arc::new(state))).await;
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].session_id, "newer");
assert_eq!(cards[0].status, "running");
assert_eq!(cards[0].agent_id, "main");
assert_eq!(cards[0].model, "test-model");
}
#[tokio::test]
async fn sandboxes_endpoint_returns_empty_vec_for_fresh_manager() {
let state = DashboardState::new(
Arc::new(InMemorySessionStore::default()) as Arc<dyn SessionStore>,
Arc::new(BackendManager::new_with_storage_dir(
Default::default(),
tmp_storage_dir("sandboxes"),
)),
);
let Json(sandboxes) = handle_sandboxes(State(Arc::new(state))).await;
assert!(sandboxes.is_empty());
}
}