| 25 | |
| 26 | |
| 27 | def get_dashboard_data(db_path=DB_PATH): |
| 28 | if not db_path.exists(): |
| 29 | return {"error": "Database not found. Run: python cli.py scan"} |
| 30 | |
| 31 | conn = sqlite3.connect(db_path) |
| 32 | # The dashboard reads while a background scan may be committing (cmd_dashboard |
| 33 | # serves first, scans in a background thread; /api/rescan scans in-process too). |
| 34 | # Wait briefly for write locks instead of raising "database is locked". |
| 35 | conn.execute("PRAGMA busy_timeout = 5000") |
| 36 | conn.row_factory = sqlite3.Row |
| 37 | # Ensure the schema is current before querying. cmd_dashboard binds and serves |
| 38 | # *before* its background scan runs init_db, so on the first load after an |
| 39 | # upgrade a pre-existing DB may still be on the old schema — the subagent |
| 40 | # queries below reference the `agents` table and the `is_subagent`/`agent_id` |
| 41 | # columns and would raise "no such table: agents" until the scan caught up. |
| 42 | # init_db is idempotent (CREATE ... IF NOT EXISTS + additive column checks), |
| 43 | # so this is a cheap no-op once migrated. |
| 44 | init_db(conn) |
| 45 | |
| 46 | # ── All models (for filter UI) ──────────────────────────────────────────── |
| 47 | # GROUP BY uses the normalised expression too so NULL and '' don't end up |
| 48 | # as two separate "unknown" rows. |
| 49 | model_rows = conn.execute(""" |
| 50 | SELECT COALESCE(NULLIF(model, ''), 'unknown') as model |
| 51 | FROM turns |
| 52 | GROUP BY COALESCE(NULLIF(model, ''), 'unknown') |
| 53 | ORDER BY SUM(input_tokens + output_tokens) DESC |
| 54 | """).fetchall() |
| 55 | all_models = [r["model"] for r in model_rows] |
| 56 | |
| 57 | # ── Daily per-model, ALL history (client filters by range) ──────────────── |
| 58 | daily_rows = conn.execute(""" |
| 59 | SELECT |
| 60 | substr(timestamp, 1, 10) as day, |
| 61 | COALESCE(NULLIF(model, ''), 'unknown') as model, |
| 62 | SUM(input_tokens) as input, |
| 63 | SUM(output_tokens) as output, |
| 64 | SUM(cache_read_tokens) as cache_read, |
| 65 | SUM(cache_creation_tokens) as cache_creation, |
| 66 | COUNT(*) as turns |
| 67 | FROM turns |
| 68 | GROUP BY day, COALESCE(NULLIF(model, ''), 'unknown') |
| 69 | ORDER BY day, model |
| 70 | """).fetchall() |
| 71 | |
| 72 | daily_by_model = [{ |
| 73 | "day": r["day"], |
| 74 | "model": r["model"], |
| 75 | "input": r["input"] or 0, |
| 76 | "output": r["output"] or 0, |
| 77 | "cache_read": r["cache_read"] or 0, |
| 78 | "cache_creation": r["cache_creation"] or 0, |
| 79 | "turns": r["turns"] or 0, |
| 80 | } for r in daily_rows] |
| 81 | |
| 82 | # ── Hourly per-day per-model (client filters by range + TZ-shifts) ──────── |
| 83 | # Timestamps are ISO8601 UTC (e.g. "2026-04-08T09:30:00Z"); chars 12-13 = hour. |
| 84 | hourly_rows = conn.execute(""" |