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::experiments::ExperimentRepo;
18use evolve_storage::projects::ProjectRepo;
19use evolve_storage::sessions::SessionRepo;
20use rust_embed::RustEmbed;
21use serde_json::json;
22use std::net::SocketAddr;
23use std::sync::Arc;
24
25#[derive(RustEmbed)]
26#[folder = "static/"]
27struct Assets;
28
29/// Shared state: a storage handle.
30#[derive(Clone)]
31pub struct AppState {
32    /// Storage handle.
33    pub storage: Arc<Storage>,
34}
35
36/// Build the axum router.
37pub fn router(state: AppState) -> Router {
38    Router::new()
39        .route("/", get(static_handler))
40        .route("/api/projects", get(list_projects))
41        .route("/api/projects/{id}", get(get_project))
42        .route("/api/projects/{id}/sessions", get(project_sessions))
43        .route(
44            "/api/projects/{id}/experiment",
45            get(project_running_experiment),
46        )
47        .route(
48            "/api/projects/{id}/promotion-log",
49            get(project_promotion_log),
50        )
51        .route("/healthz", get(|| async { "ok" }))
52        .fallback(static_handler)
53        .with_state(state)
54}
55
56/// Bind + serve.
57pub async fn serve(addr: SocketAddr, state: AppState) -> Result<(), std::io::Error> {
58    let app = router(state);
59    let listener = tokio::net::TcpListener::bind(addr).await?;
60    tracing::info!(?addr, "evolve-dashboard listening");
61    axum::serve(listener, app).await
62}
63
64async fn static_handler(uri: Uri) -> Response {
65    let path = uri.path().trim_start_matches('/');
66    let target = if path.is_empty() { "index.html" } else { path };
67    match Assets::get(target) {
68        Some(content) => {
69            let mime = mime_guess::from_path(target).first_or_octet_stream();
70            (
71                StatusCode::OK,
72                [(header::CONTENT_TYPE, mime.as_ref().to_string())],
73                content.data.into_owned(),
74            )
75                .into_response()
76        }
77        None => {
78            // Fallback to index.html (SPA router).
79            match Assets::get("index.html") {
80                Some(content) => (
81                    StatusCode::OK,
82                    [(header::CONTENT_TYPE, "text/html".to_string())],
83                    content.data.into_owned(),
84                )
85                    .into_response(),
86                None => (StatusCode::NOT_FOUND, "not found").into_response(),
87            }
88        }
89    }
90}
91
92async fn list_projects(State(state): State<AppState>) -> Response {
93    match ProjectRepo::new(&state.storage).list().await {
94        Ok(rows) => {
95            let payload: Vec<_> = rows
96                .into_iter()
97                .map(|p| {
98                    json!({
99                        "id": p.id.to_string(),
100                        "adapter_id": p.adapter_id.as_str(),
101                        "root_path": p.root_path,
102                        "name": p.name,
103                        "created_at": p.created_at,
104                        "champion_config_id": p.champion_config_id.map(|c| c.to_string()),
105                    })
106                })
107                .collect();
108            Json(payload).into_response()
109        }
110        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
111    }
112}
113
114async fn get_project(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
115    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
116        Ok(p) => p,
117        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
118    };
119    match ProjectRepo::new(&state.storage).get_by_id(pid).await {
120        Ok(Some(p)) => Json(json!({
121            "id": p.id.to_string(),
122            "adapter_id": p.adapter_id.as_str(),
123            "root_path": p.root_path,
124            "name": p.name,
125            "created_at": p.created_at,
126            "champion_config_id": p.champion_config_id.map(|c| c.to_string()),
127        }))
128        .into_response(),
129        Ok(None) => (StatusCode::NOT_FOUND, "not found").into_response(),
130        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
131    }
132}
133
134async fn project_running_experiment(
135    State(state): State<AppState>,
136    AxumPath(id): AxumPath<String>,
137) -> Response {
138    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
139        Ok(p) => p,
140        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
141    };
142    match ExperimentRepo::new(&state.storage)
143        .get_running_for_project(pid)
144        .await
145    {
146        Ok(Some(exp)) => Json(json!({
147            "id": exp.id.to_string(),
148            "champion_config_id": exp.champion_config_id.to_string(),
149            "challenger_config_id": exp.challenger_config_id.to_string(),
150            "traffic_share": exp.traffic_share,
151            "started_at": exp.started_at,
152        }))
153        .into_response(),
154        Ok(None) => Json(json!(null)).into_response(),
155        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
156    }
157}
158
159async fn project_promotion_log(
160    State(state): State<AppState>,
161    AxumPath(id): AxumPath<String>,
162) -> Response {
163    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
164        Ok(p) => p,
165        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
166    };
167    match ExperimentRepo::new(&state.storage)
168        .list_completed(pid)
169        .await
170    {
171        Ok(rows) => {
172            let payload: Vec<_> = rows
173                .into_iter()
174                .map(|exp| {
175                    json!({
176                        "id": exp.id.to_string(),
177                        "champion_config_id": exp.champion_config_id.to_string(),
178                        "challenger_config_id": exp.challenger_config_id.to_string(),
179                        "status": format!("{:?}", exp.status),
180                        "traffic_share": exp.traffic_share,
181                        "started_at": exp.started_at,
182                        "decided_at": exp.decided_at,
183                        "decision_posterior": exp.decision_posterior,
184                    })
185                })
186                .collect();
187            Json(payload).into_response()
188        }
189        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
190    }
191}
192
193async fn project_sessions(
194    State(state): State<AppState>,
195    AxumPath(id): AxumPath<String>,
196) -> Response {
197    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
198        Ok(p) => p,
199        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
200    };
201    match SessionRepo::new(&state.storage).list_recent(pid, 50).await {
202        Ok(rows) => {
203            let payload: Vec<_> = rows
204                .into_iter()
205                .map(|s| {
206                    json!({
207                        "id": s.id.to_string(),
208                        "started_at": s.started_at,
209                        "ended_at": s.ended_at,
210                        "variant": format!("{:?}", s.variant),
211                    })
212                })
213                .collect();
214            Json(payload).into_response()
215        }
216        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use axum::body::to_bytes;
224    use axum::http::{Request, StatusCode};
225    use tower::ServiceExt;
226
227    async fn app_with_empty_storage() -> Router {
228        let storage = Arc::new(Storage::in_memory_for_tests().await.unwrap());
229        router(AppState { storage })
230    }
231
232    #[tokio::test]
233    async fn serves_index_html_at_root() {
234        let app = app_with_empty_storage().await;
235        let resp = app
236            .oneshot(
237                Request::builder()
238                    .uri("/")
239                    .body(axum::body::Body::empty())
240                    .unwrap(),
241            )
242            .await
243            .unwrap();
244        assert_eq!(resp.status(), StatusCode::OK);
245        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
246        let text = String::from_utf8_lossy(&body);
247        assert!(text.contains("Evolve"));
248    }
249
250    #[tokio::test]
251    async fn api_projects_returns_empty_list_when_no_data() {
252        let app = app_with_empty_storage().await;
253        let resp = app
254            .oneshot(
255                Request::builder()
256                    .uri("/api/projects")
257                    .body(axum::body::Body::empty())
258                    .unwrap(),
259            )
260            .await
261            .unwrap();
262        assert_eq!(resp.status(), StatusCode::OK);
263        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
264        let text = String::from_utf8_lossy(&body);
265        assert_eq!(text.trim(), "[]");
266    }
267
268    #[tokio::test]
269    async fn healthz_ok() {
270        let app = app_with_empty_storage().await;
271        let resp = app
272            .oneshot(
273                Request::builder()
274                    .uri("/healthz")
275                    .body(axum::body::Body::empty())
276                    .unwrap(),
277            )
278            .await
279            .unwrap();
280        assert_eq!(resp.status(), StatusCode::OK);
281    }
282}