dashboard-for-claude-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_dashboard/__init__.py +0 -0
- claude_dashboard/__main__.py +55 -0
- claude_dashboard/app.py +276 -0
- claude_dashboard/parser.py +370 -0
- claude_dashboard/pricing.py +91 -0
- claude_dashboard/scanner.py +176 -0
- claude_dashboard/static/app.js +2799 -0
- claude_dashboard/static/chart.umd.min.js +20 -0
- claude_dashboard/static/index.html +273 -0
- claude_dashboard/static/styles.css +1109 -0
- claude_dashboard/store.py +591 -0
- dashboard_for_claude_code-0.1.0.dist-info/METADATA +277 -0
- dashboard_for_claude_code-0.1.0.dist-info/RECORD +16 -0
- dashboard_for_claude_code-0.1.0.dist-info/WHEEL +4 -0
- dashboard_for_claude_code-0.1.0.dist-info/entry_points.txt +2 -0
- dashboard_for_claude_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
import uvicorn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _dist_version() -> str:
|
|
9
|
+
try:
|
|
10
|
+
return version("dashboard-for-claude-code")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
return "unknown (running from source without install)"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None):
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="dashboard-for-claude-code",
|
|
18
|
+
description="Claude Code Personal Analytics — a private, local, read-only analytics dashboard for your Claude Code sessions.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--host",
|
|
22
|
+
help="interface to bind; overrides DASHBOARD_HOST (default: 127.0.0.1, "
|
|
23
|
+
"localhost-only — use 0.0.0.0 to expose on your LAN)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--port", type=int,
|
|
27
|
+
help="port to serve on; overrides DASHBOARD_PORT (default: 8042)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version", action="version", version=f"%(prog)s {_dist_version()}",
|
|
31
|
+
)
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
host = args.host or os.environ.get("DASHBOARD_HOST", "127.0.0.1")
|
|
35
|
+
if args.port is not None:
|
|
36
|
+
port = args.port
|
|
37
|
+
else:
|
|
38
|
+
try:
|
|
39
|
+
port = int(os.environ.get("DASHBOARD_PORT", "8042"))
|
|
40
|
+
except ValueError:
|
|
41
|
+
print("ERROR: DASHBOARD_PORT must be an integer", flush=True)
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
|
|
44
|
+
if args.host:
|
|
45
|
+
# app.py derives its Host-header guard (and cookie secure flag) from
|
|
46
|
+
# DASHBOARD_HOST at import time, so the override must land in the
|
|
47
|
+
# environment before the app module is imported below.
|
|
48
|
+
os.environ["DASHBOARD_HOST"] = args.host
|
|
49
|
+
|
|
50
|
+
from claude_dashboard.app import app
|
|
51
|
+
uvicorn.run(app, host=host, port=port)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
claude_dashboard/app.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from claude_dashboard.scanner import RefreshReport, refresh
|
|
16
|
+
from claude_dashboard.store import Store
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger("claude_dashboard")
|
|
19
|
+
|
|
20
|
+
# DB location. DASHBOARD_DB lets tests, demos, and the screenshot generator
|
|
21
|
+
# point at an isolated database so a run never has to touch (or scan) your
|
|
22
|
+
# real ~/.claude data.
|
|
23
|
+
def _default_db() -> Path:
|
|
24
|
+
# Source checkout (repo root has pyproject.toml): keep the DB in-tree at
|
|
25
|
+
# data/usage.db, as documented. Installed package (pipx/uvx/pip): the tree
|
|
26
|
+
# location would land inside site-packages and be wiped on reinstall, so
|
|
27
|
+
# use a per-user data dir instead.
|
|
28
|
+
root = Path(__file__).resolve().parent.parent.parent
|
|
29
|
+
if (root / "pyproject.toml").is_file():
|
|
30
|
+
return root / "data" / "usage.db"
|
|
31
|
+
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
|
|
32
|
+
return base / "dashboard-for-claude-code" / "usage.db"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_DB_PATH = Path(os.environ.get("DASHBOARD_DB") or _default_db())
|
|
36
|
+
_STATIC = Path(__file__).parent / "static"
|
|
37
|
+
|
|
38
|
+
# Optional token auth — active only when DASHBOARD_AUTH_TOKEN is set.
|
|
39
|
+
# Accepts: Authorization: Bearer <token> OR cookie dashboard_auth=<token>
|
|
40
|
+
_AUTH_TOKEN: str | None = os.environ.get("DASHBOARD_AUTH_TOKEN") or None
|
|
41
|
+
|
|
42
|
+
# Loopback detection — used for DNS-rebinding protection (M3) and cookie
|
|
43
|
+
# secure flag (M5). True when bound to a loopback address (the default).
|
|
44
|
+
_HOST = os.environ.get("DASHBOARD_HOST", "127.0.0.1")
|
|
45
|
+
_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "0:0:0:0:0:0:0:1"}
|
|
46
|
+
_IS_LOCALHOST = _HOST in _LOOPBACK_ADDRS
|
|
47
|
+
_LOOPBACK_HOST_NAMES = {"localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"}
|
|
48
|
+
|
|
49
|
+
_store: Store | None = None
|
|
50
|
+
_refresh_lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _host_name(raw_host: str) -> str:
|
|
54
|
+
"""Extract the hostname from a Host header value.
|
|
55
|
+
|
|
56
|
+
Handles bracketed IPv6 literals ("[::1]:8042" → "::1"); a naive
|
|
57
|
+
split(":") would mangle them into "" and skip the loopback check.
|
|
58
|
+
"""
|
|
59
|
+
raw_host = raw_host.strip()
|
|
60
|
+
if raw_host.startswith("["):
|
|
61
|
+
end = raw_host.find("]")
|
|
62
|
+
return raw_host[1:end] if end != -1 else ""
|
|
63
|
+
return raw_host.split(":")[0]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def _lifespan(app: FastAPI):
|
|
68
|
+
global _store
|
|
69
|
+
_store = Store(_DB_PATH)
|
|
70
|
+
# Initial scan in background so startup is fast. DASHBOARD_NO_SCAN=1 disables
|
|
71
|
+
# it entirely (used by tests and the screenshot generator, which supply their
|
|
72
|
+
# own pre-populated DB and must never read ~/.claude).
|
|
73
|
+
if not os.environ.get("DASHBOARD_NO_SCAN"):
|
|
74
|
+
threading.Thread(target=_do_refresh, daemon=True).start()
|
|
75
|
+
try:
|
|
76
|
+
yield
|
|
77
|
+
finally:
|
|
78
|
+
if _store:
|
|
79
|
+
_store.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
app = FastAPI(title="Claude Code Personal Analytics", lifespan=_lifespan)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.middleware("http")
|
|
86
|
+
async def security_middleware(request: Request, call_next):
|
|
87
|
+
# ── DNS-rebinding protection (M3) ────────────────────────────────────────
|
|
88
|
+
# When bound to a loopback address, reject requests whose Host header
|
|
89
|
+
# isn't a recognized localhost name. This blocks a malicious page from
|
|
90
|
+
# reaching the unauthenticated API via a rebound hostname.
|
|
91
|
+
if _IS_LOCALHOST:
|
|
92
|
+
host_name = _host_name(request.headers.get("host", ""))
|
|
93
|
+
# An absent/empty Host is rejected too: every legitimate client
|
|
94
|
+
# (HTTP/1.1 requires Host) sends one, and allowing it would let
|
|
95
|
+
# malformed requests skip the check.
|
|
96
|
+
if host_name not in _LOOPBACK_HOST_NAMES:
|
|
97
|
+
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
|
98
|
+
|
|
99
|
+
# ── Optional token auth ──────────────────────────────────────────────────
|
|
100
|
+
if _AUTH_TOKEN:
|
|
101
|
+
# Allow the login page (GET /) and static assets even without auth,
|
|
102
|
+
# so the browser can render the 401 page gracefully.
|
|
103
|
+
skip = request.url.path in ("/", "/overview", "/projects", "/sessions", "/calendar",
|
|
104
|
+
"/settings", "/api/login") \
|
|
105
|
+
or request.url.path.startswith("/static/")
|
|
106
|
+
if not skip:
|
|
107
|
+
bearer = request.headers.get("Authorization", "")
|
|
108
|
+
token = bearer.removeprefix("Bearer ").strip() if bearer.startswith("Bearer ") else ""
|
|
109
|
+
if not token:
|
|
110
|
+
token = request.cookies.get("dashboard_auth", "")
|
|
111
|
+
if not hmac.compare_digest(token, _AUTH_TOKEN):
|
|
112
|
+
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
|
|
113
|
+
|
|
114
|
+
# ── No-cache for static assets (local mode) ──────────────────────────────
|
|
115
|
+
response = await call_next(request)
|
|
116
|
+
if request.url.path.startswith("/static/"):
|
|
117
|
+
response.headers["Cache-Control"] = "no-store"
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _do_refresh(prune: bool = False) -> RefreshReport:
|
|
122
|
+
with _refresh_lock:
|
|
123
|
+
return refresh(_store, prune=prune)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── API ────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
@app.get("/api/summary")
|
|
129
|
+
def api_summary():
|
|
130
|
+
return _store.summary()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.get("/api/projects")
|
|
134
|
+
def api_projects(include_hidden: bool = Query(False)):
|
|
135
|
+
return _store.list_projects(include_hidden=include_hidden)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.get("/api/project")
|
|
139
|
+
def api_project(path: str = Query(None), name: str = Query(None)):
|
|
140
|
+
if not path and not name:
|
|
141
|
+
raise HTTPException(400, "path or name required")
|
|
142
|
+
# include_hidden: hidden projects are excluded from listings, but their
|
|
143
|
+
# detail page must stay reachable via a direct link.
|
|
144
|
+
projects = _store.list_projects(include_hidden=True)
|
|
145
|
+
if path:
|
|
146
|
+
matched = [p for p in projects if p.get("project_path") == path]
|
|
147
|
+
else:
|
|
148
|
+
# Match by display name (project_name after rename applied)
|
|
149
|
+
matched = [p for p in projects if p.get("project_name") == name]
|
|
150
|
+
if not matched:
|
|
151
|
+
raise HTTPException(404, "Project not found")
|
|
152
|
+
|
|
153
|
+
if len(matched) == 1:
|
|
154
|
+
proj = matched[0]
|
|
155
|
+
proj["project_paths"] = [proj["project_path"]]
|
|
156
|
+
proj["sessions"] = _store.list_sessions(proj["project_path"])
|
|
157
|
+
return proj
|
|
158
|
+
|
|
159
|
+
# Multiple paths share the same display name — merge on the fly
|
|
160
|
+
all_paths = [p["project_path"] for p in matched]
|
|
161
|
+
base = dict(matched[0])
|
|
162
|
+
base["project_paths"] = all_paths
|
|
163
|
+
for p in matched[1:]:
|
|
164
|
+
for f in ("session_count", "user_rounds", "assistant_messages",
|
|
165
|
+
"api_duration_ms", "wall_duration_ms", "code_lines_added",
|
|
166
|
+
"code_lines_removed", "cost_usd"):
|
|
167
|
+
base[f] = (base.get(f) or 0) + (p.get(f) or 0)
|
|
168
|
+
base["last_active"] = max(base.get("last_active") or "", p.get("last_active") or "") or None
|
|
169
|
+
base["first_active"] = min(
|
|
170
|
+
base.get("first_active") or "\xff", p.get("first_active") or "\xff"
|
|
171
|
+
).replace("\xff", "") or None
|
|
172
|
+
# merge tokens_by_model
|
|
173
|
+
for model, counts in (p.get("tokens_by_model") or {}).items():
|
|
174
|
+
if model not in base["tokens_by_model"]:
|
|
175
|
+
base["tokens_by_model"][model] = {"input": 0, "output": 0, "cache_read": 0,
|
|
176
|
+
"cache_write_5m": 0, "cache_write_1h": 0}
|
|
177
|
+
for k, v in counts.items():
|
|
178
|
+
base["tokens_by_model"][model][k] = base["tokens_by_model"][model].get(k, 0) + (v or 0)
|
|
179
|
+
for tool, cnt in (p.get("tools") or {}).items():
|
|
180
|
+
base["tools"][tool] = base["tools"].get(tool, 0) + cnt
|
|
181
|
+
base["sessions"] = _store.list_sessions_for_paths(all_paths)
|
|
182
|
+
return base
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.get("/api/sessions")
|
|
186
|
+
def api_sessions():
|
|
187
|
+
return _store.list_sessions()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.get("/api/sessions/{session_id}")
|
|
191
|
+
def api_session(session_id: str):
|
|
192
|
+
s = _store.get_session(session_id)
|
|
193
|
+
if not s:
|
|
194
|
+
raise HTTPException(404, "Session not found")
|
|
195
|
+
return s
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.get("/api/settings")
|
|
199
|
+
def api_get_settings():
|
|
200
|
+
return _store.get_all_project_settings()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.put("/api/settings")
|
|
204
|
+
async def api_put_setting(body: dict[str, Any]):
|
|
205
|
+
project_path = body.get("project_path")
|
|
206
|
+
if not project_path:
|
|
207
|
+
raise HTTPException(400, "project_path required")
|
|
208
|
+
_store.upsert_project_setting(
|
|
209
|
+
project_path,
|
|
210
|
+
body.get("display_name") or None,
|
|
211
|
+
bool(body.get("hidden", False)),
|
|
212
|
+
)
|
|
213
|
+
return {"ok": True}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.post("/api/login")
|
|
217
|
+
async def api_login(body: dict[str, Any], response: Response):
|
|
218
|
+
if not _AUTH_TOKEN:
|
|
219
|
+
raise HTTPException(404, "Auth not enabled")
|
|
220
|
+
token = body.get("token", "")
|
|
221
|
+
if not isinstance(token, str) or not hmac.compare_digest(token, _AUTH_TOKEN):
|
|
222
|
+
raise HTTPException(401, "Invalid token")
|
|
223
|
+
# Set the cookie from the trusted server-side _AUTH_TOKEN, not the request
|
|
224
|
+
# value: they are equal here (compare_digest passed), and using the vetted
|
|
225
|
+
# constant keeps user-supplied input out of the Set-Cookie header.
|
|
226
|
+
# secure=True on non-localhost so the cookie is HTTPS-only when hosted remotely.
|
|
227
|
+
response.set_cookie("dashboard_auth", _AUTH_TOKEN, httponly=True, samesite="strict",
|
|
228
|
+
secure=not _IS_LOCALHOST)
|
|
229
|
+
return {"ok": True}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.get("/api/summaries/missing")
|
|
233
|
+
def api_summaries_missing():
|
|
234
|
+
"""Sessions that still need an AI summary, most recent first."""
|
|
235
|
+
return _store.sessions_missing_summary()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Summaries are not stored in the DB: scripts/set_summaries.py writes
|
|
239
|
+
# data/session_summary.json (the source of truth) and Store reads it directly
|
|
240
|
+
# at query time. See Store.get_all_summaries.
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.post("/api/refresh")
|
|
244
|
+
def api_refresh(prune: bool = Query(False)):
|
|
245
|
+
"""Re-scan transcripts. prune=true also drops sessions whose transcript
|
|
246
|
+
file no longer exists on disk (by default deleted transcripts stay in the
|
|
247
|
+
cache so history survives Claude Code's own cleanup)."""
|
|
248
|
+
report = _do_refresh(prune=prune)
|
|
249
|
+
# Log the raw error strings (they contain filesystem paths and exception
|
|
250
|
+
# detail) server-side only; expose just a count to the client so a scan
|
|
251
|
+
# never leaks internal paths or stack-trace text.
|
|
252
|
+
if report.errors:
|
|
253
|
+
_log.warning("Refresh completed with %d error(s):\n%s",
|
|
254
|
+
len(report.errors), "\n".join(report.errors))
|
|
255
|
+
return {
|
|
256
|
+
"added": report.added,
|
|
257
|
+
"updated": report.updated,
|
|
258
|
+
"skipped": report.skipped,
|
|
259
|
+
"pruned": report.pruned,
|
|
260
|
+
"errors": len(report.errors),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ── Static files ───────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.get("/")
|
|
270
|
+
@app.get("/overview")
|
|
271
|
+
@app.get("/projects")
|
|
272
|
+
@app.get("/sessions")
|
|
273
|
+
@app.get("/calendar")
|
|
274
|
+
@app.get("/settings")
|
|
275
|
+
def index():
|
|
276
|
+
return FileResponse(str(_STATIC / "index.html"))
|