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("/api/projects/{id}/success-rate", get(project_success_rate))
52        .route("/healthz", get(|| async { "ok" }))
53        .fallback(static_handler)
54        .with_state(state)
55}
56
57/// Bind + serve.
58pub async fn serve(addr: SocketAddr, state: AppState) -> Result<(), std::io::Error> {
59    let app = router(state);
60    let listener = tokio::net::TcpListener::bind(addr).await?;
61    tracing::info!(?addr, "evolve-dashboard listening");
62    axum::serve(listener, app).await
63}
64
65async fn static_handler(uri: Uri) -> Response {
66    let path = uri.path().trim_start_matches('/');
67    let target = if path.is_empty() { "index.html" } else { path };
68    match Assets::get(target) {
69        Some(content) => {
70            let mime = mime_guess::from_path(target).first_or_octet_stream();
71            (
72                StatusCode::OK,
73                [(header::CONTENT_TYPE, mime.as_ref().to_string())],
74                content.data.into_owned(),
75            )
76                .into_response()
77        }
78        None => {
79            // Fallback to index.html (SPA router).
80            match Assets::get("index.html") {
81                Some(content) => (
82                    StatusCode::OK,
83                    [(header::CONTENT_TYPE, "text/html".to_string())],
84                    content.data.into_owned(),
85                )
86                    .into_response(),
87                None => (StatusCode::NOT_FOUND, "not found").into_response(),
88            }
89        }
90    }
91}
92
93async fn list_projects(State(state): State<AppState>) -> Response {
94    match ProjectRepo::new(&state.storage).list().await {
95        Ok(rows) => {
96            let payload: Vec<_> = rows
97                .into_iter()
98                .map(|p| {
99                    json!({
100                        "id": p.id.to_string(),
101                        "adapter_id": p.adapter_id.as_str(),
102                        "root_path": p.root_path,
103                        "name": p.name,
104                        "created_at": p.created_at,
105                        "champion_config_id": p.champion_config_id.map(|c| c.to_string()),
106                    })
107                })
108                .collect();
109            Json(payload).into_response()
110        }
111        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
112    }
113}
114
115async fn get_project(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
116    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
117        Ok(p) => p,
118        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
119    };
120    match ProjectRepo::new(&state.storage).get_by_id(pid).await {
121        Ok(Some(p)) => Json(json!({
122            "id": p.id.to_string(),
123            "adapter_id": p.adapter_id.as_str(),
124            "root_path": p.root_path,
125            "name": p.name,
126            "created_at": p.created_at,
127            "champion_config_id": p.champion_config_id.map(|c| c.to_string()),
128        }))
129        .into_response(),
130        Ok(None) => (StatusCode::NOT_FOUND, "not found").into_response(),
131        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
132    }
133}
134
135async fn project_running_experiment(
136    State(state): State<AppState>,
137    AxumPath(id): AxumPath<String>,
138) -> Response {
139    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
140        Ok(p) => p,
141        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
142    };
143    match ExperimentRepo::new(&state.storage)
144        .get_running_for_project(pid)
145        .await
146    {
147        Ok(Some(exp)) => Json(json!({
148            "id": exp.id.to_string(),
149            "champion_config_id": exp.champion_config_id.to_string(),
150            "challenger_config_id": exp.challenger_config_id.to_string(),
151            "traffic_share": exp.traffic_share,
152            "started_at": exp.started_at,
153        }))
154        .into_response(),
155        Ok(None) => Json(json!(null)).into_response(),
156        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
157    }
158}
159
160async fn project_promotion_log(
161    State(state): State<AppState>,
162    AxumPath(id): AxumPath<String>,
163) -> Response {
164    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
165        Ok(p) => p,
166        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
167    };
168    match ExperimentRepo::new(&state.storage)
169        .list_completed(pid)
170        .await
171    {
172        Ok(rows) => {
173            let payload: Vec<_> = rows
174                .into_iter()
175                .map(|exp| {
176                    json!({
177                        "id": exp.id.to_string(),
178                        "champion_config_id": exp.champion_config_id.to_string(),
179                        "challenger_config_id": exp.challenger_config_id.to_string(),
180                        "status": format!("{:?}", exp.status),
181                        "traffic_share": exp.traffic_share,
182                        "started_at": exp.started_at,
183                        "decided_at": exp.decided_at,
184                        "decision_posterior": exp.decision_posterior,
185                    })
186                })
187                .collect();
188            Json(payload).into_response()
189        }
190        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
191    }
192}
193
194/// Per-project success-rate-over-time metric. Returns one bucket per
195/// (date, variant) with the mean session aggregate score for that bucket.
196/// This is the killer metric: "is the new champion actually better than the
197/// old one?" — answered with a line chart.
198async fn project_success_rate(
199    State(state): State<AppState>,
200    AxumPath(id): AxumPath<String>,
201) -> Response {
202    use evolve_core::promotion::{
203        AggregationConfig, SignalInput, SignalKind as PromSignalKind, aggregate,
204    };
205    use evolve_storage::signals::{SignalKind as StorageSignalKind, SignalRepo};
206    use std::collections::BTreeMap;
207
208    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
209        Ok(p) => p,
210        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
211    };
212    let sessions = match SessionRepo::new(&state.storage)
213        .list_recent(pid, 5_000)
214        .await
215    {
216        Ok(rows) => rows,
217        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
218    };
219
220    let cfg = AggregationConfig::default();
221    let signal_repo = SignalRepo::new(&state.storage);
222
223    // (date_string, variant) -> Vec<aggregated_score>
224    let mut buckets: BTreeMap<(String, String), Vec<f64>> = BTreeMap::new();
225    for sess in sessions {
226        let date = sess.started_at.format("%Y-%m-%d").to_string();
227        let variant = format!("{:?}", sess.variant).to_lowercase();
228        let sigs = match signal_repo.list_for_session(sess.id).await {
229            Ok(s) => s,
230            Err(_) => continue,
231        };
232        let inputs: Vec<SignalInput> = sigs
233            .into_iter()
234            .map(|s| SignalInput {
235                kind: match s.kind {
236                    StorageSignalKind::Explicit => PromSignalKind::Explicit,
237                    StorageSignalKind::Implicit => PromSignalKind::Implicit,
238                },
239                value: s.value,
240            })
241            .collect();
242        let score = aggregate(&inputs, &cfg);
243        buckets.entry((date, variant)).or_default().push(score);
244    }
245
246    let series: Vec<_> = buckets
247        .into_iter()
248        .map(|((date, variant), scores)| {
249            let n = scores.len();
250            let mean = scores.iter().sum::<f64>() / (n as f64).max(1.0);
251            json!({
252                "date": date,
253                "variant": variant,
254                "session_count": n,
255                "mean_score": mean,
256            })
257        })
258        .collect();
259    Json(series).into_response()
260}
261
262async fn project_sessions(
263    State(state): State<AppState>,
264    AxumPath(id): AxumPath<String>,
265) -> Response {
266    let pid = match uuid::Uuid::parse_str(&id).map(evolve_core::ids::ProjectId::from_uuid) {
267        Ok(p) => p,
268        Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
269    };
270    match SessionRepo::new(&state.storage).list_recent(pid, 50).await {
271        Ok(rows) => {
272            let payload: Vec<_> = rows
273                .into_iter()
274                .map(|s| {
275                    json!({
276                        "id": s.id.to_string(),
277                        "started_at": s.started_at,
278                        "ended_at": s.ended_at,
279                        "variant": format!("{:?}", s.variant),
280                    })
281                })
282                .collect();
283            Json(payload).into_response()
284        }
285        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use axum::body::to_bytes;
293    use axum::http::{Request, StatusCode};
294    use tower::ServiceExt;
295
296    async fn app_with_empty_storage() -> Router {
297        let storage = Arc::new(Storage::in_memory_for_tests().await.unwrap());
298        router(AppState { storage })
299    }
300
301    #[tokio::test]
302    async fn serves_index_html_at_root() {
303        let app = app_with_empty_storage().await;
304        let resp = app
305            .oneshot(
306                Request::builder()
307                    .uri("/")
308                    .body(axum::body::Body::empty())
309                    .unwrap(),
310            )
311            .await
312            .unwrap();
313        assert_eq!(resp.status(), StatusCode::OK);
314        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
315        let text = String::from_utf8_lossy(&body);
316        assert!(text.contains("Evolve"));
317    }
318
319    #[tokio::test]
320    async fn api_projects_returns_empty_list_when_no_data() {
321        let app = app_with_empty_storage().await;
322        let resp = app
323            .oneshot(
324                Request::builder()
325                    .uri("/api/projects")
326                    .body(axum::body::Body::empty())
327                    .unwrap(),
328            )
329            .await
330            .unwrap();
331        assert_eq!(resp.status(), StatusCode::OK);
332        let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
333        let text = String::from_utf8_lossy(&body);
334        assert_eq!(text.trim(), "[]");
335    }
336
337    #[tokio::test]
338    async fn healthz_ok() {
339        let app = app_with_empty_storage().await;
340        let resp = app
341            .oneshot(
342                Request::builder()
343                    .uri("/healthz")
344                    .body(axum::body::Body::empty())
345                    .unwrap(),
346            )
347            .await
348            .unwrap();
349        assert_eq!(resp.status(), StatusCode::OK);
350    }
351}