2026-02-09 09:07:38 +00:00
|
|
|
use std::fs;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-02-08 16:18:39 +00:00
|
|
|
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
|
|
|
|
|
use crate::dashboard_html::DASHBOARD_HTML;
|
|
|
|
|
|
2026-02-09 09:07:38 +00:00
|
|
|
// ── Trace directory scanning ──────────────────────────────────────────
|
2026-02-08 16:18:39 +00:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
2026-02-09 09:07:38 +00:00
|
|
|
struct TraceEntry {
|
|
|
|
|
file: String,
|
2026-02-08 16:18:39 +00:00
|
|
|
name: String,
|
2026-02-09 09:07:38 +00:00
|
|
|
nodes: usize,
|
|
|
|
|
events: usize,
|
2026-02-08 16:18:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 09:07:38 +00:00
|
|
|
fn scan_traces(dir: &Path) -> Vec<TraceEntry> {
|
|
|
|
|
let mut entries = Vec::new();
|
|
|
|
|
let Ok(read_dir) = fs::read_dir(dir) else {
|
|
|
|
|
return entries;
|
2026-02-08 16:18:39 +00:00
|
|
|
};
|
2026-02-09 09:07:38 +00:00
|
|
|
for entry in read_dir.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
let fname = path
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if !fname.ends_with(".trace.json") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Ok(data) = fs::read_to_string(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
// Parse as generic JSON to extract metadata without full deserialization.
|
|
|
|
|
let Ok(val) = serde_json::from_str::<serde_json::Value>(&data) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let name = val
|
|
|
|
|
.get("name")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or(&fname)
|
|
|
|
|
.to_string();
|
|
|
|
|
let nodes = val
|
|
|
|
|
.get("node_names")
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
.map(|a| a.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let events = val
|
|
|
|
|
.get("events")
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
.map(|a| a.len())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
entries.push(TraceEntry {
|
|
|
|
|
file: fname,
|
|
|
|
|
name,
|
|
|
|
|
nodes,
|
|
|
|
|
events,
|
2026-02-08 16:18:39 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-02-09 09:07:38 +00:00
|
|
|
entries.sort_by(|a, b| a.file.cmp(&b.file));
|
|
|
|
|
entries
|
2026-02-08 16:18:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 09:07:38 +00:00
|
|
|
// ── HTTP server ───────────────────────────────────────────────────────
|
2026-02-08 16:18:39 +00:00
|
|
|
|
2026-02-09 09:07:38 +00:00
|
|
|
pub fn serve_dashboard(trace_dir: &str, port: u16) {
|
|
|
|
|
let dir = PathBuf::from(trace_dir);
|
|
|
|
|
assert!(dir.is_dir(), "trace directory does not exist: {trace_dir}");
|
2026-02-08 16:18:39 +00:00
|
|
|
|
|
|
|
|
let addr = format!("0.0.0.0:{port}");
|
|
|
|
|
let server = tiny_http::Server::http(&addr).expect("failed to bind HTTP server");
|
|
|
|
|
|
2026-02-09 09:07:38 +00:00
|
|
|
eprintln!("Dashboard at http://localhost:{port}");
|
|
|
|
|
eprintln!("Serving traces from: {trace_dir}");
|
2026-02-08 16:18:39 +00:00
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
let request = match server.recv() {
|
|
|
|
|
Ok(r) => r,
|
|
|
|
|
Err(_) => break,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let url = request.url().to_string();
|
|
|
|
|
match url.as_str() {
|
|
|
|
|
"/" => {
|
2026-02-09 09:07:38 +00:00
|
|
|
let response = tiny_http::Response::from_string(DASHBOARD_HTML).with_header(
|
2026-02-08 16:18:39 +00:00
|
|
|
"Content-Type: text/html; charset=utf-8"
|
|
|
|
|
.parse::<tiny_http::Header>()
|
|
|
|
|
.unwrap(),
|
|
|
|
|
);
|
|
|
|
|
let _ = request.respond(response);
|
|
|
|
|
}
|
2026-02-09 09:07:38 +00:00
|
|
|
"/traces" => {
|
|
|
|
|
let entries = scan_traces(&dir);
|
|
|
|
|
let json = serde_json::to_string(&entries).unwrap();
|
2026-02-08 16:18:39 +00:00
|
|
|
let response = tiny_http::Response::from_string(json).with_header(
|
|
|
|
|
"Content-Type: application/json"
|
|
|
|
|
.parse::<tiny_http::Header>()
|
|
|
|
|
.unwrap(),
|
|
|
|
|
);
|
|
|
|
|
let _ = request.respond(response);
|
|
|
|
|
}
|
2026-02-09 09:07:38 +00:00
|
|
|
_ if url.starts_with("/trace.json?file=") => {
|
|
|
|
|
let raw = url.strip_prefix("/trace.json?file=").unwrap();
|
|
|
|
|
let file = percent_decode(raw);
|
|
|
|
|
|
|
|
|
|
// Reject path traversal attempts.
|
|
|
|
|
if file.contains('/')
|
|
|
|
|
|| file.contains('\\')
|
|
|
|
|
|| file.contains("..")
|
|
|
|
|
|| file.is_empty()
|
|
|
|
|
{
|
|
|
|
|
let response =
|
|
|
|
|
tiny_http::Response::from_string("Bad Request").with_status_code(400);
|
|
|
|
|
let _ = request.respond(response);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let path = dir.join(&file);
|
|
|
|
|
match fs::read_to_string(&path) {
|
|
|
|
|
Ok(data) => {
|
|
|
|
|
let response = tiny_http::Response::from_string(data).with_header(
|
|
|
|
|
"Content-Type: application/json"
|
|
|
|
|
.parse::<tiny_http::Header>()
|
|
|
|
|
.unwrap(),
|
|
|
|
|
);
|
|
|
|
|
let _ = request.respond(response);
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
let response =
|
|
|
|
|
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
|
|
|
|
let _ = request.respond(response);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 16:18:39 +00:00
|
|
|
_ => {
|
|
|
|
|
let response =
|
|
|
|
|
tiny_http::Response::from_string("Not Found").with_status_code(404);
|
|
|
|
|
let _ = request.respond(response);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 09:07:38 +00:00
|
|
|
|
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn percent_decode(s: &str) -> String {
|
|
|
|
|
let mut result = Vec::new();
|
|
|
|
|
let bytes = s.as_bytes();
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
while i < bytes.len() {
|
|
|
|
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
|
|
|
|
if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
|
|
|
|
|
result.push(h << 4 | l);
|
|
|
|
|
i += 3;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
result.push(bytes[i]);
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
String::from_utf8_lossy(&result).to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hex_val(b: u8) -> Option<u8> {
|
|
|
|
|
match b {
|
|
|
|
|
b'0'..=b'9' => Some(b - b'0'),
|
|
|
|
|
b'a'..=b'f' => Some(b - b'a' + 10),
|
|
|
|
|
b'A'..=b'F' => Some(b - b'A' + 10),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|