1use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
7use serde::Serialize;
8use std::collections::HashMap;
9use std::net::SocketAddr;
10use std::sync::{Arc, Mutex, OnceLock};
11
12static DASHBOARD_STATE: OnceLock<Arc<Mutex<GraphState>>> = OnceLock::new();
14
15pub fn init_dashboard_state() -> Arc<Mutex<GraphState>> {
17 let state = Arc::new(Mutex::new(GraphState::default()));
18 let _ = DASHBOARD_STATE.set(state.clone());
19 state
20}
21
22pub fn dashboard_state() -> Option<Arc<Mutex<GraphState>>> {
24 DASHBOARD_STATE.get().cloned()
25}
26
27#[derive(Debug, Clone)]
29pub struct DashboardConfig {
30 pub port: u16,
31 pub open_browser: bool,
32 #[allow(dead_code)]
33 pub graph_state: Arc<Mutex<GraphState>>,
34}
35
36impl Default for DashboardConfig {
37 fn default() -> Self {
38 Self {
39 port: 9731,
40 open_browser: true,
41 graph_state: Arc::new(Mutex::new(GraphState::default())),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Default)]
48pub struct GraphState {
49 pub nodes: Vec<NodeInfo>,
50 pub edges: Vec<EdgeInfo>,
51 pub themes: HashMap<String, Vec<f32>>,
52 pub events: Vec<EventInfo>,
53 pub frame_time_ms: f32,
55 pub gpu_memory_mb: f32,
57}
58
59#[derive(Debug, Clone, Serialize)]
61pub struct NodeInfo {
62 pub id: u64,
63 pub label: String,
64 pub node_type: String,
65 pub x: f32,
66 pub y: f32,
67 pub width: f32,
68 pub height: f32,
69}
70
71#[derive(Debug, Clone, Serialize)]
73pub struct EdgeInfo {
74 pub id: u64,
75 pub source: u64,
76 pub target: u64,
77 pub label: String,
78}
79
80#[derive(Debug, Clone, Serialize)]
82pub struct EventInfo {
83 pub timestamp: String,
84 pub event_type: String,
85 pub message: String,
86}
87
88pub type AppState = Arc<Mutex<GraphState>>;
90
91pub async fn start_dashboard(config: DashboardConfig) -> Result<(), std::io::Error> {
93 let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
94
95 println!("🔧 CVKG DevTools Dashboard starting at http://{}", addr);
96
97 if config.open_browser {
98 let url = format!("http://{}", addr);
99 println!("🌐 Opening browser at {}", url);
100 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
101 }
102
103 let state = init_dashboard_state();
104
105 let app = Router::new()
106 .route("/", get(serve_dashboard_html))
107 .route("/api/graph", get(api_graph))
108 .route("/api/nodes", get(api_nodes))
109 .route("/api/edges", get(api_edges))
110 .route("/api/themes", get(api_themes))
111 .route("/api/events", get(api_events))
112 .layer(tower_http::trace::TraceLayer::new_for_http())
113 .with_state(state);
114
115 let listener = tokio::net::TcpListener::bind(addr).await?;
116 println!("✅ DevTools server listening on {}", addr);
117 axum::serve(listener, app).await
118}
119
120async fn serve_dashboard_html() -> impl IntoResponse {
122 let html = include_str!("dashboard.html");
123 (
124 StatusCode::OK,
125 [("content-type", "text/html; charset=utf-8")],
126 html,
127 )
128}
129
130async fn api_graph(State(state): State<AppState>) -> Json<GraphState> {
132 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
133 Json(guard.clone())
134}
135
136async fn api_nodes(State(state): State<AppState>) -> Json<Vec<NodeInfo>> {
138 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
139 Json(guard.nodes.clone())
140}
141
142async fn api_edges(State(state): State<AppState>) -> Json<Vec<EdgeInfo>> {
144 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
145 Json(guard.edges.clone())
146}
147
148async fn api_themes(State(state): State<AppState>) -> Json<HashMap<String, Vec<f32>>> {
150 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
151 Json(guard.themes.clone())
152}
153
154async fn api_events(State(state): State<AppState>) -> Json<Vec<EventInfo>> {
156 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
157 Json(guard.events.clone())
158}
159
160pub fn add_node(state: &Arc<Mutex<GraphState>>, node: NodeInfo) {
164 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
165 s.nodes.push(node);
166}
167
168pub fn add_edge(state: &Arc<Mutex<GraphState>>, edge: EdgeInfo) {
170 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
171 s.edges.push(edge);
172}
173
174pub fn add_event(state: &Arc<Mutex<GraphState>>, event_type: &str, message: &str) {
176 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
177 let timestamp = format!(
178 "{:.3}",
179 std::time::SystemTime::now()
180 .duration_since(std::time::UNIX_EPOCH)
181 .unwrap_or_default()
182 .as_secs_f64()
183 );
184 s.events.push(EventInfo {
185 timestamp,
186 event_type: event_type.to_string(),
187 message: message.to_string(),
188 });
189 if s.events.len() > 100 {
190 let trim = s.events.len() - 100;
191 s.events.drain(0..trim);
192 }
193}
194
195pub fn set_theme_token(state: &Arc<Mutex<GraphState>>, name: &str, rgba: [f32; 4]) {
197 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
198 s.themes.insert(name.to_string(), rgba.to_vec());
199}
200
201#[cfg(test)]
202mod dashboard_tests {
203 use super::*;
204
205 #[test]
206 fn test_dashboard_config_default() {
207 let config = DashboardConfig::default();
208 assert_eq!(config.port, 9731);
209 assert!(config.open_browser);
210 }
211
212 #[test]
213 fn test_graph_state_default() {
214 let state = GraphState::default();
215 assert!(state.nodes.is_empty());
216 assert!(state.edges.is_empty());
217 assert!(state.themes.is_empty());
218 assert!(state.events.is_empty());
219 }
220
221 #[test]
222 fn test_add_node() {
223 let state = Arc::new(Mutex::new(GraphState::default()));
224 add_node(
225 &state,
226 NodeInfo {
227 id: 1,
228 label: "Test".into(),
229 node_type: "process".into(),
230 x: 10.0,
231 y: 20.0,
232 width: 120.0,
233 height: 60.0,
234 },
235 );
236 let s = state.lock().unwrap();
237 assert_eq!(s.nodes.len(), 1);
238 assert_eq!(s.nodes[0].id, 1);
239 }
240
241 #[test]
242 fn test_add_edge() {
243 let state = Arc::new(Mutex::new(GraphState::default()));
244 add_edge(
245 &state,
246 EdgeInfo {
247 id: 1,
248 source: 1,
249 target: 2,
250 label: "flows".into(),
251 },
252 );
253 let s = state.lock().unwrap();
254 assert_eq!(s.edges.len(), 1);
255 }
256
257 #[test]
258 fn test_add_event_trims() {
259 let state = Arc::new(Mutex::new(GraphState::default()));
260 for i in 0..150 {
261 add_event(&state, "test", &format!("event {}", i));
262 }
263 let s = state.lock().unwrap();
264 assert_eq!(s.events.len(), 100);
265 }
266
267 #[test]
268 fn test_set_theme_token() {
269 let state = Arc::new(Mutex::new(GraphState::default()));
270 set_theme_token(&state, "primary", [0.5, 0.5, 1.0, 1.0]);
271 let s = state.lock().unwrap();
272 assert_eq!(s.themes.get("primary"), Some(&vec![0.5, 0.5, 1.0, 1.0]));
273 }
274}