1use crate::pipeline::AppState;
16use crate::session::Workflow;
17use av_core::time::elapsed_us;
18use axum::extract::{Path, State};
19use axum::http::{header, HeaderName, HeaderValue, StatusCode};
20use axum::response::{IntoResponse, Response};
21use axum::Json;
22use serde::Serialize;
23use serde_json::{json, Value};
24use std::sync::atomic::Ordering;
25use std::time::Instant;
26
27const INDEX_HTML: &str = include_str!("../dashboard/index.html");
28const STYLE_CSS: &str = include_str!("../dashboard/style.css");
29const APP_JS: &str = include_str!("../dashboard/app.js");
30
31pub const DEFAULT_LIST_LIMIT: usize = 50;
34pub const MAX_LIST_LIMIT: usize = 500;
36
37fn record(
49 state: &AppState,
50 endpoint: crate::pipeline::DashboardEndpoint,
51 status: crate::pipeline::DashboardStatus,
52 started: Instant,
53) {
54 #[allow(clippy::indexing_slicing)]
58 {
59 state.hot_metrics.dashboard_request_duration_histograms[endpoint.index()]
60 .observe_us(elapsed_us(started));
61 state.hot_metrics.dashboard_request_counters[endpoint.index()][status.index()].inc();
62 }
63}
64
65#[derive(Serialize)]
66pub(crate) struct SessionSummary {
67 pub id: String,
68 pub workflow: &'static str,
69 pub last_activity_ms: u64,
70 pub open: bool,
71 pub closed: bool,
72 pub artifact_committed: bool,
73 pub close_complete: bool,
74 pub capture_failed: bool,
75 pub active_streams: u64,
76 pub pending_jobs: u64,
77 pub stop_reason_id: u8,
78 pub stop_reason: &'static str,
79 pub tool_calls: u64,
80 pub tool_allowed: u64,
81 pub tool_blocked: u64,
82 pub prompt_tokens: u64,
83 pub completion_tokens: u64,
84 pub cached_tokens: u64,
85 pub cost_usd_micros: u64,
86 pub identity: Identity,
87 pub has_receipt: bool,
88}
89
90#[derive(Serialize)]
91pub(crate) struct Identity {
92 pub instance_uid: String,
93 pub charter: String,
94 pub version: String,
95 pub ttl_remaining_s: Option<u64>,
96}
97
98impl SessionSummary {
99 pub(crate) fn from_session(session: &crate::session::Session) -> Self {
100 let latest = session.current_identity();
101 let stop_id = u8::try_from(session.recorded_stop_reason_id()).unwrap_or(0);
102 let stop = av_events::StopReason::from_id(stop_id);
103 Self {
104 id: session.id.clone(),
105 workflow: session.workflow.as_str(),
106 last_activity_ms: session.last_activity_ms.load(Ordering::Acquire),
107 open: !session.is_closed(),
108 closed: session.closed.load(Ordering::Acquire) != 0,
109 artifact_committed: session.artifact_committed_flag(),
110 close_complete: session.close_complete_flag(),
111 capture_failed: session.capture_failed(),
112 active_streams: session.active_streams_count(),
113 pending_jobs: session.pending_jobs_count(),
114 stop_reason_id: stop_id,
115 stop_reason: stop.caption(),
116 tool_calls: session.totals.tool_calls.load(Ordering::Acquire),
117 tool_allowed: session.totals.tool_allowed.load(Ordering::Acquire),
118 tool_blocked: session.totals.tool_blocked.load(Ordering::Acquire),
119 prompt_tokens: session.totals.prompt_tokens.load(Ordering::Acquire),
120 completion_tokens: session.totals.completion_tokens.load(Ordering::Acquire),
121 cached_tokens: session.totals.cached_tokens.load(Ordering::Acquire),
122 cost_usd_micros: session.totals.cost_usd_micros.load(Ordering::Acquire),
123 identity: Identity {
124 instance_uid: latest.instance_uid,
125 charter: latest.charter.name.clone(),
126 version: latest.version,
127 ttl_remaining_s: latest.ttl_remaining_s,
128 },
129 has_receipt: session.receipt.lock().is_some(),
130 }
131 }
132}
133
134#[derive(Serialize)]
135struct Stats {
136 generated_at_ms: u64,
137 session_count: usize,
138 open_count: usize,
139 closed_count: usize,
140 capture_failed_count: usize,
141 total_prompt_tokens: u64,
142 total_completion_tokens: u64,
143 total_cached_tokens: u64,
144 total_cost_usd_micros: u64,
145 total_tool_calls: u64,
146 total_tool_allowed: u64,
147 total_tool_blocked: u64,
148 workflow_signed: usize,
149 workflow_unsigned: usize,
150}
151
152pub async fn index() -> Response {
154 static_asset(INDEX_HTML, "text/html; charset=utf-8")
155}
156
157pub async fn style_css() -> Response {
159 static_asset(STYLE_CSS, "text/css; charset=utf-8")
160}
161
162pub async fn app_js() -> Response {
164 static_asset(APP_JS, "text/javascript; charset=utf-8")
165}
166
167fn static_asset(body: &'static str, content_type: &'static str) -> Response {
168 let mut response = Response::new(axum::body::Body::from(body));
169 response
170 .headers_mut()
171 .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
172 response.headers_mut().insert(
173 header::CACHE_CONTROL,
174 HeaderValue::from_static("no-store, max-age=0"),
175 );
176 response.headers_mut().insert(
177 HeaderName::from_static("x-content-type-options"),
178 HeaderValue::from_static("nosniff"),
179 );
180 response.headers_mut().insert(
191 header::CONTENT_SECURITY_POLICY,
192 HeaderValue::from_static(
193 "default-src 'none'; script-src 'self'; style-src 'self'; \
194 img-src 'self' data:; font-src 'self'; \
195 connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; \
196 form-action 'none'",
197 ),
198 );
199 response.headers_mut().insert(
200 HeaderName::from_static("x-frame-options"),
201 HeaderValue::from_static("DENY"),
202 );
203 response
204 .headers_mut()
205 .insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
206 response
207}
208
209fn no_store_json_response(value: impl serde::Serialize) -> Response {
215 let mut response = Json(value).into_response();
216 response.headers_mut().insert(
217 header::CACHE_CONTROL,
218 HeaderValue::from_static("no-store, max-age=0"),
219 );
220 response
221 .headers_mut()
222 .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
223 response
224 .headers_mut()
225 .insert(header::VARY, HeaderValue::from_static("Authorization"));
226 response.headers_mut().insert(
227 HeaderName::from_static("x-content-type-options"),
228 HeaderValue::from_static("nosniff"),
229 );
230 response.headers_mut().insert(
237 header::CONTENT_SECURITY_POLICY,
238 HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'; base-uri 'none'"),
239 );
240 response.headers_mut().insert(
241 HeaderName::from_static("x-frame-options"),
242 HeaderValue::from_static("DENY"),
243 );
244 response
245 .headers_mut()
246 .insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
247 response
248}
249
250pub async fn list_sessions(
252 State(state): State<AppState>,
253 axum::extract::Query(query): axum::extract::Query<ListQuery>,
254) -> Response {
255 let started = Instant::now();
256 let limit = query.limit.unwrap_or(DEFAULT_LIST_LIMIT).min(MAX_LIST_LIMIT);
257 let snapshot = state.sessions.open_sessions_including_closed();
258 let total_before_truncate = snapshot.len();
259 let mut summaries: Vec<SessionSummary> = snapshot
260 .iter()
261 .filter(|session| match query.status.as_deref() {
262 Some("open") => !session.is_closed(),
263 Some("closed") => session.is_closed(),
264 Some("failed") => session.capture_failed(),
265 _ => true,
266 })
267 .filter(|session| match query.workflow.as_deref() {
268 Some("signed") => session.workflow == Workflow::Signed,
269 Some("unsigned") => session.workflow == Workflow::Unsigned,
270 _ => true,
271 })
272 .map(|session| SessionSummary::from_session(session))
273 .collect();
274 let matched = summaries.len();
275 summaries.sort_by_key(|s| std::cmp::Reverse(s.last_activity_ms));
276 summaries.truncate(limit);
277 let response = no_store_json_response(json!({
278 "sessions": summaries,
279 "generated_at_ms": av_core::time::now_ms(),
280 "total_before_truncate": total_before_truncate,
283 "matched": matched,
286 }));
287 record(
288 &state,
289 crate::pipeline::DashboardEndpoint::List,
290 crate::pipeline::DashboardStatus::Ok,
291 started,
292 );
293 response
294}
295
296pub async fn session_detail(State(state): State<AppState>, Path(id): Path<String>) -> Response {
298 let started = Instant::now();
299 let Some(session) = state.sessions.get(&id) else {
300 record(
301 &state,
302 crate::pipeline::DashboardEndpoint::Detail,
303 crate::pipeline::DashboardStatus::NotFound,
304 started,
305 );
306 let mut response = no_store_json_response(json!({"error": "session not found in registry"}));
307 *response.status_mut() = StatusCode::NOT_FOUND;
308 return response;
309 };
310 let summary = SessionSummary::from_session(&session);
311 let receipt_clone: Option<av_receipts::Receipt> = session.receipt.lock().clone();
314 let atif_filename: Option<String> = session
325 .atif_path
326 .lock()
327 .as_ref()
328 .and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned()));
329 let (chain_head_hex, chain_count) = {
333 let chain = session.chain.lock();
334 (chain.head_hex(), chain.count())
335 };
336 let receipt: Option<Value> = receipt_clone.and_then(|r| serde_json::to_value(&r).ok());
337 let response = no_store_json_response(json!({
338 "summary": summary,
339 "chain": {
340 "head_hex": chain_head_hex,
341 "count": chain_count,
342 },
343 "receipt": receipt,
344 "atif_filename": atif_filename,
345 }));
346 record(
347 &state,
348 crate::pipeline::DashboardEndpoint::Detail,
349 crate::pipeline::DashboardStatus::Ok,
350 started,
351 );
352 response
353}
354
355pub async fn stats(State(state): State<AppState>) -> Response {
357 let started = Instant::now();
358 let sessions = state.sessions.open_sessions_including_closed();
359 let mut totals = Stats {
360 generated_at_ms: av_core::time::now_ms(),
361 session_count: sessions.len(),
362 open_count: 0,
363 closed_count: 0,
364 capture_failed_count: 0,
365 total_prompt_tokens: 0,
366 total_completion_tokens: 0,
367 total_cached_tokens: 0,
368 total_cost_usd_micros: 0,
369 total_tool_calls: 0,
370 total_tool_allowed: 0,
371 total_tool_blocked: 0,
372 workflow_signed: 0,
373 workflow_unsigned: 0,
374 };
375 for session in &sessions {
376 if session.is_closed() {
377 totals.closed_count += 1;
378 } else {
379 totals.open_count += 1;
380 }
381 if session.capture_failed() {
382 totals.capture_failed_count += 1;
383 }
384 match session.workflow {
385 Workflow::Signed => totals.workflow_signed += 1,
386 Workflow::Unsigned => totals.workflow_unsigned += 1,
387 }
388 totals.total_prompt_tokens = totals
389 .total_prompt_tokens
390 .saturating_add(session.totals.prompt_tokens.load(Ordering::Acquire));
391 totals.total_completion_tokens = totals
392 .total_completion_tokens
393 .saturating_add(session.totals.completion_tokens.load(Ordering::Acquire));
394 totals.total_cached_tokens = totals
395 .total_cached_tokens
396 .saturating_add(session.totals.cached_tokens.load(Ordering::Acquire));
397 totals.total_cost_usd_micros = totals
398 .total_cost_usd_micros
399 .saturating_add(session.totals.cost_usd_micros.load(Ordering::Acquire));
400 totals.total_tool_calls = totals
401 .total_tool_calls
402 .saturating_add(session.totals.tool_calls.load(Ordering::Acquire));
403 totals.total_tool_allowed = totals
404 .total_tool_allowed
405 .saturating_add(session.totals.tool_allowed.load(Ordering::Acquire));
406 totals.total_tool_blocked = totals
407 .total_tool_blocked
408 .saturating_add(session.totals.tool_blocked.load(Ordering::Acquire));
409 }
410 let response = no_store_json_response(totals);
411 record(
412 &state,
413 crate::pipeline::DashboardEndpoint::Stats,
414 crate::pipeline::DashboardStatus::Ok,
415 started,
416 );
417 response
418}
419
420#[derive(Debug, Default, Clone, serde::Deserialize)]
422pub struct ListQuery {
423 limit: Option<usize>,
424 status: Option<String>,
425 workflow: Option<String>,
426}
427
428#[cfg(test)]
429mod tests {
430 #![allow(
431 clippy::unwrap_used,
432 clippy::expect_used,
433 clippy::panic,
434 clippy::indexing_slicing
435 )]
436
437 use crate::config::HarnessConfig;
438 use crate::pipeline::{AppState, SESSION_HEADER};
439 use crate::routes::build_router;
440 use av_bridge::{BusError, EventBus, PublishAck, StoredEvent};
441 use av_events::EventClass;
442 use av_receipts::Ed25519Signer;
443 use av_sandbox::{Sandbox, SandboxConfig};
444 use av_state::InMemoryStore;
445 use axum::body::{to_bytes, Body};
446 use axum::http::{header, HeaderValue, Method, Request, StatusCode};
447 use serde_json::Value;
448 use std::sync::Arc;
449 use tower::ServiceExt;
450
451 #[tokio::test]
456 async fn dashboard_assets_serve_their_bundles() {
457 for (handler, content_type, needle) in [
458 (super::style_css().await, "text/css; charset=utf-8", "{"),
459 (
460 super::app_js().await,
461 "text/javascript; charset=utf-8",
462 "function",
463 ),
464 (super::index().await, "text/html; charset=utf-8", "<html"),
465 ] {
466 assert_eq!(handler.status(), StatusCode::OK);
467 assert_eq!(
468 handler
469 .headers()
470 .get(header::CONTENT_TYPE)
471 .and_then(|value| value.to_str().ok()),
472 Some(content_type)
473 );
474 let body = to_bytes(handler.into_body(), 1024 * 1024).await.unwrap();
475 let text = String::from_utf8_lossy(&body);
476 assert!(
477 text.contains(needle),
478 "{content_type} asset must carry its bundle (missing {needle:?})"
479 );
480 }
481 }
482
483 struct NullBus;
484
485 impl EventBus for NullBus {
486 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
487 Ok(PublishAck {
488 topic: topic.to_owned(),
489 partition: 0,
490 offset: 0,
491 })
492 }
493
494 fn fetch(
495 &self,
496 _topic: &str,
497 _partition: u32,
498 _offset: u64,
499 _max: usize,
500 ) -> Result<Vec<StoredEvent>, BusError> {
501 Ok(Vec::new())
502 }
503
504 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
505 Ok(1)
506 }
507
508 fn topics(&self) -> Vec<String> {
509 EventClass::all()
510 .iter()
511 .map(|class| class.topic().to_owned())
512 .collect()
513 }
514 }
515
516 fn build_state(mut config: HarnessConfig) -> AppState {
517 config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
518 AppState::new(
519 config,
520 Arc::new(InMemoryStore::new()),
521 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
522 Arc::new(NullBus),
523 None,
524 Arc::new(Ed25519Signer::from_seed(&[9; 32])),
525 )
526 .unwrap()
527 }
528
529 async fn get_json(router: &axum::Router, path: &str) -> (StatusCode, Value) {
530 let response = router
531 .clone()
532 .oneshot(
533 Request::builder()
534 .method(Method::GET)
535 .uri(path)
536 .body(Body::empty())
537 .unwrap(),
538 )
539 .await
540 .unwrap();
541 let status = response.status();
542 let bytes = to_bytes(response.into_body(), 256 * 1024).await.unwrap();
543 let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
544 (status, value)
545 }
546
547 fn open_a_session(state: &AppState, id: &str) {
548 let mut headers = axum::http::HeaderMap::new();
549 headers.insert(SESSION_HEADER, HeaderValue::from_str(id).unwrap());
550 let payload = serde_json::json!({
551 "model": "test",
552 "messages": [{"role": "user", "content": "hello"}],
553 });
554 state.prepare_chat(&headers, payload, None).unwrap();
555 }
556
557 #[tokio::test]
558 async fn stats_endpoint_reports_empty_registry() {
559 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
560 let state = build_state(config);
561 let router = build_router(state);
562 let (status, body) = get_json(&router, "/api/v1/dashboard/stats").await;
563 assert_eq!(status, StatusCode::OK);
564 assert_eq!(body["session_count"], 0);
565 assert_eq!(body["open_count"], 0);
566 assert_eq!(body["closed_count"], 0);
567 assert_eq!(body["total_cost_usd_micros"], 0);
568 }
569
570 #[tokio::test]
571 async fn list_endpoint_returns_open_session() {
572 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
573 let state = build_state(config);
574 open_a_session(&state, "sess-abc");
575 let router = build_router(state);
576
577 let (status, body) = get_json(&router, "/api/v1/dashboard/sessions").await;
578 assert_eq!(status, StatusCode::OK);
579 let sessions = body["sessions"].as_array().unwrap();
580 assert_eq!(sessions.len(), 1);
581 assert_eq!(sessions[0]["id"], "sess-abc");
582 assert_eq!(sessions[0]["open"], true);
583 assert_eq!(sessions[0]["closed"], false);
584 assert_eq!(body["total_before_truncate"], 1);
585 }
586
587 #[tokio::test]
588 async fn list_endpoint_filters_by_status() {
589 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
590 let state = build_state(config);
591 open_a_session(&state, "sess-open");
592 open_a_session(&state, "sess-closed");
597 {
598 let session = state.sessions.get("sess-closed").unwrap();
599 assert!(session.try_close());
600 session.mark_artifact_committed();
601 session.mark_close_complete();
602 }
603 open_a_session(&state, "sess-failed");
604 state.sessions.get("sess-failed").unwrap().mark_capture_failed();
605 let router = build_router(state);
606
607 let (_, all) = get_json(&router, "/api/v1/dashboard/sessions?status=all").await;
608 assert_eq!(all["sessions"].as_array().unwrap().len(), 3);
609
610 let (_, only_closed) = get_json(&router, "/api/v1/dashboard/sessions?status=closed").await;
611 let closed: Vec<&str> = only_closed["sessions"]
612 .as_array()
613 .unwrap()
614 .iter()
615 .map(|s| s["id"].as_str().unwrap())
616 .collect();
617 assert_eq!(closed, ["sess-closed"]);
618
619 let (_, only_open) = get_json(&router, "/api/v1/dashboard/sessions?status=open").await;
620 let mut open: Vec<&str> = only_open["sessions"]
621 .as_array()
622 .unwrap()
623 .iter()
624 .map(|s| s["id"].as_str().unwrap())
625 .collect();
626 open.sort_unstable();
627 assert_eq!(open, ["sess-failed", "sess-open"]);
628
629 let (_, only_failed) = get_json(&router, "/api/v1/dashboard/sessions?status=failed").await;
630 let failed: Vec<&str> = only_failed["sessions"]
631 .as_array()
632 .unwrap()
633 .iter()
634 .map(|s| s["id"].as_str().unwrap())
635 .collect();
636 assert_eq!(failed, ["sess-failed"]);
637
638 let (_, stats) = get_json(&router, "/api/v1/dashboard/stats").await;
640 assert_eq!(stats["session_count"], 3);
641 assert_eq!(stats["open_count"], 2);
642 assert_eq!(stats["closed_count"], 1);
643 assert_eq!(stats["capture_failed_count"], 1);
644 assert_eq!(
647 stats["workflow_signed"].as_u64().unwrap() + stats["workflow_unsigned"].as_u64().unwrap(),
648 3,
649 "the workflow partition must cover the whole registry"
650 );
651 }
652
653 #[tokio::test]
654 async fn detail_endpoint_returns_summary_shape() {
655 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
656 let state = build_state(config);
657 open_a_session(&state, "sess-detail");
658 let router = build_router(state);
659
660 let (status, body) = get_json(&router, "/api/v1/dashboard/sessions/sess-detail").await;
661 assert_eq!(status, StatusCode::OK);
662 assert_eq!(body["summary"]["id"], "sess-detail");
663 assert!(body["chain"].is_object());
664 assert!(body["receipt"].is_null(), "no receipt until session close");
665 }
666
667 #[tokio::test]
668 async fn detail_endpoint_404_for_unknown_id() {
669 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
670 let router = build_router(build_state(config));
671 let (status, _body) = get_json(&router, "/api/v1/dashboard/sessions/does-not-exist").await;
672 assert_eq!(status, StatusCode::NOT_FOUND);
673 }
674
675 #[tokio::test]
676 async fn dashboard_index_served_as_html() {
677 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
678 let router = build_router(build_state(config));
679 let response = router
680 .oneshot(
681 Request::builder()
682 .method(Method::GET)
683 .uri("/dashboard")
684 .body(Body::empty())
685 .unwrap(),
686 )
687 .await
688 .unwrap();
689 assert_eq!(response.status(), StatusCode::OK);
690 let ct = response
691 .headers()
692 .get(header::CONTENT_TYPE)
693 .cloned()
694 .unwrap_or_else(|| HeaderValue::from_static(""));
695 assert!(
696 ct.to_str().unwrap().starts_with("text/html"),
697 "unexpected content-type: {ct:?}"
698 );
699 let body = to_bytes(response.into_body(), 256 * 1024).await.unwrap();
700 let text = std::str::from_utf8(&body).unwrap();
701 assert!(text.contains("AgentVisor AI"), "index should mention product");
702 }
703
704 #[tokio::test]
705 async fn dashboard_disabled_returns_404() {
706 let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
707 config.dashboard_enabled = false;
708 let router = build_router(build_state(config));
709 let response = router
710 .oneshot(
711 Request::builder()
712 .method(Method::GET)
713 .uri("/dashboard")
714 .body(Body::empty())
715 .unwrap(),
716 )
717 .await
718 .unwrap();
719 assert_eq!(response.status(), StatusCode::NOT_FOUND);
720 }
721
722 #[tokio::test]
723 async fn list_endpoint_reports_matched_count_and_registry_total() {
724 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
725 let state = build_state(config);
726 open_a_session(&state, "sess-open-1");
727 open_a_session(&state, "sess-open-2");
728 let router = build_router(state);
729
730 let (_, all) = get_json(&router, "/api/v1/dashboard/sessions").await;
731 assert_eq!(all["total_before_truncate"], 2);
732 assert_eq!(all["matched"], 2);
733
734 let (_, closed_only) = get_json(&router, "/api/v1/dashboard/sessions?status=closed").await;
735 assert_eq!(closed_only["total_before_truncate"], 2);
738 assert_eq!(closed_only["matched"], 0);
739 assert_eq!(closed_only["sessions"].as_array().unwrap().len(), 0);
740 }
741
742 #[tokio::test]
743 async fn list_endpoint_limit_clamps_at_max() {
744 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
745 let state = build_state(config);
746 for i in 0..3 {
747 open_a_session(&state, &format!("sess-{i}"));
748 }
749 let router = build_router(state);
750 let (_, over) = get_json(&router, "/api/v1/dashboard/sessions?limit=99999").await;
751 assert_eq!(over["sessions"].as_array().unwrap().len(), 3);
753
754 let (_, zero) = get_json(&router, "/api/v1/dashboard/sessions?limit=0").await;
755 assert_eq!(zero["sessions"].as_array().unwrap().len(), 0);
756 assert_eq!(zero["matched"], 3, "limit does not hide the matched total");
757 }
758
759 #[tokio::test]
760 async fn list_endpoint_returns_400_on_invalid_limit() {
761 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
762 let router = build_router(build_state(config));
763 let response = router
764 .oneshot(
765 Request::builder()
766 .method(Method::GET)
767 .uri("/api/v1/dashboard/sessions?limit=abc")
768 .body(Body::empty())
769 .unwrap(),
770 )
771 .await
772 .unwrap();
773 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
774 }
775
776 #[tokio::test]
777 async fn detail_endpoint_returns_receipt_when_present() {
778 use av_receipts::{CostSummary, Receipt, ReceiptBody, ReceiptSubject, Signer as _, ToolCallSummary};
783 use base64::Engine as _;
784 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
785 let state = build_state(config);
786 open_a_session(&state, "sess-with-receipt");
787 let session = state.sessions.get("sess-with-receipt").unwrap();
788 let signer = Ed25519Signer::from_seed(&[7; 32]);
789 let key_id = signer.key_id().to_owned();
790 let public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
791 let body = ReceiptBody {
792 receipt_version: 1,
793 receipt_id: "test-receipt".to_owned(),
794 session_id: session.id.clone(),
795 issued_at: 1_700_000_000_000,
796 issued_at_iso: "2023-11-14T22:13:20.000Z".to_owned(),
797 ai_agent: session.identity.clone(),
798 subject: ReceiptSubject::EventChain {
799 chain_head: "ab".repeat(32),
800 event_count: 1,
801 },
802 tool_calls: ToolCallSummary::default(),
803 cost: CostSummary::default(),
804 stop_reason_id: 1,
805 stop_reason: "Stop".to_owned(),
806 key_id,
807 public_key_b64,
808 };
809 let receipt = Receipt::issue(body, &signer).expect("sign");
810 session.restore_receipt(receipt.clone());
811 let router = build_router(state.clone());
812 let (status, body) = get_json(&router, "/api/v1/dashboard/sessions/sess-with-receipt").await;
813 assert_eq!(status, StatusCode::OK);
814 assert_eq!(body["summary"]["has_receipt"], true);
815 assert_eq!(body["receipt"]["receipt_id"], "test-receipt");
816 assert!(
817 !body["receipt"]["signature_b64"].as_str().unwrap().is_empty(),
818 "signature must round-trip",
819 );
820 }
821
822 #[tokio::test]
823 async fn stats_endpoint_aggregates_totals_across_sessions() {
824 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
825 let state = build_state(config);
826 open_a_session(&state, "sess-a");
827 open_a_session(&state, "sess-b");
828 let sess_a = state.sessions.get("sess-a").unwrap();
829 let sess_b = state.sessions.get("sess-b").unwrap();
830 sess_a
831 .totals
832 .cost_usd_micros
833 .store(1_000_000, std::sync::atomic::Ordering::Release);
834 sess_a
835 .totals
836 .tool_calls
837 .store(5, std::sync::atomic::Ordering::Release);
838 sess_a
839 .totals
840 .tool_blocked
841 .store(1, std::sync::atomic::Ordering::Release);
842 sess_b
843 .totals
844 .cost_usd_micros
845 .store(500_000, std::sync::atomic::Ordering::Release);
846 sess_b
847 .totals
848 .tool_calls
849 .store(2, std::sync::atomic::Ordering::Release);
850 let router = build_router(state.clone());
851 let (status, body) = get_json(&router, "/api/v1/dashboard/stats").await;
852 assert_eq!(status, StatusCode::OK);
853 assert_eq!(body["session_count"], 2);
854 assert_eq!(body["total_cost_usd_micros"], 1_500_000);
855 assert_eq!(body["total_tool_calls"], 7);
856 assert_eq!(body["total_tool_blocked"], 1);
857 }
858
859 #[tokio::test]
860 async fn dashboard_endpoints_register_prometheus_metrics() {
861 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
862 let state = build_state(config);
863 open_a_session(&state, "sess-metrics");
864 let metrics_registry = std::sync::Arc::clone(&state.metrics);
865 let router = build_router(state.clone());
866 let (_, _) = get_json(&router, "/api/v1/dashboard/stats").await;
867 let (_, _) = get_json(&router, "/api/v1/dashboard/sessions").await;
868 let (_, _) = get_json(&router, "/api/v1/dashboard/sessions/sess-metrics").await;
869 let (_, _) = get_json(&router, "/api/v1/dashboard/sessions/does-not-exist").await;
870 let rendered = metrics_registry.render();
871 for line in [
872 "av_dashboard_requests_total{endpoint=\"stats\",status=\"ok\"} 1",
873 "av_dashboard_requests_total{endpoint=\"list\",status=\"ok\"} 1",
874 "av_dashboard_requests_total{endpoint=\"detail\",status=\"ok\"} 1",
875 "av_dashboard_requests_total{endpoint=\"detail\",status=\"not_found\"} 1",
876 "av_dashboard_request_duration_seconds_count{endpoint=\"stats\"} 1",
877 ] {
878 assert!(
879 rendered.contains(line),
880 "expected `{line}` in metrics output:\n{rendered}",
881 );
882 }
883 }
884
885 #[tokio::test]
886 async fn dashboard_survives_concurrent_reads_under_hot_path_writes() {
887 let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
892 let state = build_state(config);
893 for i in 0..64 {
896 open_a_session(&state, &format!("seed-{i}"));
897 }
898 let router = build_router(state.clone());
899
900 let mut readers = Vec::new();
901 for _ in 0..128 {
902 let router = router.clone();
903 readers.push(tokio::spawn(async move {
904 let (status, body) = get_json(&router, "/api/v1/dashboard/stats").await;
905 assert_eq!(status, StatusCode::OK);
906 assert!(body["session_count"].as_u64().unwrap() >= 64);
907 }));
908 }
909 let mut writers = Vec::new();
911 for i in 64..96 {
912 let state = state.clone();
913 writers.push(tokio::spawn(async move {
914 let mut headers = axum::http::HeaderMap::new();
915 headers.insert(
916 SESSION_HEADER,
917 HeaderValue::from_str(&format!("hot-{i}")).unwrap(),
918 );
919 let payload = serde_json::json!({
920 "model": "test",
921 "messages": [{"role":"user","content":"go"}],
922 });
923 state.prepare_chat(&headers, payload, None).unwrap();
924 }));
925 }
926 for reader in readers {
927 reader.await.unwrap();
928 }
929 for writer in writers {
930 writer.await.unwrap();
931 }
932 }
933}