diff --git a/dashboard.py b/dashboard.py index ebf8d5f..d566719 100644 --- a/dashboard.py +++ b/dashboard.py @@ -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 @@ -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, @@ -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 @@ -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))); } @@ -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); diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 76287d1..d014875 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2,6 +2,7 @@ import json import os +import re import sqlite3 import tempfile import threading @@ -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("", HTML_TEMPLATE) self.assertIn("", HTML_TEMPLATE) @@ -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."""