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}
54
55#[derive(Debug, Clone, Serialize)]
57pub struct NodeInfo {
58 pub id: u64,
59 pub label: String,
60 pub node_type: String,
61 pub x: f32,
62 pub y: f32,
63 pub width: f32,
64 pub height: f32,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct EdgeInfo {
70 pub id: u64,
71 pub source: u64,
72 pub target: u64,
73 pub label: String,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct EventInfo {
79 pub timestamp: String,
80 pub event_type: String,
81 pub message: String,
82}
83
84pub type AppState = Arc<Mutex<GraphState>>;
86
87pub async fn start_dashboard(config: DashboardConfig) -> Result<(), std::io::Error> {
89 let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
90
91 println!("🔧 CVKG DevTools Dashboard starting at http://{}", addr);
92
93 if config.open_browser {
94 let url = format!("http://{}", addr);
95 println!("🌐 Opening browser at {}", url);
96 let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
97 }
98
99 let state = init_dashboard_state();
100
101 let app = Router::new()
102 .route("/", get(serve_dashboard_html))
103 .route("/api/graph", get(api_graph))
104 .route("/api/nodes", get(api_nodes))
105 .route("/api/edges", get(api_edges))
106 .route("/api/themes", get(api_themes))
107 .route("/api/events", get(api_events))
108 .layer(tower_http::trace::TraceLayer::new_for_http())
109 .with_state(state);
110
111 let listener = tokio::net::TcpListener::bind(addr).await?;
112 println!("✅ DevTools server listening on {}", addr);
113 axum::serve(listener, app).await
114}
115
116async fn serve_dashboard_html() -> impl IntoResponse {
118 let html = include_str!("dashboard.html");
119 (
120 StatusCode::OK,
121 [("content-type", "text/html; charset=utf-8")],
122 html,
123 )
124}
125
126async fn api_graph(State(state): State<AppState>) -> Json<GraphState> {
128 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
129 Json(guard.clone())
130}
131
132async fn api_nodes(State(state): State<AppState>) -> Json<Vec<NodeInfo>> {
134 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
135 Json(guard.nodes.clone())
136}
137
138async fn api_edges(State(state): State<AppState>) -> Json<Vec<EdgeInfo>> {
140 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
141 Json(guard.edges.clone())
142}
143
144async fn api_themes(State(state): State<AppState>) -> Json<HashMap<String, Vec<f32>>> {
146 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
147 Json(guard.themes.clone())
148}
149
150async fn api_events(State(state): State<AppState>) -> Json<Vec<EventInfo>> {
152 let guard = state.lock().unwrap_or_else(|e| e.into_inner());
153 Json(guard.events.clone())
154}
155
156pub fn add_node(state: &Arc<Mutex<GraphState>>, node: NodeInfo) {
160 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
161 s.nodes.push(node);
162}
163
164pub fn add_edge(state: &Arc<Mutex<GraphState>>, edge: EdgeInfo) {
166 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
167 s.edges.push(edge);
168}
169
170pub fn add_event(state: &Arc<Mutex<GraphState>>, event_type: &str, message: &str) {
172 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
173 let timestamp = format!(
174 "{:.3}",
175 std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .unwrap_or_default()
178 .as_secs_f64()
179 );
180 s.events.push(EventInfo {
181 timestamp,
182 event_type: event_type.to_string(),
183 message: message.to_string(),
184 });
185 if s.events.len() > 100 {
186 let trim = s.events.len() - 100;
187 s.events.drain(0..trim);
188 }
189}
190
191pub fn set_theme_token(state: &Arc<Mutex<GraphState>>, name: &str, rgba: [f32; 4]) {
193 let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
194 s.themes.insert(name.to_string(), rgba.to_vec());
195}
196
197#[cfg(test)]
198mod dashboard_tests {
199 use super::*;
200
201 #[test]
202 fn test_dashboard_config_default() {
203 let config = DashboardConfig::default();
204 assert_eq!(config.port, 9731);
205 assert!(config.open_browser);
206 }
207
208 #[test]
209 fn test_graph_state_default() {
210 let state = GraphState::default();
211 assert!(state.nodes.is_empty());
212 assert!(state.edges.is_empty());
213 assert!(state.themes.is_empty());
214 assert!(state.events.is_empty());
215 }
216
217 #[test]
218 fn test_add_node() {
219 let state = Arc::new(Mutex::new(GraphState::default()));
220 add_node(
221 &state,
222 NodeInfo {
223 id: 1,
224 label: "Test".into(),
225 node_type: "process".into(),
226 x: 10.0,
227 y: 20.0,
228 width: 120.0,
229 height: 60.0,
230 },
231 );
232 let s = state.lock().unwrap();
233 assert_eq!(s.nodes.len(), 1);
234 assert_eq!(s.nodes[0].id, 1);
235 }
236
237 #[test]
238 fn test_add_edge() {
239 let state = Arc::new(Mutex::new(GraphState::default()));
240 add_edge(
241 &state,
242 EdgeInfo {
243 id: 1,
244 source: 1,
245 target: 2,
246 label: "flows".into(),
247 },
248 );
249 let s = state.lock().unwrap();
250 assert_eq!(s.edges.len(), 1);
251 }
252
253 #[test]
254 fn test_add_event_trims() {
255 let state = Arc::new(Mutex::new(GraphState::default()));
256 for i in 0..150 {
257 add_event(&state, "test", &format!("event {}", i));
258 }
259 let s = state.lock().unwrap();
260 assert_eq!(s.events.len(), 100);
261 }
262
263 #[test]
264 fn test_set_theme_token() {
265 let state = Arc::new(Mutex::new(GraphState::default()));
266 set_theme_token(&state, "primary", [0.5, 0.5, 1.0, 1.0]);
267 let s = state.lock().unwrap();
268 assert_eq!(s.themes.get("primary"), Some(&vec![0.5, 0.5, 1.0, 1.0]));
269 }
270}