1use 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#[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#[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#[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#[derive(Debug, Clone)]
68pub struct DashboardMetrics {
69 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 pub active_session_id: Option<String>,
80 pub active_model: Option<String>,
81 pub budget_consumed_pct: f64,
82
83 pub per_tool: Vec<ToolBreakdown>,
85 pub per_command: Vec<CommandBreakdown>,
86
87 pub sessions: Vec<SessionHistoryEntry>,
89
90 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 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 pub fn to_json(&self) -> String {
127 let mut s = String::with_capacity(2048);
128 s.push('{');
129
130 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 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 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 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 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
223fn 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
242pub struct DashboardHtml;
248
249impl DashboardHtml {
250 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#[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
398pub struct DashboardServer {
404 config: DashboardConfig,
405 metrics: Arc<Mutex<DashboardMetrics>>,
406}
407
408impl DashboardServer {
409 pub fn new(config: DashboardConfig, metrics: Arc<Mutex<DashboardMetrics>>) -> Self {
411 DashboardServer { config, metrics }
412 }
413
414 pub fn metrics_handle(&self) -> Arc<Mutex<DashboardMetrics>> {
416 Arc::clone(&self.metrics)
417 }
418
419 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 let mut reader = BufReader::new(stream.try_clone().unwrap_or_else(|_| {
442 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 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 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 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 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#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[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 #[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 #[test]
626 fn test_html_is_self_contained() {
627 let html = DashboardHtml::render(3001);
628 assert!(html.contains("<!DOCTYPE html>"));
630 assert!(html.contains("<style>"));
631 assert!(html.contains("<script>"));
632 assert!(html.contains("</html>"));
633 assert!(!html.contains("https://"));
635 assert!(!html.contains("http://"));
636 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 #[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 {
683 let handle = server.metrics_handle();
684 let mut m = handle.lock().unwrap();
685 m.tokens_saved = 42;
686 }
687
688 let m = metrics.lock().unwrap();
690 assert_eq!(m.tokens_saved, 42);
691 }
692
693 #[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}