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