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
26 changes: 18 additions & 8 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
import os
import sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from datetime import datetime

Expand Down Expand Up @@ -742,7 +742,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 Expand Up @@ -1242,13 +1242,14 @@ def log_message(self, format, *args):
pass

def do_GET(self):
if self.path in ("/", "/index.html"):
path = self.path.split("?", 1)[0]
if path in ("/", "/index.html"):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(HTML_TEMPLATE.encode("utf-8"))

elif self.path == "/api/data":
elif path == "/api/data":
data = get_dashboard_data()
body = json.dumps(data).encode("utf-8")
self.send_response(200)
Expand All @@ -1262,14 +1263,18 @@ def do_GET(self):
self.end_headers()

def do_POST(self):
if self.path == "/api/rescan":
# Full rebuild: delete DB and rescan from scratch.
path, _, query = self.path.partition("?")
if path == "/api/rescan":
# Default: incremental scan (fast, non-destructive).
# Opt-in full rebuild with ?full=1 — useful when pricing or
# parsing logic changes and historical rows need to be redone.
# Pass DB_PATH / DEFAULT_PROJECTS_DIRS explicitly so tests that
# patch the module globals are honored (scan's defaults are
# frozen at def time and would otherwise target the real paths).
import scanner
db_path = DB_PATH
if db_path.exists():
full = "full=1" in query
if full and db_path.exists():
db_path.unlink()
result = scanner.scan(
db_path=db_path,
Expand All @@ -1290,8 +1295,13 @@ def do_POST(self):
def serve(host=None, port=None):
host = host or os.environ.get("HOST", "localhost")
port = port or int(os.environ.get("PORT", "8080"))
server = HTTPServer((host, port), DashboardHandler)
ThreadingHTTPServer.allow_reuse_address = True
server = ThreadingHTTPServer((host, port), DashboardHandler)
print(f"Dashboard running at http://{host}:{port}")
if host not in ("localhost", "127.0.0.1", "::1"):
print(f" WARNING: bound to {host} — no authentication. "
"Anyone reachable on this interface can read your project history "
"and trigger /api/rescan.")
print("Press Ctrl+C to stop.")
try:
server.serve_forever()
Expand Down
7 changes: 6 additions & 1 deletion scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,13 @@ def upsert_sessions(conn, sessions):


def insert_turns(conn, turns):
# INSERT OR REPLACE: if a later record arrives for the same message_id
# (Claude streams multiple records per message — the last has the final
# usage tallies), overwrite the earlier partial row. INSERT OR IGNORE
# would lock in stale partial counts when the streaming boundary fell
# between two incremental scans.
conn.executemany("""
INSERT OR IGNORE INTO turns
INSERT OR REPLACE INTO turns
(session_id, timestamp, model, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, tool_name, cwd, message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Expand Down