Skip to main content

perspt_dashboard/
lib.rs

1//! perspt-dashboard: Real-time web dashboard for Perspt agent monitoring
2//!
3//! Provides a browser-based interface for observing agent execution, including
4//! DAG topology, energy convergence, LLM telemetry, and decision traces.
5
6pub mod auth;
7pub mod error;
8pub mod handlers;
9pub mod sse;
10pub mod state;
11pub mod views;
12
13use axum::middleware;
14use axum::routing::{get, post};
15use axum::Router;
16use tower_http::services::ServeDir;
17
18use state::AppState;
19
20/// Build the dashboard router with all routes and middleware.
21pub fn build_router(state: AppState) -> Router {
22    // Public routes (no auth)
23    let public = Router::new()
24        .route("/login", get(auth::login_page))
25        .route("/login", post(auth::login_handler));
26
27    // Protected routes (behind auth middleware)
28    let protected = Router::new()
29        .route("/", get(handlers::overview::overview_handler))
30        .route(
31            "/sessions/{session_id}",
32            get(handlers::session_detail::session_detail_handler),
33        )
34        .route(
35            "/sessions/{session_id}/dag",
36            get(handlers::dag::dag_handler),
37        )
38        .route(
39            "/sessions/{session_id}/energy",
40            get(handlers::energy::energy_handler),
41        )
42        .route(
43            "/sessions/{session_id}/llm",
44            get(handlers::llm::llm_handler),
45        )
46        .route(
47            "/sessions/{session_id}/sandbox",
48            get(handlers::sandbox::sandbox_handler),
49        )
50        .route(
51            "/sessions/{session_id}/decisions",
52            get(handlers::decisions::decisions_handler),
53        )
54        .route("/sse/{session_id}", get(sse::sse_handler))
55        .layer(middleware::from_fn_with_state(
56            state.clone(),
57            auth::auth_middleware,
58        ));
59
60    let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static");
61
62    Router::new()
63        .merge(public)
64        .merge(protected)
65        .nest_service("/static", ServeDir::new(static_dir))
66        .with_state(state)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use axum::body::Body;
73    use axum::http::{Request, StatusCode};
74    use perspt_store::SessionStore;
75    use std::sync::Arc;
76    use tokio::sync::Mutex;
77    use tower::ServiceExt;
78
79    fn test_db_path() -> std::path::PathBuf {
80        std::env::temp_dir().join(format!("perspt_dash_test_{}.db", rand::random::<u64>()))
81    }
82
83    /// Create a test AppState with a temp store (no password).
84    fn test_state_open() -> AppState {
85        let db = test_db_path();
86        let store = SessionStore::open(&db).expect("temp store");
87        AppState {
88            store: Arc::new(store),
89            password: None,
90            session_token: Arc::new(Mutex::new(None)),
91            working_dir: std::path::PathBuf::from("/tmp"),
92            is_localhost: true,
93        }
94    }
95
96    /// Create a test AppState with a password set.
97    fn test_state_auth(password: &str) -> AppState {
98        let db = test_db_path();
99        let store = SessionStore::open(&db).expect("temp store");
100        AppState {
101            store: Arc::new(store),
102            password: Some(password.to_string()),
103            session_token: Arc::new(Mutex::new(None)),
104            working_dir: std::path::PathBuf::from("/tmp"),
105            is_localhost: true,
106        }
107    }
108
109    // ── Route smoke tests (open access) ──
110
111    #[tokio::test]
112    async fn overview_returns_200() {
113        let app = build_router(test_state_open());
114        let req = Request::builder().uri("/").body(Body::empty()).unwrap();
115        let res = app.oneshot(req).await.unwrap();
116        assert_eq!(res.status(), StatusCode::OK);
117    }
118
119    #[tokio::test]
120    async fn session_detail_returns_200() {
121        let app = build_router(test_state_open());
122        let req = Request::builder()
123            .uri("/sessions/test-session")
124            .body(Body::empty())
125            .unwrap();
126        let res = app.oneshot(req).await.unwrap();
127        assert_eq!(res.status(), StatusCode::OK);
128    }
129
130    #[tokio::test]
131    async fn login_page_returns_200() {
132        let app = build_router(test_state_open());
133        let req = Request::builder()
134            .uri("/login")
135            .body(Body::empty())
136            .unwrap();
137        let res = app.oneshot(req).await.unwrap();
138        assert_eq!(res.status(), StatusCode::OK);
139    }
140
141    #[tokio::test]
142    async fn dag_page_returns_200() {
143        let app = build_router(test_state_open());
144        let req = Request::builder()
145            .uri("/sessions/test-session/dag")
146            .body(Body::empty())
147            .unwrap();
148        let res = app.oneshot(req).await.unwrap();
149        assert_eq!(res.status(), StatusCode::OK);
150    }
151
152    #[tokio::test]
153    async fn energy_page_returns_200() {
154        let app = build_router(test_state_open());
155        let req = Request::builder()
156            .uri("/sessions/test-session/energy")
157            .body(Body::empty())
158            .unwrap();
159        let res = app.oneshot(req).await.unwrap();
160        assert_eq!(res.status(), StatusCode::OK);
161    }
162
163    #[tokio::test]
164    async fn llm_page_returns_200() {
165        let app = build_router(test_state_open());
166        let req = Request::builder()
167            .uri("/sessions/test-session/llm")
168            .body(Body::empty())
169            .unwrap();
170        let res = app.oneshot(req).await.unwrap();
171        assert_eq!(res.status(), StatusCode::OK);
172    }
173
174    #[tokio::test]
175    async fn sandbox_page_returns_200() {
176        let app = build_router(test_state_open());
177        let req = Request::builder()
178            .uri("/sessions/test-session/sandbox")
179            .body(Body::empty())
180            .unwrap();
181        let res = app.oneshot(req).await.unwrap();
182        assert_eq!(res.status(), StatusCode::OK);
183    }
184
185    #[tokio::test]
186    async fn decisions_page_returns_200() {
187        let app = build_router(test_state_open());
188        let req = Request::builder()
189            .uri("/sessions/test-session/decisions")
190            .body(Body::empty())
191            .unwrap();
192        let res = app.oneshot(req).await.unwrap();
193        assert_eq!(res.status(), StatusCode::OK);
194    }
195
196    // ── SSE test ──
197
198    #[tokio::test]
199    async fn sse_returns_event_stream() {
200        let app = build_router(test_state_open());
201        let req = Request::builder()
202            .uri("/sse/test-session")
203            .body(Body::empty())
204            .unwrap();
205        let res = app.oneshot(req).await.unwrap();
206        assert_eq!(res.status(), StatusCode::OK);
207        let ct = res.headers().get("content-type").unwrap().to_str().unwrap();
208        assert!(ct.contains("text/event-stream"));
209    }
210
211    // ── Auth tests ──
212
213    #[tokio::test]
214    async fn unauth_request_redirects_to_login() {
215        let app = build_router(test_state_auth("secret123"));
216        let req = Request::builder().uri("/").body(Body::empty()).unwrap();
217        let res = app.oneshot(req).await.unwrap();
218        assert_eq!(res.status(), StatusCode::SEE_OTHER);
219        let location = res.headers().get("location").unwrap().to_str().unwrap();
220        assert_eq!(location, "/login");
221    }
222
223    #[tokio::test]
224    async fn invalid_cookie_redirects_to_login() {
225        let app = build_router(test_state_auth("secret123"));
226        let req = Request::builder()
227            .uri("/")
228            .header("cookie", "perspt_session=wrong-token")
229            .body(Body::empty())
230            .unwrap();
231        let res = app.oneshot(req).await.unwrap();
232        assert_eq!(res.status(), StatusCode::SEE_OTHER);
233    }
234
235    #[tokio::test]
236    async fn valid_cookie_passes_auth() {
237        let state = test_state_auth("secret123");
238        // Pre-set a known token
239        *state.session_token.lock().await = Some("valid-token-123".to_string());
240
241        let app = build_router(state);
242        let req = Request::builder()
243            .uri("/")
244            .header("cookie", "perspt_session=valid-token-123")
245            .body(Body::empty())
246            .unwrap();
247        let res = app.oneshot(req).await.unwrap();
248        assert_eq!(res.status(), StatusCode::OK);
249    }
250
251    #[tokio::test]
252    async fn sse_behind_auth() {
253        let app = build_router(test_state_auth("secret123"));
254        let req = Request::builder()
255            .uri("/sse/test-session")
256            .body(Body::empty())
257            .unwrap();
258        let res = app.oneshot(req).await.unwrap();
259        // Should redirect, not 200
260        assert_eq!(res.status(), StatusCode::SEE_OTHER);
261    }
262}