Skip to main content

magic_dashboard/
lib.rs

1pub use magic_dashboard_macros::Dashboard;
2pub use serde_json;
3
4use axum::{
5    body::Bytes,
6    response::{Html, IntoResponse},
7    routing::get,
8    Router,
9};
10use std::net::SocketAddr;
11use std::sync::{Arc, RwLock};
12use tokio::net::TcpListener;
13
14/// The trait that will be implemented by the macro
15pub trait DashboardState: Send + Sync {
16    /// Returns the current state as a JSON string
17    fn to_json(&self) -> String;
18
19    /// Returns the metadata mapping fields to categories as a JSON string
20    fn metadata(&self) -> String {
21        "{}".to_string()
22    }
23
24    /// Update a specific field with a JSON string representation
25    fn update_field(&mut self, _field: &str, _value: &str) -> Result<(), String> {
26        Err("Update not supported".to_string())
27    }
28}
29
30/// A wrapper to share state safely between the main thread and the web server
31#[derive(Clone)]
32pub struct DashboardRunner<T: DashboardState + 'static> {
33    state: Arc<RwLock<T>>,
34}
35
36impl<T: DashboardState + 'static> DashboardRunner<T> {
37    pub fn new(initial_state: T) -> Self {
38        Self {
39            state: Arc::new(RwLock::new(initial_state)),
40        }
41    }
42
43    /// Update the state from the main thread
44    pub fn update<F>(&self, f: F)
45    where
46        F: FnOnce(&mut T),
47    {
48        if let Ok(mut state) = self.state.write() {
49            f(&mut state);
50        }
51    }
52
53    /// Read the state from the main thread
54    pub fn read<F, R>(&self, f: F) -> R
55    where
56        F: FnOnce(&T) -> R,
57    {
58        let state = self.state.read().unwrap();
59        f(&state)
60    }
61
62    /// Start the dashboard server in a background Tokio task
63    pub fn start_server(&self, port: u16) {
64        let state_clone = self.state.clone();
65
66        std::thread::spawn(move || {
67            if let Ok(rt) = tokio::runtime::Runtime::new() {
68                rt.block_on(async move {
69                    let app = Router::new()
70                        .route("/", get(index_handler))
71                        .route(
72                            "/api/metadata",
73                            get({
74                                let state = state_clone.clone();
75                                move || metadata_handler(state)
76                            }),
77                        )
78                        .route(
79                            "/api/state",
80                            get({
81                                let state = state_clone.clone();
82                                move || state_handler(state)
83                            })
84                            .post({
85                                let state = state_clone.clone();
86                                move |body| update_state_handler(state, body)
87                            }),
88                        );
89
90                    let addr = SocketAddr::from(([127, 0, 0, 1], port));
91                    if let Ok(listener) = TcpListener::bind(addr).await {
92                        println!("Magic Dashboard running at http://127.0.0.1:{}", port);
93                        let _ = axum::serve(listener, app).await;
94                    } else {
95                        eprintln!("Failed to start Magic Dashboard on port {}", port);
96                    }
97                });
98            } else {
99                eprintln!("Failed to create Tokio runtime for Magic Dashboard");
100            }
101        });
102    }
103}
104
105async fn index_handler() -> Html<&'static str> {
106    Html(include_str!("index.html"))
107}
108
109async fn metadata_handler<T: DashboardState>(state: Arc<RwLock<T>>) -> impl IntoResponse {
110    if let Ok(guard) = state.read() {
111        guard.metadata()
112    } else {
113        r#"{"error": "state locked"}"#.to_string()
114    }
115}
116
117async fn state_handler<T: DashboardState>(state: Arc<RwLock<T>>) -> impl IntoResponse {
118    if let Ok(guard) = state.read() {
119        guard.to_json()
120    } else {
121        r#"{"error": "state locked"}"#.to_string()
122    }
123}
124
125async fn update_state_handler<T: DashboardState>(
126    state: Arc<RwLock<T>>,
127    body: Bytes,
128) -> impl IntoResponse {
129    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&body) {
130        if let (Some(field), Some(value)) = (
131            json.get("field").and_then(|v| v.as_str()),
132            json.get("value"),
133        ) {
134            if let Ok(mut guard) = state.write() {
135                let value_str = value.to_string();
136                if let Err(e) = guard.update_field(field, &value_str) {
137                    return (axum::http::StatusCode::BAD_REQUEST, e).into_response();
138                }
139                return "OK".into_response();
140            } else {
141                return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Lock error")
142                    .into_response();
143            }
144        }
145    }
146    (
147        axum::http::StatusCode::BAD_REQUEST,
148        "Invalid request format",
149    )
150        .into_response()
151}