Skip to main content

evolve_dashboard/
lib.rs

1//! Local-only web dashboard for Evolve.
2//!
3//! Serves a tiny static SPA (bundled via `rust-embed`) plus a REST API over
4//! `evolve-storage`. Bound to 127.0.0.1 by default.
5
6#![forbid(unsafe_code)]
7#![warn(missing_docs)]
8
9use axum::{
10    Json, Router,
11    extract::{Path as AxumPath, State},
12    http::{StatusCode, Uri, header},
13    response::{IntoResponse, Response},
14    routing::get,
15};
16use evolve_storage::Storage;
17use evolve_storage::projects::ProjectRepo;
18use evolve_storage::sessions::SessionRepo;
19use rust_embed::RustEmbed;
20use serde_json::json;
21use std::net::SocketAddr;
22use std::sync::Arc;
23
24#[derive(RustEmbed)]
25#[folder = "static/"]
26struct Assets;
27
28/// Shared state: a storage handle.
29#[derive(Clone)]
30pub struct AppState {
31    /// Storage handle.
32    pub storage: Arc<Storage>,
33}
34
35/// Build the axum router.
36pub fn router(state: AppState) -> Router {
37    Router::new()
38        .route("/", get(static_handler))
39        .route("/api/projects", get(list_projects))
40        .route("/api/projects/{id}", get(get_project))
41        .route("/api/projects/{id}/sessions", get(project_sessions))
42        .route("/healthz", get(|| async { "ok" }))
43        .fallback(static_handler)
44        .with_state(state)
45}
46
47/// Bind + serve.
48pub async fn serve(addr: SocketAddr, state: AppState) -> Result<(), std::io::Error> {
49    let app = router(state);
50    let listener = tokio::net::TcpListener::bind(addr).await?;
51    tracing::info!(?addr, "evolve-dashboard listening");
52    axum::serve(listener, app).await
53}
54
55async fn static_handler(uri: Uri) -> Response {
56    let path = uri.path().trim_start_matches('/');
57    let target = if path.is_empty() { "index.html" } else { path };
58    match Assets::get(target) {
59        Some(content) => {
60            let mime = mime_guess::from_path(target).first_or_octet_stream();
61            (
62                StatusCode::OK,
63                [(header::CONTENT_TYPE, mime.as_ref().to_string())],
64                content.data.into_owned(),
65            )
66                .into_response()
67        }
68        None => {
69            // Fallback to index.html (SPA router).
70            match Assets::get("index.html") {
71                Some(content) => (
72                    StatusCode::OK,
73                    [(header::CONTENT_TYPE, "text/html".to_string())],
74                    content.data.into_owned(),
75                )
76                    .into_response(),
77                None => (StatusCode::NOT_FOUND, "not found").into_response(),
78            }
79        }
80    }
81}
82
83async fn list_projects(State(state): State<AppState>) -> Response {
84    match ProjectRepo::new(&state.storage).list().await {
85        Ok(rows) => {
86            let payload: Vec<_> = rows
87                .into_iter()
88                .map(|p| {
89                    json!({
90                        "id": p.id.to_string(),
91                        "adapter_id": p.adapter_id.as_str(),
92                        "root_path": p.root_path,
93                        "name": p.name,
94                        "created_at": p.created_at,
95                        "champion_config_id": p.champion_config_id.map(|c| c.to_string()),
96                    })
97                })
98                .collect();
99            Json(payload).into_response()
100        }
101        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
102    }
103}
104
105async fn get_project(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
106    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
107        Ok(p) => p,
108        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
109    };
110    match ProjectRepo::new(&state.storage).get_by_id(pid).await {
111        Ok(Some(p)) => Json(json!({
112            "id": p.id.to_string(),
113            "adapter_id": p.adapter_id.as_str(),
114            "root_path": p.root_path,
115            "name": p.name,
116            "created_at": p.created_at,
117            "champion_config_id": p.champion_config_id.map(|c| c.to_string()),
118        }))
119        .into_response(),
120        Ok(None) => (StatusCode::NOT_FOUND, "not found").into_response(),
121        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
122    }
123}
124
125async fn project_sessions(
126    State(state): State<AppState>,
127    AxumPath(id): AxumPath<String>,
128) -> Response {
129    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
130        Ok(p) => p,
131        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
132    };
133    match SessionRepo::new(&state.storage).list_recent(pid, 50).await {
134        Ok(rows) => {
135            let payload: Vec<_> = rows
136                .into_iter()
137                .map(|s| {
138                    json!({
139                        "id": s.id.to_string(),
140                        "started_at": s.started_at,
141                        "ended_at": s.ended_at,
142                        "variant": format!("{:?}", s.variant),
143                    })
144                })
145                .collect();
146            Json(payload).into_response()
147        }
148        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use axum::body::to_bytes;
156    use axum::http::{Request, StatusCode};
157    use tower::ServiceExt;
158
159    async fn app_with_empty_storage() -> Router {
160        let storage = Arc::new(Storage::in_memory_for_tests().await.unwrap());
161        router(AppState { storage })
162    }
163
164    #[tokio::test]
165    async fn serves_index_html_at_root() {
166        let app = app_with_empty_storage().await;
167        let resp = app
168            .oneshot(
169                Request::builder()
170                    .uri("/")
171                    .body(axum::body::Body::empty())
172                    .unwrap(),
173            )
174            .await
175            .unwrap();
176        assert_eq!(resp.status(), StatusCode::OK);
177        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
178        let text = String::from_utf8_lossy(&body);
179        assert!(text.contains("Evolve"));
180    }
181
182    #[tokio::test]
183    async fn api_projects_returns_empty_list_when_no_data() {
184        let app = app_with_empty_storage().await;
185        let resp = app
186            .oneshot(
187                Request::builder()
188                    .uri("/api/projects")
189                    .body(axum::body::Body::empty())
190                    .unwrap(),
191            )
192            .await
193            .unwrap();
194        assert_eq!(resp.status(), StatusCode::OK);
195        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
196        let text = String::from_utf8_lossy(&body);
197        assert_eq!(text.trim(), "[]");
198    }
199
200    #[tokio::test]
201    async fn healthz_ok() {
202        let app = app_with_empty_storage().await;
203        let resp = app
204            .oneshot(
205                Request::builder()
206                    .uri("/healthz")
207                    .body(axum::body::Body::empty())
208                    .unwrap(),
209            )
210            .await
211            .unwrap();
212        assert_eq!(resp.status(), StatusCode::OK);
213    }
214}