1use std::path::Path;
8
9use anyhow::{Context, Result};
10use rusqlite::Connection;
11
12use super::telemetry_db;
13
14#[derive(Debug, Clone, Default, PartialEq)]
16pub struct DashboardMetrics {
17 pub total_tasks_completed: i64,
19 pub total_merges: i64,
20 pub total_events: i64,
21 pub sessions_count: i64,
22
23 pub avg_cycle_time_secs: Option<f64>,
25 pub min_cycle_time_secs: Option<i64>,
26 pub max_cycle_time_secs: Option<i64>,
27
28 pub completion_rate: Option<f64>,
30 pub failure_rate: Option<f64>,
31 pub merge_success_rate: Option<f64>,
32
33 pub auto_merge_count: i64,
35 pub manual_merge_count: i64,
36 pub auto_merge_rate: Option<f64>,
37 pub rework_count: i64,
38 pub rework_rate: Option<f64>,
39 pub avg_review_latency_secs: Option<f64>,
40
41 pub agent_rows: Vec<AgentRow>,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct AgentRow {
48 pub role: String,
49 pub completions: i64,
50 pub failures: i64,
51 pub restarts: i64,
52 pub total_cycle_secs: i64,
53 pub idle_pct: Option<f64>,
54}
55
56pub fn query_dashboard(conn: &Connection) -> Result<DashboardMetrics> {
58 let mut m = DashboardMetrics::default();
59
60 let sessions = telemetry_db::query_session_summaries(conn)?;
62 m.sessions_count = sessions.len() as i64;
63 for s in &sessions {
64 m.total_tasks_completed += s.tasks_completed;
65 m.total_merges += s.total_merges;
66 m.total_events += s.total_events;
67 }
68
69 let agents = telemetry_db::query_agent_metrics(conn)?;
71 let mut total_completions: i64 = 0;
72 let mut total_failures: i64 = 0;
73 for a in &agents {
74 total_completions += a.completions;
75 total_failures += a.failures;
76 let total_polls = a.idle_polls + a.working_polls;
77 let idle_pct = if total_polls > 0 {
78 Some(a.idle_polls as f64 / total_polls as f64 * 100.0)
79 } else {
80 None
81 };
82 m.agent_rows.push(AgentRow {
83 role: a.role.clone(),
84 completions: a.completions,
85 failures: a.failures,
86 restarts: a.restarts,
87 total_cycle_secs: a.total_cycle_secs,
88 idle_pct,
89 });
90 }
91
92 let total_outcomes = total_completions + total_failures;
94 if total_outcomes > 0 {
95 m.completion_rate = Some(total_completions as f64 / total_outcomes as f64 * 100.0);
96 m.failure_rate = Some(total_failures as f64 / total_outcomes as f64 * 100.0);
97 }
98 if m.total_tasks_completed > 0 {
99 m.merge_success_rate = Some(m.total_merges as f64 / m.total_tasks_completed as f64 * 100.0);
100 }
101
102 let tasks = telemetry_db::query_task_metrics(conn)?;
104 let cycle_times: Vec<i64> = tasks
105 .iter()
106 .filter_map(|t| match (t.started_at, t.completed_at) {
107 (Some(s), Some(c)) if c > s => Some(c - s),
108 _ => None,
109 })
110 .collect();
111 if !cycle_times.is_empty() {
112 let sum: i64 = cycle_times.iter().sum();
113 m.avg_cycle_time_secs = Some(sum as f64 / cycle_times.len() as f64);
114 m.min_cycle_time_secs = cycle_times.iter().copied().min();
115 m.max_cycle_time_secs = cycle_times.iter().copied().max();
116 }
117
118 let review = telemetry_db::query_review_metrics(conn)?;
120 m.auto_merge_count = review.auto_merge_count;
121 m.manual_merge_count = review.manual_merge_count;
122 m.rework_count = review.rework_count;
123 m.avg_review_latency_secs = review.avg_review_latency_secs;
124
125 let total_merge = m.auto_merge_count + m.manual_merge_count;
126 if total_merge > 0 {
127 m.auto_merge_rate = Some(m.auto_merge_count as f64 / total_merge as f64 * 100.0);
128 }
129 let total_reviewed = total_merge + m.rework_count;
130 if total_reviewed > 0 {
131 m.rework_rate = Some(m.rework_count as f64 / total_reviewed as f64 * 100.0);
132 }
133
134 Ok(m)
135}
136
137fn format_duration(secs: f64) -> String {
139 if secs < 60.0 {
140 format!("{:.0}s", secs)
141 } else if secs < 3600.0 {
142 let m = (secs / 60.0).floor();
143 let s = secs - m * 60.0;
144 format!("{:.0}m {:.0}s", m, s)
145 } else {
146 let h = (secs / 3600.0).floor();
147 let rem = secs - h * 3600.0;
148 let m = (rem / 60.0).floor();
149 format!("{:.0}h {:.0}m", h, m)
150 }
151}
152
153pub fn format_dashboard(m: &DashboardMetrics) -> String {
155 let mut out = String::new();
156 let na = "n/a".to_string();
157
158 out.push_str("Telemetry Dashboard\n");
160 out.push_str(&"=".repeat(60));
161 out.push('\n');
162
163 out.push_str("\nSession Totals\n");
165 out.push_str(&"-".repeat(40));
166 out.push('\n');
167 out.push_str(&format!(" Sessions: {}\n", m.sessions_count));
168 out.push_str(&format!(" Tasks Completed: {}\n", m.total_tasks_completed));
169 out.push_str(&format!(" Total Merges: {}\n", m.total_merges));
170 out.push_str(&format!(" Total Events: {}\n", m.total_events));
171
172 out.push_str("\nCycle Time\n");
174 out.push_str(&"-".repeat(40));
175 out.push('\n');
176 let avg = m
177 .avg_cycle_time_secs
178 .map(format_duration)
179 .unwrap_or_else(|| na.clone());
180 let min = m
181 .min_cycle_time_secs
182 .map(|s| format_duration(s as f64))
183 .unwrap_or_else(|| na.clone());
184 let max = m
185 .max_cycle_time_secs
186 .map(|s| format_duration(s as f64))
187 .unwrap_or_else(|| na.clone());
188 out.push_str(&format!(" Average: {}\n", avg));
189 out.push_str(&format!(" Min: {}\n", min));
190 out.push_str(&format!(" Max: {}\n", max));
191
192 out.push_str("\nRates\n");
194 out.push_str(&"-".repeat(40));
195 out.push('\n');
196 let cr = m
197 .completion_rate
198 .map(|r| format!("{:.0}%", r))
199 .unwrap_or_else(|| na.clone());
200 let fr = m
201 .failure_rate
202 .map(|r| format!("{:.0}%", r))
203 .unwrap_or_else(|| na.clone());
204 let mr = m
205 .merge_success_rate
206 .map(|r| format!("{:.0}%", r))
207 .unwrap_or_else(|| na.clone());
208 out.push_str(&format!(" Completion Rate: {}\n", cr));
209 out.push_str(&format!(" Failure Rate: {}\n", fr));
210 out.push_str(&format!(" Merge Success Rate: {}\n", mr));
211
212 out.push_str("\nReview Pipeline\n");
214 out.push_str(&"-".repeat(40));
215 out.push('\n');
216 let amr = m
217 .auto_merge_rate
218 .map(|r| format!("{:.0}%", r))
219 .unwrap_or_else(|| na.clone());
220 let rr = m
221 .rework_rate
222 .map(|r| format!("{:.0}%", r))
223 .unwrap_or_else(|| na.clone());
224 let latency = m
225 .avg_review_latency_secs
226 .map(format_duration)
227 .unwrap_or_else(|| na.clone());
228 out.push_str(&format!(" Auto-merge Rate: {}\n", amr));
229 out.push_str(&format!(
230 " Auto: {} Manual: {} Rework: {}\n",
231 m.auto_merge_count, m.manual_merge_count, m.rework_count
232 ));
233 out.push_str(&format!(" Rework Rate: {}\n", rr));
234 out.push_str(&format!(" Avg Review Latency: {}\n", latency));
235
236 if !m.agent_rows.is_empty() {
238 out.push_str("\nPer-Agent Breakdown\n");
239 out.push_str(&"-".repeat(60));
240 out.push('\n');
241 out.push_str(&format!(
242 " {:<16} {:>6} {:>6} {:>6} {:>10} {:>8}\n",
243 "ROLE", "DONE", "FAIL", "RESTART", "CYCLE_S", "IDLE%"
244 ));
245 for a in &m.agent_rows {
246 let idle = a
247 .idle_pct
248 .map(|p| format!("{:.0}%", p))
249 .unwrap_or_else(|| "-".to_string());
250 out.push_str(&format!(
251 " {:<16} {:>6} {:>6} {:>6} {:>10} {:>8}\n",
252 a.role, a.completions, a.failures, a.restarts, a.total_cycle_secs, idle
253 ));
254 }
255 }
256
257 out
258}
259
260pub fn run(project_root: &Path) -> Result<()> {
265 let db_path = project_root.join(".batty").join("telemetry.db");
266 if !db_path.exists() {
267 println!("Telemetry Dashboard\n{}", "=".repeat(60));
268 println!("\nNo telemetry database found. Run `batty start` to begin collecting data.");
269 return Ok(());
270 }
271
272 let conn = telemetry_db::open(project_root).context("failed to open telemetry database")?;
273
274 let metrics = query_dashboard(&conn)?;
275 print!("{}", format_dashboard(&metrics));
276 Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::team::events::TeamEvent;
283 use crate::team::telemetry_db;
284
285 fn setup_db_with_data() -> Connection {
286 let conn = telemetry_db::open_in_memory().unwrap();
287
288 let mut started = TeamEvent::daemon_started();
290 started.ts = 1000;
291 telemetry_db::insert_event(&conn, &started).unwrap();
292
293 let mut a1 = TeamEvent::task_assigned("eng-1", "10");
295 a1.ts = 1100;
296 telemetry_db::insert_event(&conn, &a1).unwrap();
297
298 let mut c1 = TeamEvent::task_completed("eng-1", Some("10"));
299 c1.ts = 1400; telemetry_db::insert_event(&conn, &c1).unwrap();
301
302 let mut a2 = TeamEvent::task_assigned("eng-2", "20");
303 a2.ts = 1200;
304 telemetry_db::insert_event(&conn, &a2).unwrap();
305
306 let mut c2 = TeamEvent::task_completed("eng-2", Some("20"));
307 c2.ts = 1700; telemetry_db::insert_event(&conn, &c2).unwrap();
309
310 let mut m1 = TeamEvent::task_auto_merged("eng-1", "10", 0.9, 2, 30);
312 m1.ts = 1500;
313 telemetry_db::insert_event(&conn, &m1).unwrap();
314
315 let mut m2 = TeamEvent::task_manual_merged("20");
316 m2.ts = 1800;
317 telemetry_db::insert_event(&conn, &m2).unwrap();
318
319 telemetry_db::insert_event(&conn, &TeamEvent::pane_death("eng-1")).unwrap();
321
322 conn
323 }
324
325 #[test]
326 fn metrics_with_data() {
327 let conn = setup_db_with_data();
328 let m = query_dashboard(&conn).unwrap();
329
330 assert_eq!(m.sessions_count, 1);
331 assert_eq!(m.total_tasks_completed, 2);
332 assert_eq!(m.total_merges, 2);
333 assert!(m.total_events > 0);
334
335 let avg = m.avg_cycle_time_secs.unwrap();
337 assert!((avg - 400.0).abs() < 0.01);
338 assert_eq!(m.min_cycle_time_secs, Some(300));
339 assert_eq!(m.max_cycle_time_secs, Some(500));
340
341 let cr = m.completion_rate.unwrap();
343 assert!((cr - 66.67).abs() < 1.0);
344
345 let fr = m.failure_rate.unwrap();
347 assert!((fr - 33.33).abs() < 1.0);
348
349 let mr = m.merge_success_rate.unwrap();
351 assert!((mr - 100.0).abs() < 0.01);
352
353 assert_eq!(m.auto_merge_count, 1);
355 assert_eq!(m.manual_merge_count, 1);
356 let amr = m.auto_merge_rate.unwrap();
357 assert!((amr - 50.0).abs() < 0.01);
358
359 assert_eq!(m.agent_rows.len(), 2);
361 }
362
363 #[test]
364 fn empty_db() {
365 let conn = telemetry_db::open_in_memory().unwrap();
366 let m = query_dashboard(&conn).unwrap();
367
368 assert_eq!(m, DashboardMetrics::default());
369 assert_eq!(m.sessions_count, 0);
370 assert_eq!(m.total_tasks_completed, 0);
371 assert!(m.avg_cycle_time_secs.is_none());
372 assert!(m.completion_rate.is_none());
373 assert!(m.failure_rate.is_none());
374 assert!(m.merge_success_rate.is_none());
375 }
376
377 #[test]
378 fn missing_db_shows_message() {
379 let tmp = tempfile::tempdir().unwrap();
380 let result = run(tmp.path());
382 assert!(result.is_ok());
383 }
384
385 #[test]
386 fn rate_calculations() {
387 let conn = telemetry_db::open_in_memory().unwrap();
388
389 let mut started = TeamEvent::daemon_started();
391 started.ts = 100;
392 telemetry_db::insert_event(&conn, &started).unwrap();
393
394 for i in 1..=3 {
395 let mut a = TeamEvent::task_assigned("eng-1", &i.to_string());
396 a.ts = 200 + i as u64 * 100;
397 telemetry_db::insert_event(&conn, &a).unwrap();
398
399 let mut c = TeamEvent::task_completed("eng-1", Some(&i.to_string()));
400 c.ts = 200 + i as u64 * 100 + 60;
401 telemetry_db::insert_event(&conn, &c).unwrap();
402
403 let mut m = TeamEvent::task_auto_merged("eng-1", &i.to_string(), 0.9, 2, 30);
404 m.ts = 200 + i as u64 * 100 + 120;
405 telemetry_db::insert_event(&conn, &m).unwrap();
406 }
407 telemetry_db::insert_event(&conn, &TeamEvent::pane_death("eng-1")).unwrap();
408
409 let m = query_dashboard(&conn).unwrap();
410
411 let cr = m.completion_rate.unwrap();
413 assert!((cr - 75.0).abs() < 0.01);
414
415 let fr = m.failure_rate.unwrap();
417 assert!((fr - 25.0).abs() < 0.01);
418
419 let mr = m.merge_success_rate.unwrap();
421 assert!((mr - 100.0).abs() < 0.01);
422
423 let amr = m.auto_merge_rate.unwrap();
425 assert!((amr - 100.0).abs() < 0.01);
426
427 let avg = m.avg_cycle_time_secs.unwrap();
429 assert!((avg - 60.0).abs() < 0.01);
430 }
431
432 #[test]
433 fn format_dashboard_renders_sections() {
434 let m = DashboardMetrics {
435 sessions_count: 2,
436 total_tasks_completed: 10,
437 total_merges: 8,
438 total_events: 50,
439 avg_cycle_time_secs: Some(300.0),
440 min_cycle_time_secs: Some(60),
441 max_cycle_time_secs: Some(900),
442 completion_rate: Some(90.0),
443 failure_rate: Some(10.0),
444 merge_success_rate: Some(80.0),
445 auto_merge_count: 6,
446 manual_merge_count: 2,
447 auto_merge_rate: Some(75.0),
448 rework_count: 1,
449 rework_rate: Some(11.0),
450 avg_review_latency_secs: Some(120.0),
451 agent_rows: vec![AgentRow {
452 role: "eng-1".to_string(),
453 completions: 5,
454 failures: 1,
455 restarts: 0,
456 total_cycle_secs: 1500,
457 idle_pct: Some(20.0),
458 }],
459 };
460
461 let text = format_dashboard(&m);
462
463 assert!(text.contains("Telemetry Dashboard"));
464 assert!(text.contains("Session Totals"));
465 assert!(text.contains("Sessions: 2"));
466 assert!(text.contains("Tasks Completed: 10"));
467 assert!(text.contains("Total Merges: 8"));
468
469 assert!(text.contains("Cycle Time"));
470 assert!(text.contains("Average: 5m 0s"));
471 assert!(text.contains("Min: 1m 0s"));
472 assert!(text.contains("Max: 15m 0s"));
473
474 assert!(text.contains("Rates"));
475 assert!(text.contains("Completion Rate: 90%"));
476 assert!(text.contains("Failure Rate: 10%"));
477 assert!(text.contains("Merge Success Rate: 80%"));
478
479 assert!(text.contains("Review Pipeline"));
480 assert!(text.contains("Auto-merge Rate: 75%"));
481 assert!(text.contains("Rework Rate: 11%"));
482
483 assert!(text.contains("Per-Agent Breakdown"));
484 assert!(text.contains("eng-1"));
485 }
486
487 #[test]
488 fn format_dashboard_empty_shows_na() {
489 let m = DashboardMetrics::default();
490 let text = format_dashboard(&m);
491
492 assert!(text.contains("n/a"));
493 assert!(text.contains("Sessions: 0"));
494 assert!(!text.contains("Per-Agent Breakdown"));
496 }
497
498 #[test]
499 fn format_duration_works() {
500 assert_eq!(format_duration(30.0), "30s");
501 assert_eq!(format_duration(90.0), "1m 30s");
502 assert_eq!(format_duration(3661.0), "1h 1m");
503 }
504}