Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def get_dashboard_data(db_path=DB_PATH):

# ── All models (for filter UI) ────────────────────────────────────────────
model_rows = conn.execute("""
SELECT COALESCE(model, 'unknown') as model
SELECT COALESCE(NULLIF(model, ''), 'unknown') as model
FROM turns
GROUP BY model
ORDER BY SUM(input_tokens + output_tokens) DESC
Expand All @@ -31,8 +31,8 @@ def get_dashboard_data(db_path=DB_PATH):
# ── Daily per-model, ALL history (client filters by range) ────────────────
daily_rows = conn.execute("""
SELECT
substr(timestamp, 1, 10) as day,
COALESCE(model, 'unknown') as model,
substr(timestamp, 1, 10) as day,
COALESCE(NULLIF(model, ''), 'unknown') as model,
SUM(input_tokens) as input,
SUM(output_tokens) as output,
SUM(cache_read_tokens) as cache_read,
Expand All @@ -59,7 +59,7 @@ def get_dashboard_data(db_path=DB_PATH):
SELECT
substr(timestamp, 1, 10) as day,
CAST(substr(timestamp, 12, 2) AS INTEGER) as hour,
COALESCE(model, 'unknown') as model,
COALESCE(NULLIF(model, ''), 'unknown') as model,
SUM(output_tokens) as output,
COUNT(*) as turns
FROM turns
Expand Down Expand Up @@ -555,7 +555,10 @@ def get_dashboard_data(db_path=DB_PATH):

function readURLModels(allModels) {
const param = new URLSearchParams(window.location.search).get('models');
if (!param) return new Set(allModels.filter(m => isBillable(m)));
if (!param) {
const billable = allModels.filter(m => isBillable(m));
return new Set(billable.length > 0 ? billable : allModels);
}
const fromURL = new Set(param.split(',').map(s => s.trim()).filter(Boolean));
return new Set(allModels.filter(m => fromURL.has(m)));
}
Expand Down Expand Up @@ -742,7 +745,7 @@ def get_dashboard_data(db_path=DB_PATH):

// Hourly aggregation (filtered by model + range, then bucketed by UTC hour)
const hourlySrc = (rawData.hourly_by_model || []).filter(r =>
selectedModels.has(r.model) && (!cutoff || r.day >= cutoff)
selectedModels.has(r.model) && (!start || r.day >= start) && (!end || r.day <= end)
);
const hourlyAgg = aggregateHourly(hourlySrc, hourlyTZ);

Expand Down
111 changes: 111 additions & 0 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
import re
import sqlite3
import tempfile
import threading
Expand Down Expand Up @@ -195,7 +196,73 @@ def test_404_for_unknown_path(self):
self.assertEqual(e.code, 404)


class TestEmptyStringModel(unittest.TestCase):
"""Regression: turns with model='' must be mapped to 'unknown', not ''."""

def setUp(self):
self.tmpfile = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmpfile.close()
self.db_path = Path(self.tmpfile.name)
conn = get_db(self.db_path)
init_db(conn)
sessions = [{
"session_id": "sess-nomodel", "project_name": "user/proj",
"first_timestamp": "2026-03-12T10:00:00Z",
"last_timestamp": "2026-03-12T11:00:00Z",
"git_branch": "main", "model": None,
"total_input_tokens": 1000, "total_output_tokens": 500,
"total_cache_read": 0, "total_cache_creation": 0,
"turn_count": 5,
}]
upsert_sessions(conn, sessions)
turns = [{
"session_id": "sess-nomodel", "timestamp": "2026-03-12T10:30:00Z",
"model": "", # empty string — the problematic case
"input_tokens": 1000, "output_tokens": 500,
"cache_read_tokens": 0, "cache_creation_tokens": 0,
"tool_name": None, "cwd": "/tmp", "message_id": "",
}]
insert_turns(conn, turns)
conn.commit()
conn.close()

def tearDown(self):
os.unlink(self.db_path)

def test_empty_model_mapped_to_unknown_in_all_models(self):
data = get_dashboard_data(db_path=self.db_path)
self.assertIn("unknown", data["all_models"])
self.assertNotIn("", data["all_models"])

def test_empty_model_mapped_to_unknown_in_daily(self):
data = get_dashboard_data(db_path=self.db_path)
models_in_daily = {r["model"] for r in data["daily_by_model"]}
self.assertIn("unknown", models_in_daily)
self.assertNotIn("", models_in_daily)

def test_empty_model_mapped_to_unknown_in_hourly(self):
data = get_dashboard_data(db_path=self.db_path)
models_in_hourly = {r["model"] for r in data["hourly_by_model"]}
self.assertIn("unknown", models_in_hourly)
self.assertNotIn("", models_in_hourly)


class TestHTMLTemplate(unittest.TestCase):
def _extract_js_function(self, name):
signature = f"function {name}("
start = HTML_TEMPLATE.index(signature)
brace_start = HTML_TEMPLATE.index("{", start)
depth = 0
for idx in range(brace_start, len(HTML_TEMPLATE)):
char = HTML_TEMPLATE[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return HTML_TEMPLATE[start:idx + 1]
self.fail(f"Could not extract JavaScript function {name}")

def test_template_is_valid_html(self):
self.assertIn("<!DOCTYPE html>", HTML_TEMPLATE)
self.assertIn("</html>", HTML_TEMPLATE)
Expand Down Expand Up @@ -228,6 +295,50 @@ def test_hourly_peak_hour_constants(self):
self.assertIn('PEAK_HOURS_UTC', HTML_TEMPLATE)
self.assertIn('[12, 13, 14, 15, 16, 17]', HTML_TEMPLATE)

def test_read_url_models_falls_back_to_all_when_no_billable(self):
"""Regression GH#76: if no model names contain opus/sonnet/haiku,
readURLModels must select ALL models, not return an empty set that
causes the dashboard to show 0 data for every range including 'All'."""
read_url_models = self._extract_js_function("readURLModels")
# The fix: billable.length > 0 ? billable : allModels
self.assertIn("billable.length", read_url_models)
self.assertIn("allModels", read_url_models)

def test_range_filter_uses_bounds_for_all_filtered_data(self):
"""Regression for GH#88: range filtering must not reference undefined variables."""
apply_filter = self._extract_js_function("applyFilter")

bounds_decl = apply_filter.index("const { start, end } = getRangeBounds(selectedRange);")
daily_filter = apply_filter.index("rawData.daily_by_model.filter")
sessions_filter = apply_filter.index("rawData.sessions_all.filter")
hourly_filter = apply_filter.index("rawData.hourly_by_model || []")

self.assertLess(bounds_decl, daily_filter)
self.assertLess(bounds_decl, sessions_filter)
self.assertLess(bounds_decl, hourly_filter)
self.assertNotRegex(apply_filter, r"\bcutoff\b")
for filter_start in [daily_filter, sessions_filter, hourly_filter]:
filter_block = apply_filter[filter_start:filter_start + 180]
self.assertIn("!start", filter_block)
self.assertIn("!end", filter_block)

def test_template_handles_each_supported_range(self):
"""Each selectable range needs UI, URL parsing, labels, ticks, and bounds support."""
expected_ranges = ["7d", "30d", "90d", "all"]
get_bounds = self._extract_js_function("getRangeBounds")
read_url_range = self._extract_js_function("readURLRange")

for range_name in expected_ranges:
self.assertIn(f'data-range="{range_name}"', HTML_TEMPLATE)
self.assertIn("VALID_RANGES.includes(p)", read_url_range)
self.assertRegex(HTML_TEMPLATE, rf"RANGE_LABELS\s*=\s*\{{[^}}]*'{re.escape(range_name)}':")
self.assertRegex(HTML_TEMPLATE, rf"RANGE_TICKS\s*=\s*\{{[^}}]*'{re.escape(range_name)}':")

self.assertIn("range === 'all'", get_bounds)
self.assertIn("range === '7d' ? 7", get_bounds)
self.assertIn("range === '30d' ? 30", get_bounds)
self.assertIn(": 90", get_bounds)


class TestPricingParity(unittest.TestCase):
"""Verify CLI and dashboard pricing tables stay in sync."""
Expand Down
Loading