Skip to main content

sqz_engine/
dashboard.rs

1//! Local web dashboard with real-time metrics via SSE.
2//!
3//! Serves a self-contained HTML page (inline CSS/JS, zero external network
4//! requests) on a configurable port.  An SSE endpoint pushes updated metrics
5//! every 5 seconds.
6
7use std::fmt::Write as FmtWrite;
8use std::io::{BufRead, BufReader, Write};
9use std::net::TcpListener;
10use std::sync::{Arc, Mutex};
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14
15use crate::session_store::SessionSummary;
16
17// ---------------------------------------------------------------------------
18// Metrics data model
19// ---------------------------------------------------------------------------
20
21/// Per-tool token/cost breakdown.
22#[derive(Debug, Clone, Default)]
23pub struct ToolBreakdown {
24    pub tool_name: String,
25    pub tokens_input: u64,
26    pub tokens_output: u64,
27    pub cost_usd: f64,
28    pub call_count: u32,
29}
30
31/// Per-command compression breakdown.
32#[derive(Debug, Clone, Default)]
33pub struct CommandBreakdown {
34    pub command: String,
35    pub tokens_original: u64,
36    pub tokens_compressed: u64,
37    pub invocations: u32,
38}
39
40/// Session history entry for the dashboard.
41#[derive(Debug, Clone)]
42pub struct SessionHistoryEntry {
43    pub id: String,
44    pub project_dir: String,
45    pub summary: String,
46    pub created_at: DateTime<Utc>,
47    pub updated_at: DateTime<Utc>,
48    pub total_tokens: u64,
49    pub cost_usd: f64,
50}
51
52impl From<&SessionSummary> for SessionHistoryEntry {
53    fn from(s: &SessionSummary) -> Self {
54        SessionHistoryEntry {
55            id: s.id.clone(),
56            project_dir: s.project_dir.display().to_string(),
57            summary: s.compressed_summary.clone(),
58            created_at: s.created_at,
59            updated_at: s.updated_at,
60            total_tokens: 0,
61            cost_usd: 0.0,
62        }
63    }
64}
65
66/// All metrics exposed by the dashboard.
67#[derive(Debug, Clone)]
68pub struct DashboardMetrics {
69    // Real-time counters
70    pub tokens_saved: u64,
71    pub tokens_total: u64,
72    pub compression_ratio: f64,
73    pub cache_hits: u64,
74    pub cache_misses: u64,
75    pub cost_savings_usd: f64,
76    pub total_cost_usd: f64,
77
78    // Active session
79    pub active_session_id: Option<String>,
80    pub active_model: Option<String>,
81    pub budget_consumed_pct: f64,
82
83    // Breakdowns
84    pub per_tool: Vec<ToolBreakdown>,
85    pub per_command: Vec<CommandBreakdown>,
86
87    // Session history
88    pub sessions: Vec<SessionHistoryEntry>,
89
90    /// Timestamp of this snapshot.
91    pub snapshot_at: DateTime<Utc>,
92}
93
94impl Default for DashboardMetrics {
95    fn default() -> Self {
96        DashboardMetrics {
97            tokens_saved: 0,
98            tokens_total: 0,
99            compression_ratio: 0.0,
100            cache_hits: 0,
101            cache_misses: 0,
102            cost_savings_usd: 0.0,
103            total_cost_usd: 0.0,
104            active_session_id: None,
105            active_model: None,
106            budget_consumed_pct: 0.0,
107            per_tool: Vec::new(),
108            per_command: Vec::new(),
109            sessions: Vec::new(),
110            snapshot_at: Utc::now(),
111        }
112    }
113}
114
115impl DashboardMetrics {
116    /// Cache hit rate as a percentage (0.0–100.0).
117    pub fn cache_hit_rate(&self) -> f64 {
118        let total = self.cache_hits + self.cache_misses;
119        if total == 0 {
120            return 0.0;
121        }
122        (self.cache_hits as f64 / total as f64) * 100.0
123    }
124
125    /// Serialize metrics to a JSON string for SSE delivery.
126    pub fn to_json(&self) -> String {
127        let mut s = String::with_capacity(2048);
128        s.push('{');
129
130        // Scalars
131        let _ = write!(
132            s,
133            "\"tokens_saved\":{},\"tokens_total\":{},\"compression_ratio\":{:.4},\
134             \"cache_hit_rate\":{:.2},\"cache_hits\":{},\"cache_misses\":{},\
135             \"cost_savings_usd\":{:.6},\"total_cost_usd\":{:.6},\
136             \"budget_consumed_pct\":{:.2}",
137            self.tokens_saved,
138            self.tokens_total,
139            self.compression_ratio,
140            self.cache_hit_rate(),
141            self.cache_hits,
142            self.cache_misses,
143            self.cost_savings_usd,
144            self.total_cost_usd,
145            self.budget_consumed_pct,
146        );
147
148        // Active session
149        if let Some(ref id) = self.active_session_id {
150            let _ = write!(s, ",\"active_session_id\":\"{}\"", escape_json(id));
151        } else {
152            s.push_str(",\"active_session_id\":null");
153        }
154        if let Some(ref model) = self.active_model {
155            let _ = write!(s, ",\"active_model\":\"{}\"", escape_json(model));
156        } else {
157            s.push_str(",\"active_model\":null");
158        }
159
160        // Per-tool
161        s.push_str(",\"per_tool\":[");
162        for (i, t) in self.per_tool.iter().enumerate() {
163            if i > 0 {
164                s.push(',');
165            }
166            let _ = write!(
167                s,
168                "{{\"tool_name\":\"{}\",\"tokens_input\":{},\"tokens_output\":{},\
169                 \"cost_usd\":{:.6},\"call_count\":{}}}",
170                escape_json(&t.tool_name),
171                t.tokens_input,
172                t.tokens_output,
173                t.cost_usd,
174                t.call_count,
175            );
176        }
177        s.push(']');
178
179        // Per-command
180        s.push_str(",\"per_command\":[");
181        for (i, c) in self.per_command.iter().enumerate() {
182            if i > 0 {
183                s.push(',');
184            }
185            let _ = write!(
186                s,
187                "{{\"command\":\"{}\",\"tokens_original\":{},\"tokens_compressed\":{},\
188                 \"invocations\":{}}}",
189                escape_json(&c.command),
190                c.tokens_original,
191                c.tokens_compressed,
192                c.invocations,
193            );
194        }
195        s.push(']');
196
197        // Sessions (compact)
198        s.push_str(",\"sessions\":[");
199        for (i, sess) in self.sessions.iter().enumerate() {
200            if i > 0 {
201                s.push(',');
202            }
203            let _ = write!(
204                s,
205                "{{\"id\":\"{}\",\"project_dir\":\"{}\",\"summary\":\"{}\",\
206                 \"created_at\":\"{}\",\"total_tokens\":{},\"cost_usd\":{:.6}}}",
207                escape_json(&sess.id),
208                escape_json(&sess.project_dir),
209                escape_json(&sess.summary),
210                sess.created_at.to_rfc3339(),
211                sess.total_tokens,
212                sess.cost_usd,
213            );
214        }
215        s.push(']');
216
217        let _ = write!(s, ",\"snapshot_at\":\"{}\"", self.snapshot_at.to_rfc3339());
218        s.push('}');
219        s
220    }
221}
222
223/// Minimal JSON string escaping.
224fn escape_json(s: &str) -> String {
225    let mut out = String::with_capacity(s.len());
226    for ch in s.chars() {
227        match ch {
228            '"' => out.push_str("\\\""),
229            '\\' => out.push_str("\\\\"),
230            '\n' => out.push_str("\\n"),
231            '\r' => out.push_str("\\r"),
232            '\t' => out.push_str("\\t"),
233            c if (c as u32) < 0x20 => {
234                let _ = write!(out, "\\u{:04x}", c as u32);
235            }
236            c => out.push(c),
237        }
238    }
239    out
240}
241
242// ---------------------------------------------------------------------------
243// HTML generation
244// ---------------------------------------------------------------------------
245
246/// Generates a self-contained HTML dashboard page with inline CSS and JS.
247pub struct DashboardHtml;
248
249impl DashboardHtml {
250    /// Render the full HTML page.  The page uses SSE to auto-refresh metrics
251    /// from `/events` every 5 seconds.
252    pub fn render(_port: u16) -> String {
253        format!(
254            r##"<!DOCTYPE html>
255<html lang="en">
256<head>
257<meta charset="utf-8">
258<meta name="viewport" content="width=device-width,initial-scale=1">
259<title>sqz dashboard</title>
260<style>
261*{{margin:0;padding:0;box-sizing:border-box}}
262body{{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e1e4e8;padding:1.5rem}}
263h1{{font-size:1.4rem;margin-bottom:1rem;color:#58a6ff}}
264.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}}
265.card{{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1rem}}
266.card .label{{font-size:.75rem;color:#8b949e;text-transform:uppercase;letter-spacing:.05em}}
267.card .value{{font-size:1.6rem;font-weight:700;margin-top:.25rem}}
268.green{{color:#3fb950}} .blue{{color:#58a6ff}} .orange{{color:#d29922}} .red{{color:#f85149}}
269h2{{font-size:1.1rem;margin:1.2rem 0 .6rem;color:#c9d1d9}}
270table{{width:100%;border-collapse:collapse;margin-bottom:1rem}}
271th,td{{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #21262d;font-size:.85rem}}
272th{{color:#8b949e;font-weight:600}}
273#search{{background:#0d1117;border:1px solid #30363d;color:#e1e4e8;padding:.4rem .6rem;border-radius:4px;width:260px;margin-bottom:.6rem;font-size:.85rem}}
274.status{{font-size:.8rem;color:#8b949e;text-align:right;margin-top:1rem}}
275</style>
276</head>
277<body>
278<h1>sqz dashboard</h1>
279
280<div class="grid">
281  <div class="card"><div class="label">Tokens Saved</div><div class="value green" id="m-saved">—</div></div>
282  <div class="card"><div class="label">Compression Ratio</div><div class="value blue" id="m-ratio">—</div></div>
283  <div class="card"><div class="label">Cache Hit Rate</div><div class="value blue" id="m-cache">—</div></div>
284  <div class="card"><div class="label">Cost Savings</div><div class="value green" id="m-cost">—</div></div>
285  <div class="card"><div class="label">Total Cost</div><div class="value orange" id="m-total">—</div></div>
286  <div class="card"><div class="label">Budget Used</div><div class="value" id="m-budget">—</div></div>
287</div>
288
289<h2>Per-Tool Breakdown</h2>
290<table id="tool-table">
291<thead><tr><th>Tool</th><th>Input Tokens</th><th>Output Tokens</th><th>Cost (USD)</th><th>Calls</th></tr></thead>
292<tbody></tbody>
293</table>
294
295<h2>Per-Command Breakdown</h2>
296<table id="cmd-table">
297<thead><tr><th>Command</th><th>Original</th><th>Compressed</th><th>Ratio</th><th>Runs</th></tr></thead>
298<tbody></tbody>
299</table>
300
301<h2>Session History</h2>
302<input id="search" placeholder="Search sessions…" aria-label="Search sessions">
303<table id="sess-table">
304<thead><tr><th>ID</th><th>Project</th><th>Summary</th><th>Created</th><th>Tokens</th><th>Cost</th></tr></thead>
305<tbody></tbody>
306</table>
307
308<div class="status" id="status">Connecting…</div>
309
310<script>
311(function(){{
312  var es=new EventSource('/events');
313  var statusEl=document.getElementById('status');
314  var searchEl=document.getElementById('search');
315  var lastData=null;
316
317  es.onmessage=function(e){{
318    var d=JSON.parse(e.data);
319    lastData=d;
320    render(d);
321    statusEl.textContent='Updated '+new Date().toLocaleTimeString();
322  }};
323  es.onerror=function(){{statusEl.textContent='Disconnected — retrying…';}};
324
325  searchEl.addEventListener('input',function(){{if(lastData)renderSessions(lastData.sessions);}});
326
327  function render(d){{
328    document.getElementById('m-saved').textContent=fmt(d.tokens_saved);
329    document.getElementById('m-ratio').textContent=(d.compression_ratio*100).toFixed(1)+'%';
330    document.getElementById('m-cache').textContent=d.cache_hit_rate.toFixed(1)+'%';
331    document.getElementById('m-cost').textContent='$'+d.cost_savings_usd.toFixed(4);
332    document.getElementById('m-total').textContent='$'+d.total_cost_usd.toFixed(4);
333    var bp=d.budget_consumed_pct;
334    var budgetEl=document.getElementById('m-budget');
335    budgetEl.textContent=bp.toFixed(1)+'%';
336    budgetEl.className='value '+(bp>85?'red':bp>70?'orange':'green');
337
338    renderTable('tool-table',d.per_tool,function(t){{
339      return '<td>'+esc(t.tool_name)+'</td><td>'+fmt(t.tokens_input)+'</td><td>'+fmt(t.tokens_output)+'</td><td>$'+t.cost_usd.toFixed(4)+'</td><td>'+t.call_count+'</td>';
340    }});
341    renderTable('cmd-table',d.per_command,function(c){{
342      var r=c.tokens_original?((c.tokens_compressed/c.tokens_original)*100).toFixed(1)+'%':'—';
343      return '<td>'+esc(c.command)+'</td><td>'+fmt(c.tokens_original)+'</td><td>'+fmt(c.tokens_compressed)+'</td><td>'+r+'</td><td>'+c.invocations+'</td>';
344    }});
345    renderSessions(d.sessions);
346  }}
347
348  function renderSessions(sessions){{
349    var q=(searchEl.value||'').toLowerCase();
350    var filtered=sessions.filter(function(s){{
351      if(!q)return true;
352      return (s.id+s.project_dir+s.summary).toLowerCase().indexOf(q)>=0;
353    }});
354    renderTable('sess-table',filtered,function(s){{
355      return '<td>'+esc(s.id)+'</td><td>'+esc(s.project_dir)+'</td><td>'+esc(s.summary)+'</td><td>'+new Date(s.created_at).toLocaleDateString()+'</td><td>'+fmt(s.total_tokens)+'</td><td>$'+s.cost_usd.toFixed(4)+'</td>';
356    }});
357  }}
358
359  function renderTable(id,rows,rowFn){{
360    var tb=document.getElementById(id).querySelector('tbody');
361    tb.innerHTML=rows.map(function(r){{return '<tr>'+rowFn(r)+'</tr>';}}).join('');
362  }}
363
364  function fmt(n){{
365    if(n>=1e6)return (n/1e6).toFixed(1)+'M';
366    if(n>=1e3)return (n/1e3).toFixed(1)+'K';
367    return ''+n;
368  }}
369
370  function esc(s){{
371    var d=document.createElement('div');d.textContent=s||'';return d.innerHTML;
372  }}
373}})();
374</script>
375</body>
376</html>"##,
377        )
378    }
379}
380
381
382// ---------------------------------------------------------------------------
383// Dashboard server (minimal TCP-based)
384// ---------------------------------------------------------------------------
385
386/// Configuration for the dashboard server.
387#[derive(Debug, Clone)]
388pub struct DashboardConfig {
389    pub port: u16,
390}
391
392impl Default for DashboardConfig {
393    fn default() -> Self {
394        DashboardConfig { port: 3001 }
395    }
396}
397
398/// A minimal HTTP server that serves the dashboard HTML and an SSE endpoint.
399///
400/// The server uses a shared `Arc<Mutex<DashboardMetrics>>` so that an
401/// external thread can update the metrics while the server pushes them to
402/// connected SSE clients.
403pub struct DashboardServer {
404    config: DashboardConfig,
405    metrics: Arc<Mutex<DashboardMetrics>>,
406}
407
408impl DashboardServer {
409    /// Create a new server with the given config and a shared metrics handle.
410    pub fn new(config: DashboardConfig, metrics: Arc<Mutex<DashboardMetrics>>) -> Self {
411        DashboardServer { config, metrics }
412    }
413
414    /// Return a clone of the shared metrics handle so callers can update it.
415    pub fn metrics_handle(&self) -> Arc<Mutex<DashboardMetrics>> {
416        Arc::clone(&self.metrics)
417    }
418
419    /// Start listening.  This blocks the calling thread.
420    ///
421    /// For each incoming connection the server reads the HTTP request line,
422    /// then either:
423    /// - `GET /`        → responds with the full HTML page
424    /// - `GET /events`  → responds with an SSE stream (pushes metrics JSON
425    ///                     every 5 seconds until the client disconnects)
426    /// - anything else  → 404
427    pub fn run(&self) -> crate::error::Result<()> {
428        let addr = format!("127.0.0.1:{}", self.config.port);
429        let listener = TcpListener::bind(&addr)?;
430        eprintln!("[sqz] dashboard listening on http://{addr}");
431
432        let html = DashboardHtml::render(self.config.port);
433
434        for stream in listener.incoming() {
435            let mut stream = match stream {
436                Ok(s) => s,
437                Err(_) => continue,
438            };
439
440            // Read the request line.
441            let mut reader = BufReader::new(stream.try_clone().unwrap_or_else(|_| {
442                // Fallback: just use the stream directly (shouldn't happen).
443                stream.try_clone().expect("clone failed")
444            }));
445            let mut request_line = String::new();
446            if reader.read_line(&mut request_line).is_err() {
447                continue;
448            }
449
450            // Drain remaining headers (we don't need them).
451            let mut header = String::new();
452            loop {
453                header.clear();
454                match reader.read_line(&mut header) {
455                    Ok(0) | Err(_) => break,
456                    Ok(_) => {
457                        if header.trim().is_empty() {
458                            break;
459                        }
460                    }
461                }
462            }
463
464            if request_line.starts_with("GET /events") {
465                // SSE endpoint
466                let response_header = "HTTP/1.1 200 OK\r\n\
467                    Content-Type: text/event-stream\r\n\
468                    Cache-Control: no-cache\r\n\
469                    Connection: keep-alive\r\n\
470                    Access-Control-Allow-Origin: *\r\n\r\n";
471                if stream.write_all(response_header.as_bytes()).is_err() {
472                    continue;
473                }
474
475                // Push metrics every 5 seconds until the client disconnects.
476                loop {
477                    let json = {
478                        let m = self.metrics.lock().unwrap();
479                        m.to_json()
480                    };
481                    let event = format!("data: {json}\n\n");
482                    if stream.write_all(event.as_bytes()).is_err() {
483                        break;
484                    }
485                    if stream.flush().is_err() {
486                        break;
487                    }
488                    std::thread::sleep(Duration::from_secs(5));
489                }
490            } else if request_line.starts_with("GET / ")
491                || request_line.starts_with("GET / HTTP")
492            {
493                // Serve HTML
494                let response = format!(
495                    "HTTP/1.1 200 OK\r\n\
496                     Content-Type: text/html; charset=utf-8\r\n\
497                     Content-Length: {}\r\n\
498                     Connection: close\r\n\r\n{}",
499                    html.len(),
500                    html,
501                );
502                let _ = stream.write_all(response.as_bytes());
503            } else {
504                let body = "404 Not Found";
505                let response = format!(
506                    "HTTP/1.1 404 Not Found\r\n\
507                     Content-Type: text/plain\r\n\
508                     Content-Length: {}\r\n\
509                     Connection: close\r\n\r\n{}",
510                    body.len(),
511                    body,
512                );
513                let _ = stream.write_all(response.as_bytes());
514            }
515        }
516
517        Ok(())
518    }
519}
520
521// ---------------------------------------------------------------------------
522// Tests
523// ---------------------------------------------------------------------------
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    // -----------------------------------------------------------------------
530    // DashboardMetrics
531    // -----------------------------------------------------------------------
532
533    #[test]
534    fn test_default_metrics() {
535        let m = DashboardMetrics::default();
536        assert_eq!(m.tokens_saved, 0);
537        assert_eq!(m.tokens_total, 0);
538        assert_eq!(m.cache_hits, 0);
539        assert_eq!(m.cache_misses, 0);
540        assert!((m.compression_ratio - 0.0).abs() < f64::EPSILON);
541        assert!(m.per_tool.is_empty());
542        assert!(m.per_command.is_empty());
543        assert!(m.sessions.is_empty());
544    }
545
546    #[test]
547    fn test_cache_hit_rate_zero_total() {
548        let m = DashboardMetrics::default();
549        assert!((m.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
550    }
551
552    #[test]
553    fn test_cache_hit_rate_calculation() {
554        let mut m = DashboardMetrics::default();
555        m.cache_hits = 75;
556        m.cache_misses = 25;
557        assert!((m.cache_hit_rate() - 75.0).abs() < 0.01);
558    }
559
560    #[test]
561    fn test_cache_hit_rate_all_hits() {
562        let mut m = DashboardMetrics::default();
563        m.cache_hits = 100;
564        m.cache_misses = 0;
565        assert!((m.cache_hit_rate() - 100.0).abs() < f64::EPSILON);
566    }
567
568    // -----------------------------------------------------------------------
569    // JSON serialization
570    // -----------------------------------------------------------------------
571
572    #[test]
573    fn test_to_json_default_metrics() {
574        let m = DashboardMetrics::default();
575        let json = m.to_json();
576        assert!(json.starts_with('{'));
577        assert!(json.ends_with('}'));
578        assert!(json.contains("\"tokens_saved\":0"));
579        assert!(json.contains("\"per_tool\":[]"));
580        assert!(json.contains("\"per_command\":[]"));
581        assert!(json.contains("\"sessions\":[]"));
582        assert!(json.contains("\"active_session_id\":null"));
583    }
584
585    #[test]
586    fn test_to_json_with_data() {
587        let mut m = DashboardMetrics::default();
588        m.tokens_saved = 50_000;
589        m.compression_ratio = 0.35;
590        m.active_session_id = Some("sess_123".to_string());
591        m.per_tool.push(ToolBreakdown {
592            tool_name: "read_file".to_string(),
593            tokens_input: 1000,
594            tokens_output: 500,
595            cost_usd: 0.003,
596            call_count: 5,
597        });
598        m.per_command.push(CommandBreakdown {
599            command: "cargo build".to_string(),
600            tokens_original: 10_000,
601            tokens_compressed: 3_500,
602            invocations: 3,
603        });
604
605        let json = m.to_json();
606        assert!(json.contains("\"tokens_saved\":50000"));
607        assert!(json.contains("\"active_session_id\":\"sess_123\""));
608        assert!(json.contains("\"read_file\""));
609        assert!(json.contains("\"cargo build\""));
610    }
611
612    #[test]
613    fn test_escape_json_special_chars() {
614        assert_eq!(escape_json("hello"), "hello");
615        assert_eq!(escape_json("say \"hi\""), "say \\\"hi\\\"");
616        assert_eq!(escape_json("a\\b"), "a\\\\b");
617        assert_eq!(escape_json("line1\nline2"), "line1\\nline2");
618        assert_eq!(escape_json("tab\there"), "tab\\there");
619    }
620
621    // -----------------------------------------------------------------------
622    // HTML generation
623    // -----------------------------------------------------------------------
624
625    #[test]
626    fn test_html_is_self_contained() {
627        let html = DashboardHtml::render(3001);
628        // Must be valid HTML with inline CSS and JS
629        assert!(html.contains("<!DOCTYPE html>"));
630        assert!(html.contains("<style>"));
631        assert!(html.contains("<script>"));
632        assert!(html.contains("</html>"));
633        // Must NOT reference external resources
634        assert!(!html.contains("https://"));
635        assert!(!html.contains("http://"));
636        // Must contain SSE connection to /events
637        assert!(html.contains("EventSource"));
638        assert!(html.contains("/events"));
639    }
640
641    #[test]
642    fn test_html_contains_metric_elements() {
643        let html = DashboardHtml::render(3001);
644        assert!(html.contains("id=\"m-saved\""));
645        assert!(html.contains("id=\"m-ratio\""));
646        assert!(html.contains("id=\"m-cache\""));
647        assert!(html.contains("id=\"m-cost\""));
648        assert!(html.contains("id=\"m-budget\""));
649    }
650
651    #[test]
652    fn test_html_contains_tables() {
653        let html = DashboardHtml::render(3001);
654        assert!(html.contains("id=\"tool-table\""));
655        assert!(html.contains("id=\"cmd-table\""));
656        assert!(html.contains("id=\"sess-table\""));
657    }
658
659    #[test]
660    fn test_html_contains_search_input() {
661        let html = DashboardHtml::render(3001);
662        assert!(html.contains("id=\"search\""));
663        assert!(html.contains("Search sessions"));
664    }
665
666    // -----------------------------------------------------------------------
667    // DashboardServer construction
668    // -----------------------------------------------------------------------
669
670    #[test]
671    fn test_dashboard_config_default() {
672        let cfg = DashboardConfig::default();
673        assert_eq!(cfg.port, 3001);
674    }
675
676    #[test]
677    fn test_dashboard_server_metrics_handle() {
678        let metrics = Arc::new(Mutex::new(DashboardMetrics::default()));
679        let server = DashboardServer::new(DashboardConfig::default(), Arc::clone(&metrics));
680
681        // Update via the handle
682        {
683            let handle = server.metrics_handle();
684            let mut m = handle.lock().unwrap();
685            m.tokens_saved = 42;
686        }
687
688        // Original Arc should reflect the change
689        let m = metrics.lock().unwrap();
690        assert_eq!(m.tokens_saved, 42);
691    }
692
693    // -----------------------------------------------------------------------
694    // SessionHistoryEntry from SessionSummary
695    // -----------------------------------------------------------------------
696
697    #[test]
698    fn test_session_history_from_summary() {
699        let summary = SessionSummary {
700            id: "s1".to_string(),
701            project_dir: std::path::PathBuf::from("/tmp/proj"),
702            compressed_summary: "working on API".to_string(),
703            created_at: Utc::now(),
704            updated_at: Utc::now(),
705        };
706        let entry = SessionHistoryEntry::from(&summary);
707        assert_eq!(entry.id, "s1");
708        assert_eq!(entry.project_dir, "/tmp/proj");
709        assert_eq!(entry.summary, "working on API");
710        assert_eq!(entry.total_tokens, 0);
711        assert_eq!(entry.cost_usd, 0.0);
712    }
713}