1#![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#[derive(Clone)]
31pub struct AppState {
32 pub storage: Arc<Storage>,
34}
35
36pub 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
57pub 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 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
194async 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 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}