|
| 1 | +"""Multi-session dashboard: aggregate view across sessions with trend data. |
| 2 | +
|
| 3 | +Produces a terminal table and an optional self-contained HTML dashboard |
| 4 | +showing cost, duration, tool calls, errors, and trend lines across all |
| 5 | +(or a filtered set of) sessions. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import argparse |
| 11 | +import html |
| 12 | +import json |
| 13 | +import sys |
| 14 | +from dataclasses import dataclass, field |
| 15 | +from datetime import datetime, timezone |
| 16 | +from typing import TextIO |
| 17 | + |
| 18 | +from .models import SessionMeta |
| 19 | +from .store import TraceStore |
| 20 | + |
| 21 | + |
| 22 | +# --------------------------------------------------------------------------- |
| 23 | +# Data structures |
| 24 | +# --------------------------------------------------------------------------- |
| 25 | + |
| 26 | +@dataclass |
| 27 | +class SessionSummary: |
| 28 | + session_id: str |
| 29 | + started_at: float |
| 30 | + duration_s: float |
| 31 | + tool_calls: int |
| 32 | + llm_requests: int |
| 33 | + errors: int |
| 34 | + total_tokens: int |
| 35 | + estimated_cost: float |
| 36 | + agent_name: str |
| 37 | + succeeded: bool # True if no errors recorded |
| 38 | + |
| 39 | + |
| 40 | +@dataclass |
| 41 | +class DashboardReport: |
| 42 | + summaries: list[SessionSummary] |
| 43 | + total_cost: float |
| 44 | + total_tokens: int |
| 45 | + total_tool_calls: int |
| 46 | + total_errors: int |
| 47 | + avg_duration_s: float |
| 48 | + success_rate: float # 0.0–1.0 |
| 49 | + |
| 50 | + |
| 51 | +# --------------------------------------------------------------------------- |
| 52 | +# Aggregation |
| 53 | +# --------------------------------------------------------------------------- |
| 54 | + |
| 55 | +def build_dashboard( |
| 56 | + store: TraceStore, |
| 57 | + limit: int = 50, |
| 58 | + agent_filter: str = "", |
| 59 | +) -> DashboardReport: |
| 60 | + """Build a DashboardReport from the most recent *limit* sessions.""" |
| 61 | + all_meta = store.list_sessions() |
| 62 | + |
| 63 | + if agent_filter: |
| 64 | + all_meta = [m for m in all_meta if agent_filter.lower() in m.agent_name.lower()] |
| 65 | + |
| 66 | + sessions = all_meta[:limit] |
| 67 | + |
| 68 | + summaries: list[SessionSummary] = [] |
| 69 | + for meta in sessions: |
| 70 | + duration_s = meta.total_duration_ms / 1000 if meta.total_duration_ms else 0.0 |
| 71 | + # Estimate cost cheaply from token count stored in meta |
| 72 | + cost = meta.total_tokens / 1_000_000 * 3.0 # rough sonnet input price |
| 73 | + |
| 74 | + summaries.append(SessionSummary( |
| 75 | + session_id=meta.session_id, |
| 76 | + started_at=meta.started_at, |
| 77 | + duration_s=duration_s, |
| 78 | + tool_calls=meta.tool_calls, |
| 79 | + llm_requests=meta.llm_requests, |
| 80 | + errors=meta.errors, |
| 81 | + total_tokens=meta.total_tokens, |
| 82 | + estimated_cost=cost, |
| 83 | + agent_name=meta.agent_name or "unknown", |
| 84 | + succeeded=meta.errors == 0, |
| 85 | + )) |
| 86 | + |
| 87 | + total_cost = sum(s.estimated_cost for s in summaries) |
| 88 | + total_tokens = sum(s.total_tokens for s in summaries) |
| 89 | + total_tools = sum(s.tool_calls for s in summaries) |
| 90 | + total_errors = sum(s.errors for s in summaries) |
| 91 | + avg_dur = ( |
| 92 | + sum(s.duration_s for s in summaries) / len(summaries) |
| 93 | + if summaries else 0.0 |
| 94 | + ) |
| 95 | + success_rate = ( |
| 96 | + sum(1 for s in summaries if s.succeeded) / len(summaries) |
| 97 | + if summaries else 0.0 |
| 98 | + ) |
| 99 | + |
| 100 | + return DashboardReport( |
| 101 | + summaries=summaries, |
| 102 | + total_cost=total_cost, |
| 103 | + total_tokens=total_tokens, |
| 104 | + total_tool_calls=total_tools, |
| 105 | + total_errors=total_errors, |
| 106 | + avg_duration_s=avg_dur, |
| 107 | + success_rate=success_rate, |
| 108 | + ) |
| 109 | + |
| 110 | + |
| 111 | +# --------------------------------------------------------------------------- |
| 112 | +# Terminal formatting |
| 113 | +# --------------------------------------------------------------------------- |
| 114 | + |
| 115 | +def _fmt_dur(s: float) -> str: |
| 116 | + if s < 60: |
| 117 | + return f"{s:.0f}s" |
| 118 | + return f"{int(s)//60}m{int(s)%60:02d}s" |
| 119 | + |
| 120 | + |
| 121 | +def _fmt_ts(ts: float) -> str: |
| 122 | + try: |
| 123 | + dt = datetime.fromtimestamp(ts, tz=timezone.utc) |
| 124 | + return dt.strftime("%m-%d %H:%M") |
| 125 | + except Exception: |
| 126 | + return "?" |
| 127 | + |
| 128 | + |
| 129 | +def format_dashboard(report: DashboardReport, out: TextIO = sys.stdout) -> None: |
| 130 | + w = out.write |
| 131 | + n = len(report.summaries) |
| 132 | + |
| 133 | + w(f"\nDashboard — {n} session{'s' if n != 1 else ''}\n\n") |
| 134 | + |
| 135 | + # Summary row |
| 136 | + w(f" Total cost: ~${report.total_cost:.4f}\n") |
| 137 | + w(f" Total tokens: {report.total_tokens:,}\n") |
| 138 | + w(f" Tool calls: {report.total_tool_calls:,}\n") |
| 139 | + w(f" Errors: {report.total_errors}\n") |
| 140 | + w(f" Avg duration: {_fmt_dur(report.avg_duration_s)}\n") |
| 141 | + w(f" Success rate: {report.success_rate*100:.0f}%\n\n") |
| 142 | + |
| 143 | + if not report.summaries: |
| 144 | + return |
| 145 | + |
| 146 | + # Table header |
| 147 | + w(f" {'ID':<14} {'Started':<12} {'Dur':>7} {'Tools':>5} " |
| 148 | + f"{'LLM':>4} {'Err':>3} {'Tokens':>8} {'Cost':>8} Status\n") |
| 149 | + w(" " + "-" * 80 + "\n") |
| 150 | + |
| 151 | + for s in report.summaries: |
| 152 | + status = "✓" if s.succeeded else "✗" |
| 153 | + w( |
| 154 | + f" {s.session_id[:12]:<14} {_fmt_ts(s.started_at):<12} " |
| 155 | + f"{_fmt_dur(s.duration_s):>7} {s.tool_calls:>5} " |
| 156 | + f"{s.llm_requests:>4} {s.errors:>3} " |
| 157 | + f"{s.total_tokens:>8,} ${s.estimated_cost:>7.4f} {status}\n" |
| 158 | + ) |
| 159 | + |
| 160 | + w("\n") |
| 161 | + |
| 162 | + # Trend: last 10 sessions cost |
| 163 | + if len(report.summaries) >= 3: |
| 164 | + recent = list(reversed(report.summaries[:10])) |
| 165 | + costs = [s.estimated_cost for s in recent] |
| 166 | + max_cost = max(costs) or 1.0 |
| 167 | + w(" Cost trend (oldest → newest):\n ") |
| 168 | + bars = "▁▂▃▄▅▆▇█" |
| 169 | + for c in costs: |
| 170 | + idx = min(int(c / max_cost * (len(bars) - 1)), len(bars) - 1) |
| 171 | + w(bars[idx]) |
| 172 | + w("\n\n") |
| 173 | + |
| 174 | + |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | +# HTML dashboard |
| 177 | +# --------------------------------------------------------------------------- |
| 178 | + |
| 179 | +def render_html_dashboard(report: DashboardReport) -> str: |
| 180 | + """Produce a self-contained HTML dashboard page.""" |
| 181 | + rows_html = "" |
| 182 | + for s in report.summaries: |
| 183 | + status_cls = "ok" if s.succeeded else "err" |
| 184 | + status_sym = "✓" if s.succeeded else "✗" |
| 185 | + rows_html += ( |
| 186 | + f"<tr class='{status_cls}'>" |
| 187 | + f"<td>{html.escape(s.session_id[:12])}</td>" |
| 188 | + f"<td>{html.escape(_fmt_ts(s.started_at))}</td>" |
| 189 | + f"<td>{html.escape(_fmt_dur(s.duration_s))}</td>" |
| 190 | + f"<td>{s.tool_calls}</td>" |
| 191 | + f"<td>{s.llm_requests}</td>" |
| 192 | + f"<td>{s.errors}</td>" |
| 193 | + f"<td>{s.total_tokens:,}</td>" |
| 194 | + f"<td>${s.estimated_cost:.4f}</td>" |
| 195 | + f"<td>{status_sym}</td>" |
| 196 | + f"</tr>\n" |
| 197 | + ) |
| 198 | + |
| 199 | + # Sparkline data for Chart.js-free inline SVG |
| 200 | + costs = [s.estimated_cost for s in reversed(report.summaries[:20])] |
| 201 | + max_c = max(costs) or 1.0 |
| 202 | + spark_points = "" |
| 203 | + if costs: |
| 204 | + w = 200 |
| 205 | + h = 40 |
| 206 | + pts = [] |
| 207 | + for i, c in enumerate(costs): |
| 208 | + x = int(i / max(len(costs) - 1, 1) * w) |
| 209 | + y = int(h - (c / max_c) * h) |
| 210 | + pts.append(f"{x},{y}") |
| 211 | + spark_points = " ".join(pts) |
| 212 | + |
| 213 | + return f"""<!DOCTYPE html> |
| 214 | +<html lang="en"> |
| 215 | +<head> |
| 216 | +<meta charset="utf-8"> |
| 217 | +<title>agent-strace dashboard</title> |
| 218 | +<style> |
| 219 | +body{{font-family:monospace;background:#0d1117;color:#c9d1d9;margin:0;padding:20px}} |
| 220 | +h1{{color:#58a6ff;font-size:1.2em;margin-bottom:16px}} |
| 221 | +.stats{{display:flex;gap:24px;margin-bottom:20px;flex-wrap:wrap}} |
| 222 | +.stat{{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px 20px}} |
| 223 | +.stat .label{{font-size:.75em;color:#8b949e}} |
| 224 | +.stat .value{{font-size:1.4em;color:#e6edf3;margin-top:4px}} |
| 225 | +table{{width:100%;border-collapse:collapse;font-size:.85em}} |
| 226 | +th{{background:#161b22;color:#8b949e;padding:6px 10px;text-align:left;border-bottom:1px solid #30363d}} |
| 227 | +td{{padding:5px 10px;border-bottom:1px solid #21262d}} |
| 228 | +tr.ok td:last-child{{color:#3fb950}} |
| 229 | +tr.err td:last-child{{color:#f85149}} |
| 230 | +tr:hover{{background:#161b22}} |
| 231 | +.spark{{margin-bottom:20px}} |
| 232 | +polyline{{fill:none;stroke:#58a6ff;stroke-width:1.5}} |
| 233 | +</style> |
| 234 | +</head> |
| 235 | +<body> |
| 236 | +<h1>agent-strace dashboard</h1> |
| 237 | +<div class="stats"> |
| 238 | + <div class="stat"><div class="label">Sessions</div><div class="value">{len(report.summaries)}</div></div> |
| 239 | + <div class="stat"><div class="label">Est. cost</div><div class="value">${report.total_cost:.4f}</div></div> |
| 240 | + <div class="stat"><div class="label">Total tokens</div><div class="value">{report.total_tokens:,}</div></div> |
| 241 | + <div class="stat"><div class="label">Tool calls</div><div class="value">{report.total_tool_calls:,}</div></div> |
| 242 | + <div class="stat"><div class="label">Errors</div><div class="value">{report.total_errors}</div></div> |
| 243 | + <div class="stat"><div class="label">Success rate</div><div class="value">{report.success_rate*100:.0f}%</div></div> |
| 244 | + <div class="stat"><div class="label">Avg duration</div><div class="value">{_fmt_dur(report.avg_duration_s)}</div></div> |
| 245 | +</div> |
| 246 | +<div class="spark"> |
| 247 | + <svg width="200" height="40" viewBox="0 0 200 40"> |
| 248 | + <polyline points="{spark_points}"/> |
| 249 | + </svg> |
| 250 | + <span style="font-size:.75em;color:#8b949e"> cost trend</span> |
| 251 | +</div> |
| 252 | +<table> |
| 253 | +<thead><tr> |
| 254 | + <th>Session</th><th>Started</th><th>Duration</th> |
| 255 | + <th>Tools</th><th>LLM</th><th>Errors</th> |
| 256 | + <th>Tokens</th><th>Cost</th><th>Status</th> |
| 257 | +</tr></thead> |
| 258 | +<tbody> |
| 259 | +{rows_html} |
| 260 | +</tbody> |
| 261 | +</table> |
| 262 | +</body> |
| 263 | +</html>""" |
| 264 | + |
| 265 | + |
| 266 | +# --------------------------------------------------------------------------- |
| 267 | +# CLI handler |
| 268 | +# --------------------------------------------------------------------------- |
| 269 | + |
| 270 | +def cmd_dashboard(args: argparse.Namespace) -> int: |
| 271 | + store = TraceStore(args.trace_dir) |
| 272 | + limit = getattr(args, "limit", 50) or 50 |
| 273 | + agent_filter = getattr(args, "agent", "") or "" |
| 274 | + |
| 275 | + report = build_dashboard(store, limit=limit, agent_filter=agent_filter) |
| 276 | + |
| 277 | + output_path = getattr(args, "output", None) |
| 278 | + if output_path: |
| 279 | + from pathlib import Path |
| 280 | + html_content = render_html_dashboard(report) |
| 281 | + Path(output_path).write_text(html_content) |
| 282 | + sys.stdout.write(f"Dashboard written to {output_path}\n") |
| 283 | + return 0 |
| 284 | + |
| 285 | + format_dashboard(report) |
| 286 | + return 0 |
0 commit comments