use axum::{
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Json, Response},
routing::get,
Router,
};
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, error};
use super::trace_models::{TraceData, TraceFile, TraceFileFormat};
use super::trace_parser::parse_trace_file;
#[derive(Clone)]
pub struct TraceState {
pub traces_dir: PathBuf,
}
impl TraceState {
pub fn new(traces_dir: PathBuf) -> Self {
Self { traces_dir }
}
}
pub fn trace_routes() -> Router<Arc<TraceState>> {
Router::new()
.route("/", get(serve_trace_ui))
.route("/api/traces", get(list_traces))
.route("/api/traces/:id", get(get_trace))
}
async fn serve_trace_ui() -> impl IntoResponse {
Html(include_str!("static/trace_visualizer.html"))
}
async fn list_traces(
State(state): State<Arc<TraceState>>,
) -> Result<Json<Vec<TraceFile>>, ApiError> {
debug!(
traces_dir = %state.traces_dir.display(),
"Listing trace files"
);
let mut traces = Vec::new();
let entries = std::fs::read_dir(&state.traces_dir).map_err(|e| {
error!(error = %e, "Failed to read traces directory");
ApiError::Internal(format!("Failed to read traces directory: {}", e))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
error!(error = %e, "Failed to read directory entry");
ApiError::Internal(format!("Failed to read directory entry: {}", e))
})?;
let path = entry.path();
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !filename.starts_with("session-") {
continue;
}
let extension = path.extension().and_then(|e| e.to_str());
let format = match extension {
Some("json") => TraceFileFormat::Json,
Some("jsonl") => TraceFileFormat::Jsonl,
_ => continue, };
let id = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_prefix("session-"))
.unwrap_or("")
.to_string();
if id.is_empty() {
continue;
}
let metadata = entry.metadata().map_err(|e| {
error!(error = %e, "Failed to read file metadata");
ApiError::Internal(format!("Failed to read file metadata: {}", e))
})?;
let size = metadata.len();
let modified = metadata
.modified()
.ok()
.map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime
})
.unwrap_or_else(chrono::Utc::now);
traces.push(TraceFile {
id,
filename: filename.to_string(),
format,
size,
modified,
path: path.clone(),
});
}
traces.sort_by(|a, b| b.modified.cmp(&a.modified));
debug!(count = traces.len(), "Found trace files");
Ok(Json(traces))
}
async fn get_trace(
State(state): State<Arc<TraceState>>,
Path(id): Path<String>,
) -> Result<Json<TraceData>, ApiError> {
debug!(session_id = %id, "Fetching trace");
let json_path = state.traces_dir.join(format!("session-{}.json", id));
let jsonl_path = state.traces_dir.join(format!("session-{}.jsonl", id));
let path = if json_path.exists() {
json_path
} else if jsonl_path.exists() {
jsonl_path
} else {
return Err(ApiError::NotFound(format!("Trace not found: {}", id)));
};
debug!(path = %path.display(), "Loading trace file");
let trace = parse_trace_file(&path).map_err(|e| {
error!(error = %e, "Failed to parse trace file");
ApiError::Internal(format!("Failed to parse trace: {}", e))
})?;
Ok(Json(trace))
}
#[derive(Debug)]
pub enum ApiError {
NotFound(String),
Internal(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_trace_state_creation() {
let dir = TempDir::new().unwrap();
let state = TraceState::new(dir.path().to_path_buf());
assert_eq!(state.traces_dir, dir.path());
}
}