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::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#[derive(Clone)]
30pub struct AppState {
31 pub storage: Arc<Storage>,
33}
34
35pub 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
47pub 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 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}