@jeganwrites/claudash 1.0.0
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.
- package/CONTRIBUTING.md +35 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/analyzer.py +890 -0
- package/bin/claudash.js +121 -0
- package/claude_ai_tracker.py +358 -0
- package/cli.py +1034 -0
- package/config.py +100 -0
- package/db.py +1156 -0
- package/fix_tracker.py +539 -0
- package/insights.py +359 -0
- package/mcp_server.py +414 -0
- package/package.json +39 -0
- package/scanner.py +385 -0
- package/server.py +762 -0
- package/templates/accounts.html +936 -0
- package/templates/dashboard.html +1742 -0
- package/tools/get-derived-keys.py +112 -0
- package/tools/mac-sync.py +386 -0
- package/tools/oauth_sync.py +308 -0
- package/tools/setup-pm2.sh +53 -0
- package/waste_patterns.py +334 -0
package/server.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
9
|
+
from http.server import ThreadingHTTPServer
|
|
10
|
+
from urllib.parse import urlparse, parse_qs
|
|
11
|
+
|
|
12
|
+
from db import (
|
|
13
|
+
get_conn, query_alerts, get_session_count, get_db_size_mb,
|
|
14
|
+
get_latest_claude_ai_usage, get_claude_ai_history,
|
|
15
|
+
get_insights, dismiss_insight, get_daily_snapshots, get_window_burns,
|
|
16
|
+
get_all_accounts, get_account_projects, get_accounts_config,
|
|
17
|
+
create_account, update_account, delete_account,
|
|
18
|
+
add_account_project, remove_account_project,
|
|
19
|
+
get_claude_ai_accounts_all, get_claude_ai_account,
|
|
20
|
+
get_latest_claude_ai_snapshot, get_claude_ai_snapshot_history,
|
|
21
|
+
clear_claude_ai_session,
|
|
22
|
+
get_setting,
|
|
23
|
+
upsert_claude_ai_account, update_claude_ai_account_status,
|
|
24
|
+
insert_claude_ai_snapshot,
|
|
25
|
+
get_real_story_insights,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
from analyzer import full_analysis, project_metrics, window_intelligence, trend_metrics
|
|
30
|
+
from scanner import scan_all, get_last_scan_time, preview_paths, discover_claude_paths
|
|
31
|
+
from insights import generate_insights
|
|
32
|
+
from claude_ai_tracker import (
|
|
33
|
+
poll_all as poll_claude_ai, get_account_statuses, get_last_poll_time,
|
|
34
|
+
setup_account as tracker_setup_account, poll_single as tracker_poll_single,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
|
|
38
|
+
|
|
39
|
+
# Response cache for /api/data — {account: (timestamp, result)}
|
|
40
|
+
_data_cache = {}
|
|
41
|
+
CACHE_TTL = 30 # seconds
|
|
42
|
+
_server_start_time = time.time()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DashboardHandler(BaseHTTPRequestHandler):
|
|
46
|
+
def do_GET(self):
|
|
47
|
+
parsed = urlparse(self.path)
|
|
48
|
+
path = parsed.path
|
|
49
|
+
params = parse_qs(parsed.query)
|
|
50
|
+
|
|
51
|
+
if path == "/" or path == "/index.html":
|
|
52
|
+
self._serve_template("dashboard.html")
|
|
53
|
+
|
|
54
|
+
elif path == "/accounts":
|
|
55
|
+
self._serve_template("accounts.html")
|
|
56
|
+
|
|
57
|
+
elif path == "/api/data":
|
|
58
|
+
account = params.get("account", ["all"])[0]
|
|
59
|
+
self._serve_json(self._get_data(account))
|
|
60
|
+
|
|
61
|
+
elif path == "/api/projects":
|
|
62
|
+
account = params.get("account", ["all"])[0]
|
|
63
|
+
conn = get_conn()
|
|
64
|
+
data = project_metrics(conn, account)
|
|
65
|
+
conn.close()
|
|
66
|
+
self._serve_json(data)
|
|
67
|
+
|
|
68
|
+
elif path == "/api/insights":
|
|
69
|
+
account = params.get("account", [None])[0]
|
|
70
|
+
dismissed = int(params.get("dismissed", ["0"])[0])
|
|
71
|
+
conn = get_conn()
|
|
72
|
+
rows = get_insights(conn, account, dismissed)
|
|
73
|
+
conn.close()
|
|
74
|
+
self._serve_json([dict(r) for r in rows])
|
|
75
|
+
|
|
76
|
+
elif path == "/api/window":
|
|
77
|
+
account = params.get("account", ["personal_max"])[0]
|
|
78
|
+
conn = get_conn()
|
|
79
|
+
data = window_intelligence(conn, account)
|
|
80
|
+
conn.close()
|
|
81
|
+
self._serve_json(data)
|
|
82
|
+
|
|
83
|
+
elif path == "/api/trends":
|
|
84
|
+
account = params.get("account", ["all"])[0]
|
|
85
|
+
days = int(params.get("days", ["7"])[0])
|
|
86
|
+
conn = get_conn()
|
|
87
|
+
data = trend_metrics(conn, account, days)
|
|
88
|
+
conn.close()
|
|
89
|
+
self._serve_json(data)
|
|
90
|
+
|
|
91
|
+
elif path == "/api/alerts":
|
|
92
|
+
conn = get_conn()
|
|
93
|
+
alerts = [dict(r) for r in query_alerts(conn)]
|
|
94
|
+
conn.close()
|
|
95
|
+
self._serve_json(alerts)
|
|
96
|
+
|
|
97
|
+
elif path == "/api/claude-ai":
|
|
98
|
+
conn = get_conn()
|
|
99
|
+
latest = [dict(r) for r in get_latest_claude_ai_usage(conn)]
|
|
100
|
+
history = [dict(r) for r in get_claude_ai_history(conn)]
|
|
101
|
+
statuses = get_account_statuses()
|
|
102
|
+
conn.close()
|
|
103
|
+
self._serve_json({
|
|
104
|
+
"accounts": latest,
|
|
105
|
+
"history": history,
|
|
106
|
+
"statuses": statuses,
|
|
107
|
+
"last_poll": get_last_poll_time(),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
elif path == "/api/health":
|
|
111
|
+
conn = get_conn()
|
|
112
|
+
total = get_session_count(conn)
|
|
113
|
+
accounts = get_accounts_config(conn)
|
|
114
|
+
conn.close()
|
|
115
|
+
self._serve_json({
|
|
116
|
+
"db_size_mb": get_db_size_mb(),
|
|
117
|
+
"total_records": total,
|
|
118
|
+
"last_scan": get_last_scan_time(),
|
|
119
|
+
"accounts_active": list(accounts.keys()),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
elif path == "/api/real-story":
|
|
123
|
+
import time as _time
|
|
124
|
+
stories = get_real_story_insights()
|
|
125
|
+
conn = get_conn()
|
|
126
|
+
total = get_session_count(conn)
|
|
127
|
+
conn.close()
|
|
128
|
+
self._serve_json({
|
|
129
|
+
"stories": stories,
|
|
130
|
+
"generated_at": int(_time.time()),
|
|
131
|
+
"sessions_analyzed": total,
|
|
132
|
+
"date_range_days": 30,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
# ── Account management GET endpoints ──
|
|
136
|
+
elif path == "/api/accounts":
|
|
137
|
+
conn = get_conn()
|
|
138
|
+
accounts = get_all_accounts(conn)
|
|
139
|
+
# Add session stats per account
|
|
140
|
+
for a in accounts:
|
|
141
|
+
row = conn.execute(
|
|
142
|
+
"SELECT COUNT(DISTINCT session_id) as cnt, COALESCE(SUM(cost_usd),0) as cost "
|
|
143
|
+
"FROM sessions WHERE account = ? AND timestamp >= ?",
|
|
144
|
+
(a["account_id"], int((__import__("time").time()) - 30 * 86400)),
|
|
145
|
+
).fetchone()
|
|
146
|
+
a["sessions_30d"] = row["cnt"] if row else 0
|
|
147
|
+
a["cost_30d"] = round(row["cost"], 2) if row else 0
|
|
148
|
+
conn.close()
|
|
149
|
+
self._serve_json(accounts)
|
|
150
|
+
|
|
151
|
+
elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path):
|
|
152
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path)
|
|
153
|
+
account_id = m.group(1)
|
|
154
|
+
conn = get_conn()
|
|
155
|
+
data = get_account_projects(conn, account_id)
|
|
156
|
+
conn.close()
|
|
157
|
+
self._serve_json(data)
|
|
158
|
+
|
|
159
|
+
elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/preview$", path):
|
|
160
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/preview$", path)
|
|
161
|
+
account_id = m.group(1)
|
|
162
|
+
conn = get_conn()
|
|
163
|
+
row = conn.execute("SELECT data_paths FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
|
|
164
|
+
conn.close()
|
|
165
|
+
if not row:
|
|
166
|
+
self._serve_json({"error": "account not found"}, 404)
|
|
167
|
+
return
|
|
168
|
+
paths = json.loads(row["data_paths"]) if row["data_paths"] else []
|
|
169
|
+
info = preview_paths(paths)
|
|
170
|
+
total_est = sum(p["jsonl_files"] for p in info)
|
|
171
|
+
self._serve_json({
|
|
172
|
+
"paths": info,
|
|
173
|
+
"estimated_records": total_est,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
# ── claude.ai browser tracking GET endpoints ──
|
|
177
|
+
elif path == "/api/claude-ai/accounts":
|
|
178
|
+
conn = get_conn()
|
|
179
|
+
accounts = get_claude_ai_accounts_all(conn)
|
|
180
|
+
# Attach latest snapshot to each
|
|
181
|
+
for a in accounts:
|
|
182
|
+
snap = get_latest_claude_ai_snapshot(conn, a["account_id"])
|
|
183
|
+
a["latest_snapshot"] = snap
|
|
184
|
+
# Never expose session_key in API responses
|
|
185
|
+
a.pop("session_key", None)
|
|
186
|
+
conn.close()
|
|
187
|
+
self._serve_json(accounts)
|
|
188
|
+
|
|
189
|
+
elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/history$", path):
|
|
190
|
+
m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/history$", path)
|
|
191
|
+
account_id = m.group(1)
|
|
192
|
+
conn = get_conn()
|
|
193
|
+
history = get_claude_ai_snapshot_history(conn, account_id, 48)
|
|
194
|
+
conn.close()
|
|
195
|
+
self._serve_json(history)
|
|
196
|
+
|
|
197
|
+
elif path == "/tools/mac-sync.py":
|
|
198
|
+
self._serve_mac_sync()
|
|
199
|
+
|
|
200
|
+
# ── Fix tracker GET endpoints ──
|
|
201
|
+
elif path == "/api/fixes":
|
|
202
|
+
from fix_tracker import all_fixes_with_latest
|
|
203
|
+
conn = get_conn()
|
|
204
|
+
data = all_fixes_with_latest(conn)
|
|
205
|
+
conn.close()
|
|
206
|
+
self._serve_json(data)
|
|
207
|
+
|
|
208
|
+
elif re.match(r"^/api/fixes/(\d+)$", path):
|
|
209
|
+
m = re.match(r"^/api/fixes/(\d+)$", path)
|
|
210
|
+
fix_id = int(m.group(1))
|
|
211
|
+
from fix_tracker import fix_with_latest
|
|
212
|
+
conn = get_conn()
|
|
213
|
+
data = fix_with_latest(conn, fix_id)
|
|
214
|
+
conn.close()
|
|
215
|
+
if data is None:
|
|
216
|
+
self._serve_json({"error": "fix not found"}, 404)
|
|
217
|
+
else:
|
|
218
|
+
self._serve_json(data)
|
|
219
|
+
|
|
220
|
+
elif re.match(r"^/api/fixes/(\d+)/share-card$", path):
|
|
221
|
+
m = re.match(r"^/api/fixes/(\d+)/share-card$", path)
|
|
222
|
+
fix_id = int(m.group(1))
|
|
223
|
+
from db import get_fix, get_latest_fix_measurement
|
|
224
|
+
from fix_tracker import build_share_card
|
|
225
|
+
conn = get_conn()
|
|
226
|
+
fix = get_fix(conn, fix_id)
|
|
227
|
+
latest = get_latest_fix_measurement(conn, fix_id) if fix else None
|
|
228
|
+
conn.close()
|
|
229
|
+
if not fix:
|
|
230
|
+
self._serve_json({"error": "fix not found"}, 404)
|
|
231
|
+
else:
|
|
232
|
+
text = build_share_card(fix, latest)
|
|
233
|
+
body = text.encode("utf-8")
|
|
234
|
+
self.send_response(200)
|
|
235
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
236
|
+
self.send_header("Access-Control-Allow-Origin", self._cors_origin())
|
|
237
|
+
self.send_header("Content-Length", str(len(body)))
|
|
238
|
+
self.end_headers()
|
|
239
|
+
self.wfile.write(body)
|
|
240
|
+
|
|
241
|
+
elif path == "/health":
|
|
242
|
+
conn = get_conn()
|
|
243
|
+
total = get_session_count(conn)
|
|
244
|
+
conn.close()
|
|
245
|
+
last_scan = get_last_scan_time()
|
|
246
|
+
last_scan_iso = datetime.fromtimestamp(last_scan, tz=timezone.utc).isoformat() if last_scan else None
|
|
247
|
+
self._serve_json({
|
|
248
|
+
"status": "ok",
|
|
249
|
+
"version": "1.0.0",
|
|
250
|
+
"uptime_seconds": int(time.time() - _server_start_time),
|
|
251
|
+
"records": total,
|
|
252
|
+
"last_scan": last_scan_iso,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
else:
|
|
256
|
+
self._serve_404()
|
|
257
|
+
|
|
258
|
+
def do_POST(self):
|
|
259
|
+
parsed = urlparse(self.path)
|
|
260
|
+
path = parsed.path
|
|
261
|
+
|
|
262
|
+
# Body size guard — cap at 100 KB
|
|
263
|
+
if int(self.headers.get("Content-Length", 0) or 0) > 102400:
|
|
264
|
+
self._serve_json({"error": "request too large"}, 413)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# /api/claude-ai/sync keeps its existing X-Sync-Token check (for mac-sync.py).
|
|
268
|
+
# All other write endpoints require X-Dashboard-Key.
|
|
269
|
+
if path != "/api/claude-ai/sync" and not self._require_dashboard_key():
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
body = self._read_body()
|
|
273
|
+
|
|
274
|
+
if path == "/api/scan":
|
|
275
|
+
_data_cache.clear()
|
|
276
|
+
rows = scan_all()
|
|
277
|
+
conn = get_conn()
|
|
278
|
+
insights_count = generate_insights(conn)
|
|
279
|
+
conn.close()
|
|
280
|
+
self._serve_json({"status": "ok", "rows_added": rows, "insights_generated": insights_count})
|
|
281
|
+
|
|
282
|
+
elif re.match(r"^/api/insights/(\d+)/dismiss$", path):
|
|
283
|
+
match = re.match(r"^/api/insights/(\d+)/dismiss$", path)
|
|
284
|
+
insight_id = int(match.group(1))
|
|
285
|
+
conn = get_conn()
|
|
286
|
+
dismiss_insight(conn, insight_id)
|
|
287
|
+
conn.close()
|
|
288
|
+
self._serve_json({"status": "ok", "id": insight_id})
|
|
289
|
+
|
|
290
|
+
elif path == "/api/claude-ai/poll":
|
|
291
|
+
count = poll_claude_ai()
|
|
292
|
+
self._serve_json({"status": "ok", "accounts_polled": count})
|
|
293
|
+
|
|
294
|
+
# ── Account management POST endpoints ──
|
|
295
|
+
elif path == "/api/accounts":
|
|
296
|
+
data = body or {}
|
|
297
|
+
conn = get_conn()
|
|
298
|
+
ok, err = create_account(conn, data)
|
|
299
|
+
conn.close()
|
|
300
|
+
if ok:
|
|
301
|
+
self._serve_json({"success": True, "account_id": data.get("account_id")})
|
|
302
|
+
else:
|
|
303
|
+
self._serve_json({"success": False, "error": err}, 400)
|
|
304
|
+
|
|
305
|
+
elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path):
|
|
306
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path)
|
|
307
|
+
account_id = m.group(1)
|
|
308
|
+
data = body or {}
|
|
309
|
+
conn = get_conn()
|
|
310
|
+
ok, err = add_account_project(conn, account_id, data.get("project_name", ""), data.get("keywords", []))
|
|
311
|
+
conn.close()
|
|
312
|
+
if ok:
|
|
313
|
+
self._serve_json({"success": True})
|
|
314
|
+
else:
|
|
315
|
+
self._serve_json({"success": False, "error": err}, 400)
|
|
316
|
+
|
|
317
|
+
elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/scan$", path):
|
|
318
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/scan$", path)
|
|
319
|
+
account_id = m.group(1)
|
|
320
|
+
rows = scan_all(account_filter=account_id)
|
|
321
|
+
conn = get_conn()
|
|
322
|
+
insights_count = generate_insights(conn)
|
|
323
|
+
conn.close()
|
|
324
|
+
self._serve_json({"status": "ok", "rows_added": rows, "insights_generated": insights_count})
|
|
325
|
+
|
|
326
|
+
elif path == "/api/accounts/discover":
|
|
327
|
+
discovered = discover_claude_paths()
|
|
328
|
+
self._serve_json({"discovered_paths": discovered})
|
|
329
|
+
|
|
330
|
+
# ── claude.ai browser tracking POST endpoints ──
|
|
331
|
+
elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/setup$", path):
|
|
332
|
+
m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/setup$", path)
|
|
333
|
+
account_id = m.group(1)
|
|
334
|
+
data = body or {}
|
|
335
|
+
session_key = data.get("session_key", "").strip()
|
|
336
|
+
if not session_key:
|
|
337
|
+
self._serve_json({"success": False, "error": "session_key is required"}, 400)
|
|
338
|
+
else:
|
|
339
|
+
result = tracker_setup_account(account_id, session_key)
|
|
340
|
+
status = 200 if result.get("success") else 400
|
|
341
|
+
self._serve_json(result, status)
|
|
342
|
+
|
|
343
|
+
elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/refresh$", path):
|
|
344
|
+
m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/refresh$", path)
|
|
345
|
+
account_id = m.group(1)
|
|
346
|
+
conn = get_conn()
|
|
347
|
+
snap = tracker_poll_single(account_id, conn)
|
|
348
|
+
acct = get_claude_ai_account(conn, account_id)
|
|
349
|
+
conn.close()
|
|
350
|
+
# Never expose session_key
|
|
351
|
+
if acct:
|
|
352
|
+
acct.pop("session_key", None)
|
|
353
|
+
self._serve_json({
|
|
354
|
+
"success": snap is not None and "error" not in (snap or {}),
|
|
355
|
+
"account": acct,
|
|
356
|
+
"snapshot": snap if snap and "error" not in snap else None,
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
elif path == "/api/claude-ai/sync":
|
|
360
|
+
self._handle_sync(body or {})
|
|
361
|
+
|
|
362
|
+
# ── Fix tracker POST endpoints ──
|
|
363
|
+
elif path == "/api/fixes":
|
|
364
|
+
data = body or {}
|
|
365
|
+
project = (data.get("project") or "").strip()
|
|
366
|
+
if not project:
|
|
367
|
+
self._serve_json({"success": False, "error": "project is required"}, 400)
|
|
368
|
+
return
|
|
369
|
+
from fix_tracker import record_fix
|
|
370
|
+
conn = get_conn()
|
|
371
|
+
try:
|
|
372
|
+
fix_id, baseline = record_fix(
|
|
373
|
+
conn,
|
|
374
|
+
project,
|
|
375
|
+
data.get("waste_pattern") or "custom",
|
|
376
|
+
(data.get("title") or "").strip(),
|
|
377
|
+
data.get("fix_type") or "other",
|
|
378
|
+
data.get("fix_detail") or "",
|
|
379
|
+
)
|
|
380
|
+
finally:
|
|
381
|
+
conn.close()
|
|
382
|
+
self._serve_json({
|
|
383
|
+
"success": True,
|
|
384
|
+
"fix_id": fix_id,
|
|
385
|
+
"baseline": baseline,
|
|
386
|
+
"message": "Fix recorded. Check back in 7 days to measure improvement.",
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
elif re.match(r"^/api/fixes/(\d+)/measure$", path):
|
|
390
|
+
m = re.match(r"^/api/fixes/(\d+)/measure$", path)
|
|
391
|
+
fix_id = int(m.group(1))
|
|
392
|
+
from fix_tracker import measure_fix
|
|
393
|
+
conn = get_conn()
|
|
394
|
+
try:
|
|
395
|
+
delta, verdict, metrics = measure_fix(conn, fix_id)
|
|
396
|
+
finally:
|
|
397
|
+
conn.close()
|
|
398
|
+
if delta is None:
|
|
399
|
+
self._serve_json({"success": False, "error": "fix not found"}, 404)
|
|
400
|
+
else:
|
|
401
|
+
msg = {
|
|
402
|
+
"improving": "Fix is working. Keep it in place.",
|
|
403
|
+
"worsened": "Fix regressed. Consider reverting or iterating.",
|
|
404
|
+
"neutral": "No statistically meaningful change yet.",
|
|
405
|
+
"insufficient_data": "Not enough sessions since fix — give it more time.",
|
|
406
|
+
}.get(verdict, "Measurement recorded.")
|
|
407
|
+
self._serve_json({
|
|
408
|
+
"success": True,
|
|
409
|
+
"delta": delta,
|
|
410
|
+
"verdict": verdict,
|
|
411
|
+
"message": msg,
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
else:
|
|
415
|
+
self.send_error(404)
|
|
416
|
+
|
|
417
|
+
def do_PUT(self):
|
|
418
|
+
parsed = urlparse(self.path)
|
|
419
|
+
path = parsed.path
|
|
420
|
+
|
|
421
|
+
if int(self.headers.get("Content-Length", 0) or 0) > 102400:
|
|
422
|
+
self._serve_json({"error": "request too large"}, 413)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
if not self._require_dashboard_key():
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
body = self._read_body()
|
|
429
|
+
|
|
430
|
+
if re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path):
|
|
431
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path)
|
|
432
|
+
account_id = m.group(1)
|
|
433
|
+
data = body or {}
|
|
434
|
+
conn = get_conn()
|
|
435
|
+
ok, err = update_account(conn, account_id, data)
|
|
436
|
+
conn.close()
|
|
437
|
+
if ok:
|
|
438
|
+
# Re-scan if data_paths changed
|
|
439
|
+
if "data_paths" in data:
|
|
440
|
+
scan_all(account_filter=account_id)
|
|
441
|
+
self._serve_json({"success": True})
|
|
442
|
+
else:
|
|
443
|
+
self._serve_json({"success": False, "error": err}, 400)
|
|
444
|
+
else:
|
|
445
|
+
self.send_error(404)
|
|
446
|
+
|
|
447
|
+
def do_DELETE(self):
|
|
448
|
+
parsed = urlparse(self.path)
|
|
449
|
+
path = parsed.path
|
|
450
|
+
|
|
451
|
+
if not self._require_dashboard_key():
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
if re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path):
|
|
455
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path)
|
|
456
|
+
account_id = m.group(1)
|
|
457
|
+
conn = get_conn()
|
|
458
|
+
ok, err = delete_account(conn, account_id)
|
|
459
|
+
conn.close()
|
|
460
|
+
if ok:
|
|
461
|
+
self._serve_json({"success": True})
|
|
462
|
+
else:
|
|
463
|
+
self._serve_json({"success": False, "error": err}, 400)
|
|
464
|
+
|
|
465
|
+
elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects/(.+)$", path):
|
|
466
|
+
m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects/(.+)$", path)
|
|
467
|
+
account_id = m.group(1)
|
|
468
|
+
project_name = m.group(2)
|
|
469
|
+
conn = get_conn()
|
|
470
|
+
ok, err = remove_account_project(conn, account_id, project_name)
|
|
471
|
+
conn.close()
|
|
472
|
+
if ok:
|
|
473
|
+
self._serve_json({"success": True})
|
|
474
|
+
else:
|
|
475
|
+
self._serve_json({"success": False, "error": err}, 400)
|
|
476
|
+
|
|
477
|
+
elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/session$", path):
|
|
478
|
+
m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/session$", path)
|
|
479
|
+
account_id = m.group(1)
|
|
480
|
+
conn = get_conn()
|
|
481
|
+
clear_claude_ai_session(conn, account_id)
|
|
482
|
+
conn.close()
|
|
483
|
+
self._serve_json({"success": True})
|
|
484
|
+
|
|
485
|
+
elif re.match(r"^/api/fixes/(\d+)$", path):
|
|
486
|
+
m = re.match(r"^/api/fixes/(\d+)$", path)
|
|
487
|
+
fix_id = int(m.group(1))
|
|
488
|
+
from db import update_fix_status, get_fix
|
|
489
|
+
conn = get_conn()
|
|
490
|
+
try:
|
|
491
|
+
fix = get_fix(conn, fix_id)
|
|
492
|
+
if not fix:
|
|
493
|
+
self._serve_json({"success": False, "error": "fix not found"}, 404)
|
|
494
|
+
return
|
|
495
|
+
update_fix_status(conn, fix_id, "reverted")
|
|
496
|
+
finally:
|
|
497
|
+
conn.close()
|
|
498
|
+
self._serve_json({"success": True})
|
|
499
|
+
|
|
500
|
+
else:
|
|
501
|
+
self.send_error(404)
|
|
502
|
+
|
|
503
|
+
def do_OPTIONS(self):
|
|
504
|
+
self.send_response(200)
|
|
505
|
+
self.send_header("Access-Control-Allow-Origin", self._cors_origin())
|
|
506
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
507
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Dashboard-Key, X-Sync-Token")
|
|
508
|
+
self.end_headers()
|
|
509
|
+
|
|
510
|
+
def _read_body(self):
|
|
511
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
512
|
+
if length > 0:
|
|
513
|
+
raw = self.rfile.read(length)
|
|
514
|
+
try:
|
|
515
|
+
return json.loads(raw)
|
|
516
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
517
|
+
return {}
|
|
518
|
+
return {}
|
|
519
|
+
|
|
520
|
+
def _require_dashboard_key(self):
|
|
521
|
+
"""Enforce X-Dashboard-Key header on write endpoints. Returns True on pass;
|
|
522
|
+
on failure writes 401 and returns False."""
|
|
523
|
+
received = self.headers.get("X-Dashboard-Key", "").strip()
|
|
524
|
+
conn = get_conn()
|
|
525
|
+
try:
|
|
526
|
+
stored = get_setting(conn, "dashboard_key")
|
|
527
|
+
finally:
|
|
528
|
+
conn.close()
|
|
529
|
+
if not stored or received != stored.strip():
|
|
530
|
+
self._serve_json({"error": "unauthorized"}, 401)
|
|
531
|
+
return False
|
|
532
|
+
return True
|
|
533
|
+
|
|
534
|
+
def _serve_template(self, filename):
|
|
535
|
+
filename = os.path.basename(filename)
|
|
536
|
+
filepath = os.path.join(TEMPLATE_DIR, filename)
|
|
537
|
+
try:
|
|
538
|
+
with open(filepath, "r") as f:
|
|
539
|
+
content = f.read()
|
|
540
|
+
self.send_response(200)
|
|
541
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
542
|
+
self.end_headers()
|
|
543
|
+
self.wfile.write(content.encode("utf-8"))
|
|
544
|
+
except FileNotFoundError:
|
|
545
|
+
self.send_error(500, f"{filename} not found")
|
|
546
|
+
|
|
547
|
+
def _cors_origin(self):
|
|
548
|
+
origin = self.headers.get("Origin", "")
|
|
549
|
+
allowed = {"http://127.0.0.1:8080", "http://localhost:8080"}
|
|
550
|
+
return origin if origin in allowed else "http://127.0.0.1:8080"
|
|
551
|
+
|
|
552
|
+
def _serve_json(self, data, status=200):
|
|
553
|
+
body = json.dumps(data, default=str).encode("utf-8")
|
|
554
|
+
self.send_response(status)
|
|
555
|
+
self.send_header("Content-Type", "application/json")
|
|
556
|
+
self.send_header("Access-Control-Allow-Origin", self._cors_origin())
|
|
557
|
+
self.end_headers()
|
|
558
|
+
self.wfile.write(body)
|
|
559
|
+
|
|
560
|
+
def _serve_404(self):
|
|
561
|
+
html = (
|
|
562
|
+
'<!DOCTYPE html>\n<html>\n<head>\n'
|
|
563
|
+
' <title>Claudash</title>\n'
|
|
564
|
+
' <meta http-equiv="refresh" content="5;url=/">\n'
|
|
565
|
+
' <style>\n'
|
|
566
|
+
' body { font-family: monospace; padding: 40px;\n'
|
|
567
|
+
' background: #F5F0E8; color: #1A1916; }\n'
|
|
568
|
+
' code { background: #E8E0D0; padding: 2px 6px; }\n'
|
|
569
|
+
' </style>\n'
|
|
570
|
+
'</head>\n<body>\n'
|
|
571
|
+
' <h2>Claudash</h2>\n'
|
|
572
|
+
' <p>Page not found. Redirecting to dashboard in 5 seconds...</p>\n'
|
|
573
|
+
' <p>If this keeps happening: <a href="/">click here</a></p>\n'
|
|
574
|
+
'</body>\n</html>'
|
|
575
|
+
)
|
|
576
|
+
body = html.encode("utf-8")
|
|
577
|
+
self.send_response(404)
|
|
578
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
579
|
+
self.send_header("Content-Length", str(len(body)))
|
|
580
|
+
self.end_headers()
|
|
581
|
+
self.wfile.write(body)
|
|
582
|
+
|
|
583
|
+
def _serve_mac_sync(self):
|
|
584
|
+
"""Serve tools/mac-sync.py as-is. The sync token is NOT injected — the
|
|
585
|
+
user must retrieve it via `python3 cli.py claude-ai --sync-token` on the
|
|
586
|
+
VPS and paste it into SYNC_TOKEN manually. This removes the token-leak
|
|
587
|
+
vector where any caller could download a pre-filled script."""
|
|
588
|
+
filepath = os.path.join(PROJECT_DIR, "tools", "mac-sync.py")
|
|
589
|
+
try:
|
|
590
|
+
with open(filepath, "rb") as f:
|
|
591
|
+
body = f.read()
|
|
592
|
+
self.send_response(200)
|
|
593
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
594
|
+
self.send_header("Content-Disposition", 'attachment; filename="mac-sync.py"')
|
|
595
|
+
self.send_header("Content-Length", str(len(body)))
|
|
596
|
+
self.end_headers()
|
|
597
|
+
self.wfile.write(body)
|
|
598
|
+
except FileNotFoundError:
|
|
599
|
+
self.send_error(500, "tools/mac-sync.py not found")
|
|
600
|
+
|
|
601
|
+
def _handle_sync(self, data):
|
|
602
|
+
"""Handle POST /api/claude-ai/sync from mac-sync.py.
|
|
603
|
+
Trust boundary is the sync token — if it matches, we trust the pushed data."""
|
|
604
|
+
received_token = self.headers.get("X-Sync-Token", "").strip()
|
|
605
|
+
conn = get_conn()
|
|
606
|
+
try:
|
|
607
|
+
stored_token = get_setting(conn, "sync_token")
|
|
608
|
+
if not stored_token or received_token != stored_token.strip():
|
|
609
|
+
self._serve_json({"success": False, "error": "Invalid sync token"}, 403)
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
session_key = data.get("session_key", "").strip()
|
|
613
|
+
org_id = data.get("org_id", "").strip()
|
|
614
|
+
browser = data.get("browser", "")
|
|
615
|
+
account_hint = data.get("account_hint", "")
|
|
616
|
+
|
|
617
|
+
if not session_key:
|
|
618
|
+
self._serve_json({"success": False, "error": "session_key required"}, 400)
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
accounts = get_claude_ai_accounts_all(conn)
|
|
622
|
+
target_id = None
|
|
623
|
+
target_label = ""
|
|
624
|
+
|
|
625
|
+
for a in accounts:
|
|
626
|
+
if a.get("org_id") == org_id and org_id:
|
|
627
|
+
target_id = a["account_id"]
|
|
628
|
+
target_label = a.get("label", target_id)
|
|
629
|
+
break
|
|
630
|
+
|
|
631
|
+
if not target_id and account_hint:
|
|
632
|
+
hint_lower = account_hint.lower()
|
|
633
|
+
for a in accounts:
|
|
634
|
+
label_lower = (a.get("label") or "").lower()
|
|
635
|
+
if any(word in hint_lower for word in label_lower.split() if len(word) > 2):
|
|
636
|
+
target_id = a["account_id"]
|
|
637
|
+
target_label = a.get("label", target_id)
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
if not target_id:
|
|
641
|
+
for a in accounts:
|
|
642
|
+
if a.get("status") == "unconfigured":
|
|
643
|
+
target_id = a["account_id"]
|
|
644
|
+
target_label = a.get("label", target_id)
|
|
645
|
+
break
|
|
646
|
+
|
|
647
|
+
if not target_id and accounts:
|
|
648
|
+
target_id = accounts[0]["account_id"]
|
|
649
|
+
target_label = accounts[0].get("label", target_id)
|
|
650
|
+
print(f"WARNING: no org_id match for {org_id}, falling back to {target_id}", file=sys.stderr)
|
|
651
|
+
print("Check your config.py ACCOUNTS org_id settings", file=sys.stderr)
|
|
652
|
+
|
|
653
|
+
if not target_id:
|
|
654
|
+
self._serve_json({"success": False, "error": "No accounts configured"}, 400)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
acct_row = conn.execute(
|
|
658
|
+
"SELECT plan FROM accounts WHERE account_id = ?", (target_id,)
|
|
659
|
+
).fetchone()
|
|
660
|
+
plan = acct_row["plan"] if acct_row else "max"
|
|
661
|
+
|
|
662
|
+
upsert_claude_ai_account(conn, target_id, target_label, org_id, session_key, plan, "active")
|
|
663
|
+
conn.execute(
|
|
664
|
+
"UPDATE claude_ai_accounts SET mac_sync_mode = 1 WHERE account_id = ?",
|
|
665
|
+
(target_id,),
|
|
666
|
+
)
|
|
667
|
+
update_claude_ai_account_status(conn, target_id, "active", None)
|
|
668
|
+
print(f"[sync] Stored session for {target_id} from {browser} ({account_hint})", file=sys.stderr)
|
|
669
|
+
|
|
670
|
+
pct_used = 0
|
|
671
|
+
usage = data.get("usage")
|
|
672
|
+
if usage and isinstance(usage, dict):
|
|
673
|
+
insert_claude_ai_snapshot(conn, target_id, usage)
|
|
674
|
+
pct_used = usage.get("pct_used", 0)
|
|
675
|
+
print(f"[sync] Stored usage snapshot for {target_id}: {pct_used}% used", file=sys.stderr)
|
|
676
|
+
|
|
677
|
+
self._serve_json({
|
|
678
|
+
"success": True,
|
|
679
|
+
"account_label": target_label,
|
|
680
|
+
"matched_account": target_id,
|
|
681
|
+
"pct_used": pct_used,
|
|
682
|
+
"browser": browser,
|
|
683
|
+
})
|
|
684
|
+
finally:
|
|
685
|
+
conn.close()
|
|
686
|
+
|
|
687
|
+
def _get_data(self, account):
|
|
688
|
+
# Check cache first
|
|
689
|
+
cached = _data_cache.get(account)
|
|
690
|
+
if cached and (time.time() - cached[0]) < CACHE_TTL:
|
|
691
|
+
return cached[1]
|
|
692
|
+
|
|
693
|
+
# Run analysis with 10-second timeout
|
|
694
|
+
result_holder = [None]
|
|
695
|
+
error_holder = [None]
|
|
696
|
+
|
|
697
|
+
def run_analysis():
|
|
698
|
+
try:
|
|
699
|
+
conn = get_conn()
|
|
700
|
+
result_holder[0] = self._build_data(conn, account)
|
|
701
|
+
conn.close()
|
|
702
|
+
except Exception as e:
|
|
703
|
+
error_holder[0] = str(e)
|
|
704
|
+
|
|
705
|
+
t = threading.Thread(target=run_analysis)
|
|
706
|
+
t.start()
|
|
707
|
+
t.join(timeout=10)
|
|
708
|
+
if t.is_alive():
|
|
709
|
+
return {"error": "Analysis timeout — DB may be under load"}
|
|
710
|
+
if error_holder[0]:
|
|
711
|
+
return {"error": error_holder[0]}
|
|
712
|
+
|
|
713
|
+
data = result_holder[0]
|
|
714
|
+
_data_cache[account] = (time.time(), data)
|
|
715
|
+
return data
|
|
716
|
+
|
|
717
|
+
def _build_data(self, conn, account):
|
|
718
|
+
data = full_analysis(conn, account)
|
|
719
|
+
data["last_scan"] = get_last_scan_time()
|
|
720
|
+
data["total_rows"] = get_session_count(conn)
|
|
721
|
+
if data["total_rows"] == 0:
|
|
722
|
+
data["first_run"] = True
|
|
723
|
+
data["first_run_message"] = (
|
|
724
|
+
"No sessions found. "
|
|
725
|
+
"Run: python3 cli.py scan\n"
|
|
726
|
+
"Then check that ~/.claude/projects/ contains JSONL files."
|
|
727
|
+
)
|
|
728
|
+
data["db_size_mb"] = get_db_size_mb()
|
|
729
|
+
# claude.ai browser tracking data
|
|
730
|
+
browser_accounts = get_claude_ai_accounts_all(conn)
|
|
731
|
+
browser_data = {}
|
|
732
|
+
for ba in browser_accounts:
|
|
733
|
+
aid = ba["account_id"]
|
|
734
|
+
snap = get_latest_claude_ai_snapshot(conn, aid)
|
|
735
|
+
browser_data[aid] = {
|
|
736
|
+
"status": ba.get("status", "unconfigured"),
|
|
737
|
+
"label": ba.get("label", aid),
|
|
738
|
+
"plan": ba.get("plan", "max"),
|
|
739
|
+
"last_polled": ba.get("last_polled"),
|
|
740
|
+
"snapshot": snap,
|
|
741
|
+
}
|
|
742
|
+
data["claude_ai_browser"] = browser_data
|
|
743
|
+
data["claude_ai"] = {
|
|
744
|
+
"accounts": [dict(r) for r in get_latest_claude_ai_usage(conn)],
|
|
745
|
+
"statuses": get_account_statuses(),
|
|
746
|
+
"last_poll": get_last_poll_time(),
|
|
747
|
+
}
|
|
748
|
+
conn.close()
|
|
749
|
+
return data
|
|
750
|
+
|
|
751
|
+
def log_message(self, format, *args):
|
|
752
|
+
print(f"[server] {args[0]}", file=sys.stderr)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def start_server(port=8080):
|
|
756
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), DashboardHandler)
|
|
757
|
+
print(f"Dashboard: http://127.0.0.1:{port} (localhost only)", flush=True)
|
|
758
|
+
try:
|
|
759
|
+
server.serve_forever()
|
|
760
|
+
except KeyboardInterrupt:
|
|
761
|
+
print("\n[server] Shutting down", file=sys.stderr)
|
|
762
|
+
server.server_close()
|