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, Read, Write};
9use std::net::{TcpListener, TcpStream};
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    /// Each accepted connection is served on its own thread, so a
422    /// long-lived `/events` SSE stream (or a half-open client) can never
423    /// block the accept loop. See [`Self::run_on`] for the routing table
424    /// and the history of issue #36.
425    pub fn run(&self) -> crate::error::Result<()> {
426        let addr = format!("127.0.0.1:{}", self.config.port);
427        let listener = TcpListener::bind(&addr)?;
428        eprintln!("[sqz] dashboard listening on http://{addr}");
429        self.run_on(listener)
430    }
431
432    /// Serve on an already-bound listener. This blocks the calling thread.
433    ///
434    /// Split out from [`Self::run`] so tests can bind port 0 and learn the
435    /// real port via `listener.local_addr()` before starting the server.
436    ///
437    /// For each incoming connection the server reads the HTTP request line,
438    /// then either:
439    /// - `GET /`        → responds with the full HTML page
440    /// - `GET /events`  → responds with an SSE stream (pushes metrics JSON
441    ///                     every 5 seconds until the client disconnects)
442    /// - anything else  → 404
443    ///
444    /// Concurrency model: one thread per connection. The previous design
445    /// served every connection inline on the accept thread, which meant a
446    /// single connected SSE client parked the whole server in its push
447    /// loop, and an interrupted SSE client parked it for up to two 5-second
448    /// ticks (the first write after a peer FIN succeeds into the send
449    /// buffer; only the next one observes the RST — and `TcpStream::flush`
450    /// is a no-op, so it detects nothing). Meanwhile every other connection
451    /// sat unaccepted in the kernel backlog showing CLOSE_WAIT, and the
452    /// dashboard page's EventSource auto-reconnect turned the pile-up into
453    /// a permanent hang (issue #36).
454    pub fn run_on(&self, listener: TcpListener) -> crate::error::Result<()> {
455        let html = Arc::new(DashboardHtml::render(self.config.port));
456
457        for stream in listener.incoming() {
458            let stream = match stream {
459                Ok(s) => s,
460                Err(_) => continue,
461            };
462            let metrics = Arc::clone(&self.metrics);
463            let html = Arc::clone(&html);
464            std::thread::spawn(move || {
465                handle_connection(stream, metrics, &html);
466            });
467        }
468
469        Ok(())
470    }
471}
472
473/// How long an SSE connection may sit idle before we probe it. Also the
474/// upper bound on how long a half-open client can hold its handler thread
475/// between events.
476const SSE_TICK: Duration = Duration::from_secs(5);
477
478/// Read timeout for the initial request line + headers. A client that
479/// connects and never sends a request gets its thread reclaimed after this
480/// long, instead of holding it forever.
481const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(10);
482
483/// Serve a single accepted connection. Runs on its own thread.
484fn handle_connection(mut stream: TcpStream, metrics: Arc<Mutex<DashboardMetrics>>, html: &str) {
485    // Bound the initial read so half-open clients can't pin the thread.
486    let _ = stream.set_read_timeout(Some(REQUEST_READ_TIMEOUT));
487    let _ = stream.set_write_timeout(Some(REQUEST_READ_TIMEOUT));
488
489    // Read the request line.
490    let Ok(clone) = stream.try_clone() else {
491        return;
492    };
493    let mut reader = BufReader::new(clone);
494    let mut request_line = String::new();
495    if reader.read_line(&mut request_line).is_err() {
496        return;
497    }
498
499    // Drain remaining headers (we don't need them).
500    let mut header = String::new();
501    loop {
502        header.clear();
503        match reader.read_line(&mut header) {
504            Ok(0) | Err(_) => break,
505            Ok(_) => {
506                if header.trim().is_empty() {
507                    break;
508                }
509            }
510        }
511    }
512
513    if request_line.starts_with("GET /events") {
514        serve_sse(stream, metrics);
515    } else if request_line.starts_with("GET / ") || request_line.starts_with("GET / HTTP") {
516        let response = format!(
517            "HTTP/1.1 200 OK\r\n\
518             Content-Type: text/html; charset=utf-8\r\n\
519             Content-Length: {}\r\n\
520             Connection: close\r\n\r\n{}",
521            html.len(),
522            html,
523        );
524        let _ = stream.write_all(response.as_bytes());
525    } else {
526        let body = "404 Not Found";
527        let response = format!(
528            "HTTP/1.1 404 Not Found\r\n\
529             Content-Type: text/plain\r\n\
530             Content-Length: {}\r\n\
531             Connection: close\r\n\r\n{}",
532            body.len(),
533            body,
534        );
535        let _ = stream.write_all(response.as_bytes());
536    }
537    // `stream` (and the BufReader clone) drop here, fully closing the
538    // socket — no CLOSE_WAIT lingering past the handler's lifetime.
539}
540
541/// Push metrics to an SSE client until it disconnects.
542///
543/// Disconnect detection: instead of sleeping blindly between events, we
544/// spend the inter-event gap in a read with a short timeout. SSE clients
545/// never send data after the request, so the read returns:
546/// - `Ok(0)` → orderly EOF: the client closed (curl --max-time, tab
547///   closed). Tear down immediately — this is what turns the peer's FIN
548///   into an instant close instead of a CLOSE_WAIT that lingers until the
549///   next write cycle.
550/// - `Err(WouldBlock/TimedOut)` → still connected, keep going.
551/// - `Err(other)` / RST → tear down.
552fn serve_sse(mut stream: TcpStream, metrics: Arc<Mutex<DashboardMetrics>>) {
553    let response_header = "HTTP/1.1 200 OK\r\n\
554        Content-Type: text/event-stream\r\n\
555        Cache-Control: no-cache\r\n\
556        Connection: keep-alive\r\n\
557        Access-Control-Allow-Origin: *\r\n\r\n";
558    if stream.write_all(response_header.as_bytes()).is_err() {
559        return;
560    }
561
562    let mut buf = [0u8; 512];
563    loop {
564        let json = {
565            let m = metrics.lock().unwrap();
566            m.to_json()
567        };
568        let event = format!("data: {json}\n\n");
569        if stream.write_all(event.as_bytes()).is_err() {
570            return;
571        }
572
573        // Wait out the tick watching the socket for FIN/RST.
574        if stream.set_read_timeout(Some(SSE_TICK)).is_err() {
575            return;
576        }
577        match stream.read(&mut buf) {
578            // EOF — the client closed its end.
579            Ok(0) => return,
580            // Clients aren't supposed to send anything on an SSE stream;
581            // tolerate stray bytes and keep streaming.
582            Ok(_) => {}
583            Err(e)
584                if e.kind() == std::io::ErrorKind::WouldBlock
585                    || e.kind() == std::io::ErrorKind::TimedOut =>
586            {
587                // Tick elapsed with the connection still healthy.
588            }
589            // RST or anything else — connection is gone.
590            Err(_) => return,
591        }
592    }
593}
594
595// ---------------------------------------------------------------------------
596// Tests
597// ---------------------------------------------------------------------------
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    // -----------------------------------------------------------------------
604    // DashboardMetrics
605    // -----------------------------------------------------------------------
606
607    #[test]
608    fn test_default_metrics() {
609        let m = DashboardMetrics::default();
610        assert_eq!(m.tokens_saved, 0);
611        assert_eq!(m.tokens_total, 0);
612        assert_eq!(m.cache_hits, 0);
613        assert_eq!(m.cache_misses, 0);
614        assert!((m.compression_ratio - 0.0).abs() < f64::EPSILON);
615        assert!(m.per_tool.is_empty());
616        assert!(m.per_command.is_empty());
617        assert!(m.sessions.is_empty());
618    }
619
620    #[test]
621    fn test_cache_hit_rate_zero_total() {
622        let m = DashboardMetrics::default();
623        assert!((m.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
624    }
625
626    #[test]
627    fn test_cache_hit_rate_calculation() {
628        let mut m = DashboardMetrics::default();
629        m.cache_hits = 75;
630        m.cache_misses = 25;
631        assert!((m.cache_hit_rate() - 75.0).abs() < 0.01);
632    }
633
634    #[test]
635    fn test_cache_hit_rate_all_hits() {
636        let mut m = DashboardMetrics::default();
637        m.cache_hits = 100;
638        m.cache_misses = 0;
639        assert!((m.cache_hit_rate() - 100.0).abs() < f64::EPSILON);
640    }
641
642    // -----------------------------------------------------------------------
643    // JSON serialization
644    // -----------------------------------------------------------------------
645
646    #[test]
647    fn test_to_json_default_metrics() {
648        let m = DashboardMetrics::default();
649        let json = m.to_json();
650        assert!(json.starts_with('{'));
651        assert!(json.ends_with('}'));
652        assert!(json.contains("\"tokens_saved\":0"));
653        assert!(json.contains("\"per_tool\":[]"));
654        assert!(json.contains("\"per_command\":[]"));
655        assert!(json.contains("\"sessions\":[]"));
656        assert!(json.contains("\"active_session_id\":null"));
657    }
658
659    #[test]
660    fn test_to_json_with_data() {
661        let mut m = DashboardMetrics::default();
662        m.tokens_saved = 50_000;
663        m.compression_ratio = 0.35;
664        m.active_session_id = Some("sess_123".to_string());
665        m.per_tool.push(ToolBreakdown {
666            tool_name: "read_file".to_string(),
667            tokens_input: 1000,
668            tokens_output: 500,
669            cost_usd: 0.003,
670            call_count: 5,
671        });
672        m.per_command.push(CommandBreakdown {
673            command: "cargo build".to_string(),
674            tokens_original: 10_000,
675            tokens_compressed: 3_500,
676            invocations: 3,
677        });
678
679        let json = m.to_json();
680        assert!(json.contains("\"tokens_saved\":50000"));
681        assert!(json.contains("\"active_session_id\":\"sess_123\""));
682        assert!(json.contains("\"read_file\""));
683        assert!(json.contains("\"cargo build\""));
684    }
685
686    #[test]
687    fn test_escape_json_special_chars() {
688        assert_eq!(escape_json("hello"), "hello");
689        assert_eq!(escape_json("say \"hi\""), "say \\\"hi\\\"");
690        assert_eq!(escape_json("a\\b"), "a\\\\b");
691        assert_eq!(escape_json("line1\nline2"), "line1\\nline2");
692        assert_eq!(escape_json("tab\there"), "tab\\there");
693    }
694
695    // -----------------------------------------------------------------------
696    // HTML generation
697    // -----------------------------------------------------------------------
698
699    #[test]
700    fn test_html_is_self_contained() {
701        let html = DashboardHtml::render(3001);
702        // Must be valid HTML with inline CSS and JS
703        assert!(html.contains("<!DOCTYPE html>"));
704        assert!(html.contains("<style>"));
705        assert!(html.contains("<script>"));
706        assert!(html.contains("</html>"));
707        // Must NOT reference external resources
708        assert!(!html.contains("https://"));
709        assert!(!html.contains("http://"));
710        // Must contain SSE connection to /events
711        assert!(html.contains("EventSource"));
712        assert!(html.contains("/events"));
713    }
714
715    #[test]
716    fn test_html_contains_metric_elements() {
717        let html = DashboardHtml::render(3001);
718        assert!(html.contains("id=\"m-saved\""));
719        assert!(html.contains("id=\"m-ratio\""));
720        assert!(html.contains("id=\"m-cache\""));
721        assert!(html.contains("id=\"m-cost\""));
722        assert!(html.contains("id=\"m-budget\""));
723    }
724
725    #[test]
726    fn test_html_contains_tables() {
727        let html = DashboardHtml::render(3001);
728        assert!(html.contains("id=\"tool-table\""));
729        assert!(html.contains("id=\"cmd-table\""));
730        assert!(html.contains("id=\"sess-table\""));
731    }
732
733    #[test]
734    fn test_html_contains_search_input() {
735        let html = DashboardHtml::render(3001);
736        assert!(html.contains("id=\"search\""));
737        assert!(html.contains("Search sessions"));
738    }
739
740    // -----------------------------------------------------------------------
741    // DashboardServer construction
742    // -----------------------------------------------------------------------
743
744    #[test]
745    fn test_dashboard_config_default() {
746        let cfg = DashboardConfig::default();
747        assert_eq!(cfg.port, 3001);
748    }
749
750    #[test]
751    fn test_dashboard_server_metrics_handle() {
752        let metrics = Arc::new(Mutex::new(DashboardMetrics::default()));
753        let server = DashboardServer::new(DashboardConfig::default(), Arc::clone(&metrics));
754
755        // Update via the handle
756        {
757            let handle = server.metrics_handle();
758            let mut m = handle.lock().unwrap();
759            m.tokens_saved = 42;
760        }
761
762        // Original Arc should reflect the change
763        let m = metrics.lock().unwrap();
764        assert_eq!(m.tokens_saved, 42);
765    }
766
767    // -----------------------------------------------------------------------
768    // Live server: issue #36 regression (SSE must not wedge the server)
769    // -----------------------------------------------------------------------
770
771    use std::io::Read as IoRead;
772    use std::net::{SocketAddr, TcpStream as ClientStream};
773
774    /// Bind port 0, start the server on a background thread, return the
775    /// real address. The thread is detached — `run_on` never returns — and
776    /// the OS reclaims the socket when the test process exits.
777    fn start_test_server() -> SocketAddr {
778        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test port");
779        let addr = listener.local_addr().expect("local addr");
780        let metrics = Arc::new(Mutex::new(DashboardMetrics::default()));
781        let server = DashboardServer::new(DashboardConfig { port: addr.port() }, metrics);
782        std::thread::spawn(move || {
783            let _ = server.run_on(listener);
784        });
785        addr
786    }
787
788    /// GET `path` and return the raw response bytes read within `timeout`.
789    fn http_get(addr: SocketAddr, path: &str, timeout: Duration) -> std::io::Result<Vec<u8>> {
790        let mut s = ClientStream::connect_timeout(&addr.into(), timeout)?;
791        s.set_read_timeout(Some(timeout))?;
792        s.set_write_timeout(Some(timeout))?;
793        write!(s, "GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n")?;
794        let mut buf = Vec::new();
795        let mut chunk = [0u8; 4096];
796        loop {
797            match s.read(&mut chunk) {
798                Ok(0) => break,
799                Ok(n) => {
800                    buf.extend_from_slice(&chunk[..n]);
801                    // For SSE the stream never ends; stop after the first data frame.
802                    if buf.windows(6).any(|w| w == b"data: ") {
803                        break;
804                    }
805                }
806                Err(_) => break,
807            }
808        }
809        Ok(buf)
810    }
811
812    /// Regression for issue #36: interrupted `/events` clients (connect,
813    /// read a little, hard-close — curl --max-time behaviour) must not
814    /// stop the server from answering subsequent requests.
815    #[test]
816    fn interrupted_sse_clients_do_not_block_server() {
817        let addr = start_test_server();
818
819        // Six aborted SSE connections, like the reproduction script.
820        for _ in 0..6 {
821            let mut s = ClientStream::connect_timeout(&addr.into(), Duration::from_secs(2))
822                .expect("connect sse");
823            let _ = write!(s, "GET /events HTTP/1.1\r\nHost: localhost\r\n\r\n");
824            let mut first = [0u8; 256];
825            s.set_read_timeout(Some(Duration::from_millis(500))).unwrap();
826            let _ = s.read(&mut first);
827            drop(s); // hard close while the server-side stream is open
828        }
829
830        // The old single-threaded server would sit in the dead SSE loops
831        // for many seconds; the fixed server must answer immediately.
832        let start = std::time::Instant::now();
833        let resp = http_get(addr, "/", Duration::from_secs(3)).expect("GET / after aborted SSE");
834        let text = String::from_utf8_lossy(&resp);
835        assert!(
836            text.starts_with("HTTP/1.1 200 OK"),
837            "expected 200 after aborted SSE clients, got: {}",
838            &text[..text.len().min(80)]
839        );
840        assert!(text.contains("<!DOCTYPE html>"), "should serve the dashboard page");
841        assert!(
842            start.elapsed() < Duration::from_secs(3),
843            "server must stay responsive, took {:?}",
844            start.elapsed()
845        );
846    }
847
848    /// A healthy, connected SSE client must not block other requests
849    /// either (this is what made the hang permanent: the dashboard tab's
850    /// EventSource reconnects and then holds the connection).
851    #[test]
852    fn live_sse_client_does_not_block_other_requests() {
853        let addr = start_test_server();
854
855        // Open an SSE stream and keep it open.
856        let mut sse = ClientStream::connect_timeout(&addr.into(), Duration::from_secs(2))
857            .expect("connect sse");
858        write!(sse, "GET /events HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
859        sse.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
860        let mut first = [0u8; 512];
861        let n = sse.read(&mut first).expect("first SSE frame");
862        assert!(
863            String::from_utf8_lossy(&first[..n]).contains("text/event-stream"),
864            "SSE header expected"
865        );
866
867        // While it's connected, `/` must still answer.
868        let resp = http_get(addr, "/", Duration::from_secs(3)).expect("GET / during live SSE");
869        assert!(
870            String::from_utf8_lossy(&resp).starts_with("HTTP/1.1 200 OK"),
871            "GET / must succeed while an SSE client is connected"
872        );
873
874        // And a second SSE client can connect concurrently.
875        let resp2 = http_get(addr, "/events", Duration::from_secs(3)).expect("second SSE client");
876        let text2 = String::from_utf8_lossy(&resp2);
877        assert!(text2.contains("data: "), "second SSE client should receive a frame");
878        drop(sse);
879    }
880
881    /// Unknown paths still 404 under the threaded handler.
882    #[test]
883    fn unknown_path_returns_404() {
884        let addr = start_test_server();
885        let resp = http_get(addr, "/nope", Duration::from_secs(3)).expect("GET /nope");
886        assert!(String::from_utf8_lossy(&resp).starts_with("HTTP/1.1 404"));
887    }
888
889    /// A client that connects and never sends a request must not hold its
890    /// handler thread past the request read timeout — and must never affect
891    /// other requests at all.
892    #[test]
893    fn half_open_client_does_not_block_server() {
894        let addr = start_test_server();
895
896        // Connect and send nothing.
897        let idle = ClientStream::connect_timeout(&addr.into(), Duration::from_secs(2))
898            .expect("idle connect");
899
900        let resp = http_get(addr, "/", Duration::from_secs(3)).expect("GET / with idle peer");
901        assert!(String::from_utf8_lossy(&resp).starts_with("HTTP/1.1 200 OK"));
902        drop(idle);
903    }
904
905    // -----------------------------------------------------------------------
906    // SessionHistoryEntry from SessionSummary
907    // -----------------------------------------------------------------------
908
909    #[test]
910    fn test_session_history_from_summary() {
911        let summary = SessionSummary {
912            id: "s1".to_string(),
913            project_dir: std::path::PathBuf::from("/tmp/proj"),
914            compressed_summary: "working on API".to_string(),
915            created_at: Utc::now(),
916            updated_at: Utc::now(),
917        };
918        let entry = SessionHistoryEntry::from(&summary);
919        assert_eq!(entry.id, "s1");
920        assert_eq!(entry.project_dir, "/tmp/proj");
921        assert_eq!(entry.summary, "working on API");
922        assert_eq!(entry.total_tokens, 0);
923        assert_eq!(entry.cost_usd, 0.0);
924    }
925}