Skip to main content

av_harness/
dashboard.rs

1//! Read-only operator dashboard.
2//!
3//! Serves a single-page HTML/CSS/JS application at `/dashboard` and a small
4//! JSON API under `/api/v1/dashboard/*` that lets the page render live
5//! sessions, per-session details, and aggregate stats. The assets are
6//! bundled into the binary via [`include_str!`] so there is no filesystem
7//! dependency at runtime.
8//!
9//! Trust posture: the dashboard exposes the same session metadata that
10//! already lands on disk and in `/metrics`. It is not authenticated by
11//! itself — front it with the same ingress-level control operators use
12//! for `/metrics`. There is no mutating endpoint here; every route is
13//! `GET`.
14
15use 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
31/// Sorted, capped list of session summaries. `?limit=N` clamped to
32/// `MAX_LIST_LIMIT`; the default is [`DEFAULT_LIST_LIMIT`].
33pub const DEFAULT_LIST_LIMIT: usize = 50;
34/// Upper bound on the `?limit=N` query parameter for the sessions list.
35pub const MAX_LIST_LIMIT: usize = 500;
36
37/// Record latency + outcome for a dashboard request. Kept next to the
38/// handlers so every endpoint funnels through one registration point —
39/// a new endpoint that calls `record()` is instrumented automatically,
40/// and one that doesn't shows up as a missing series in the
41/// dashboard-metrics tests (render() itself never panics on absent keys).
42fn 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
142/// GET /dashboard — HTML shell.
143pub async fn index() -> Response {
144    static_asset(INDEX_HTML, "text/html; charset=utf-8")
145}
146
147/// GET /dashboard/style.css — dashboard CSS.
148pub async fn style_css() -> Response {
149    static_asset(STYLE_CSS, "text/css; charset=utf-8")
150}
151
152/// GET /dashboard/app.js — dashboard app JS.
153pub 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    // Round-28 F2: defense-in-depth against a future XSS regression
171    // in `highlightJson` or any innerHTML-writing dashboard code.
172    // The dashboard is a same-origin single-page app that only
173    // needs its own JS/CSS/img and its own /api/v1/dashboard/*
174    // fetches; a strict CSP shrinks the blast radius to zero even
175    // if attacker-controlled text ever reaches innerHTML.
176    // `frame-ancestors 'none'` + `X-Frame-Options: DENY` block
177    // clickjacking / iframe embed for the day the dashboard grows
178    // an identity gate. `Referrer-Policy: no-referrer` prevents
179    // session ids from leaking through an outbound link.
180    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
199/// Round-17 F4: wrap dashboard JSON responses with headers that
200/// forbid any intermediary shared cache from re-serving one caller's
201/// session detail to another. The static HTML/CSS/JS assets already
202/// set these; the JSON handlers didn't. Also sets `Vary:
203/// Authorization` for the day the dashboard gets an identity gate.
204fn 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    // Round-28 F2: mirror the same anti-XSS / anti-clickjacking /
221    // anti-referrer-leak headers on JSON responses. A future
222    // dashboard client that navigates directly to
223    // /api/v1/dashboard/sessions/{id} in a top-level window must
224    // not become a referrer source for session-id leakage, and
225    // must not be framable.
226    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
240/// GET /api/v1/dashboard/sessions?limit=&status=
241pub 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 number of sessions currently in the registry (before any
271        // filter is applied). This is what /stats also sees.
272        "total_before_truncate": total_before_truncate,
273        // Number of sessions that matched the requested filter (before the
274        // `limit` cap). Useful for pagination hints and diagnostics.
275        "matched": matched,
276    }));
277    record(&state, "list", "ok", started);
278    response
279}
280
281/// GET /api/v1/dashboard/sessions/{id}
282pub 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    // Clone out under the lock and drop it before serialization so the
292    // reconciler / worker never blocks on a slow serde_json step.
293    let receipt_clone: Option<av_receipts::Receipt> = session.receipt.lock().clone();
294    // Round-27 F6: previously returned `path.display().to_string()`,
295    // which disclosed the absolute spool directory (e.g.
296    // `/var/lib/agentvisor-ai/spool/<uid>.json`) to any unauthenticated
297    // caller of the dashboard. The dashboard's design intent (see
298    // module doc-comment) is "the same session metadata that lands on
299    // disk and in /metrics" — filesystem paths appear in neither.
300    // Disclosing the absolute layout gives a probe attacker a
301    // starting point for later privilege-escalation or path-
302    // traversal attempts. Return only the file name; the UI never
303    // uses the path for anything besides display.
304    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    // Chain head/count are exposed as a small provenance stub so the UI can
310    // show "47 events, head=b2b7…". The full chain lives in the journal on
311    // disk; we don't stream it through the dashboard.
312    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
330/// GET /api/v1/dashboard/stats
331pub 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/// Query parameters for `GET /api/v1/dashboard/sessions`.
391#[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        // total_before_truncate reflects the registry size (2), matched
625        // only counts sessions passing the filter (0 closed).
626        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        // 3 sessions, cap is MAX_LIST_LIMIT — should return the 3 we have.
641        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        // Round-trip a signed receipt into a Session and confirm the
668        // dashboard surfaces it as JSON with the signature intact —
669        // catching any regression that would silently drop cryptographic
670        // material from the operator view.
671        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        // 128 parallel dashboard reads while the hot path is admitting
777        // more sessions — verifies the observability code does not
778        // deadlock, crash on shard lock contention, or panic on a
779        // registry that grows mid-scan.
780        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
781        let state = build_state(config);
782        // Seed enough sessions that iteration touches every DashMap
783        // shard and shows non-trivial payloads to the readers.
784        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        // Simultaneously open more sessions on the hot path.
799        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}