Skip to main content

cvkg_cli/
devtools_dashboard.rs

1// ── DevTools Dashboard ──────────────────────────────────────────────────────
2//
3// HTTP API server for inspecting the CVKG graph, themes, and real-time events.
4// Uses axum for robust HTTP handling (replaces previous raw TCP implementation).
5
6use 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
12/// Global dashboard state shared between the HTTP server and file watcher.
13static DASHBOARD_STATE: OnceLock<Arc<Mutex<GraphState>>> = OnceLock::new();
14
15/// Initialize the global dashboard state. Called once when the dashboard starts.
16pub 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
22/// Get the global dashboard state for updating from the file watcher.
23pub fn dashboard_state() -> Option<Arc<Mutex<GraphState>>> {
24    DASHBOARD_STATE.get().cloned()
25}
26
27/// Configuration for the DevTools dashboard server.
28#[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/// Serializable graph state for the dashboard.
47#[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/// Node information for the dashboard.
56#[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/// Edge information for the dashboard.
68#[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/// Event information for the dashboard.
77#[derive(Debug, Clone, Serialize)]
78pub struct EventInfo {
79    pub timestamp: String,
80    pub event_type: String,
81    pub message: String,
82}
83
84/// Axum state type alias.
85pub type AppState = Arc<Mutex<GraphState>>;
86
87/// Starts the DevTools dashboard HTTP server using axum.
88pub 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
116/// Serve the main dashboard HTML page.
117async 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
126/// API: full graph state.
127async 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
132/// API: nodes list.
133async 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
138/// API: edges list.
139async 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
144/// API: theme tokens.
145async 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
150/// API: event log.
151async 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
156// ── Public helper functions ──────────────────────────────────────────────────
157
158/// Adds a node to the shared graph state.
159pub 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
164/// Adds an edge to the shared graph state.
165pub 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
170/// Adds an event to the shared graph state.
171pub 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
191/// Updates a theme token in the shared graph state.
192pub 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}