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    /// Last known frame time in milliseconds (from renderer telemetry).
54    pub frame_time_ms: f32,
55    /// Last known GPU memory usage in MB (from renderer telemetry).
56    pub gpu_memory_mb: f32,
57}
58
59/// Node information for the dashboard.
60#[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/// Edge information for the dashboard.
72#[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/// Event information for the dashboard.
81#[derive(Debug, Clone, Serialize)]
82pub struct EventInfo {
83    pub timestamp: String,
84    pub event_type: String,
85    pub message: String,
86}
87
88/// Axum state type alias.
89pub type AppState = Arc<Mutex<GraphState>>;
90
91/// Starts the DevTools dashboard HTTP server using axum.
92pub 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
120/// Serve the main dashboard HTML page.
121async 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
130/// API: full graph state.
131async 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
136/// API: nodes list.
137async 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
142/// API: edges list.
143async 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
148/// API: theme tokens.
149async 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
154/// API: event log.
155async 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
160// ── Public helper functions ──────────────────────────────────────────────────
161
162/// Adds a node to the shared graph state.
163pub 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
168/// Adds an edge to the shared graph state.
169pub 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
174/// Adds an event to the shared graph state.
175pub 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
195/// Updates a theme token in the shared graph state.
196pub 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}