magic-dashboard 0.1.0

Auto-generate a real-time web dashboard from Rust structs
Documentation
pub use magic_dashboard_macros::Dashboard;
pub use serde_json;

use axum::{
    body::Bytes,
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use std::net::SocketAddr;
use std::sync::{Arc, RwLock};
use tokio::net::TcpListener;

/// The trait that will be implemented by the macro
pub trait DashboardState: Send + Sync {
    /// Returns the current state as a JSON string
    fn to_json(&self) -> String;

    /// Returns the metadata mapping fields to categories as a JSON string
    fn metadata(&self) -> String {
        "{}".to_string()
    }

    /// Update a specific field with a JSON string representation
    fn update_field(&mut self, _field: &str, _value: &str) -> Result<(), String> {
        Err("Update not supported".to_string())
    }
}

/// A wrapper to share state safely between the main thread and the web server
#[derive(Clone)]
pub struct DashboardRunner<T: DashboardState + 'static> {
    state: Arc<RwLock<T>>,
}

impl<T: DashboardState + 'static> DashboardRunner<T> {
    pub fn new(initial_state: T) -> Self {
        Self {
            state: Arc::new(RwLock::new(initial_state)),
        }
    }

    /// Update the state from the main thread
    pub fn update<F>(&self, f: F)
    where
        F: FnOnce(&mut T),
    {
        if let Ok(mut state) = self.state.write() {
            f(&mut state);
        }
    }

    /// Read the state from the main thread
    pub fn read<F, R>(&self, f: F) -> R
    where
        F: FnOnce(&T) -> R,
    {
        let state = self.state.read().unwrap();
        f(&state)
    }

    /// Start the dashboard server in a background Tokio task
    pub fn start_server(&self, port: u16) {
        let state_clone = self.state.clone();

        std::thread::spawn(move || {
            if let Ok(rt) = tokio::runtime::Runtime::new() {
                rt.block_on(async move {
                    let app = Router::new()
                        .route("/", get(index_handler))
                        .route(
                            "/api/metadata",
                            get({
                                let state = state_clone.clone();
                                move || metadata_handler(state)
                            }),
                        )
                        .route(
                            "/api/state",
                            get({
                                let state = state_clone.clone();
                                move || state_handler(state)
                            })
                            .post({
                                let state = state_clone.clone();
                                move |body| update_state_handler(state, body)
                            }),
                        );

                    let addr = SocketAddr::from(([127, 0, 0, 1], port));
                    if let Ok(listener) = TcpListener::bind(addr).await {
                        println!("Magic Dashboard running at http://127.0.0.1:{}", port);
                        let _ = axum::serve(listener, app).await;
                    } else {
                        eprintln!("Failed to start Magic Dashboard on port {}", port);
                    }
                });
            } else {
                eprintln!("Failed to create Tokio runtime for Magic Dashboard");
            }
        });
    }
}

async fn index_handler() -> Html<&'static str> {
    Html(include_str!("index.html"))
}

async fn metadata_handler<T: DashboardState>(state: Arc<RwLock<T>>) -> impl IntoResponse {
    if let Ok(guard) = state.read() {
        guard.metadata()
    } else {
        r#"{"error": "state locked"}"#.to_string()
    }
}

async fn state_handler<T: DashboardState>(state: Arc<RwLock<T>>) -> impl IntoResponse {
    if let Ok(guard) = state.read() {
        guard.to_json()
    } else {
        r#"{"error": "state locked"}"#.to_string()
    }
}

async fn update_state_handler<T: DashboardState>(
    state: Arc<RwLock<T>>,
    body: Bytes,
) -> impl IntoResponse {
    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&body) {
        if let (Some(field), Some(value)) = (
            json.get("field").and_then(|v| v.as_str()),
            json.get("value"),
        ) {
            if let Ok(mut guard) = state.write() {
                let value_str = value.to_string();
                if let Err(e) = guard.update_field(field, &value_str) {
                    return (axum::http::StatusCode::BAD_REQUEST, e).into_response();
                }
                return "OK".into_response();
            } else {
                return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Lock error")
                    .into_response();
            }
        }
    }
    (
        axum::http::StatusCode::BAD_REQUEST,
        "Invalid request format",
    )
        .into_response()
}