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("/healthz", get(|| async { "ok" }))
52 .fallback(static_handler)
53 .with_state(state)
54}
55
56pub 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 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}