diff --git a/.gitignore b/.gitignore index ffc0b3f..df38c67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ cli-proxy-api config.yaml usage-dashboard/config.json +usage.sqlite \ No newline at end of file diff --git a/usage-dashboard/config.example.json b/usage-dashboard/config.example.json new file mode 100644 index 0000000..21682e6 --- /dev/null +++ b/usage-dashboard/config.example.json @@ -0,0 +1,9 @@ +{ + "cliproxy_host": "127.0.0.1", + "cliproxy_port": 8317, + "management_key": "replace-with-your-management-key", + "poll_interval_seconds": 2, + "quota_refresh_seconds": 300, + "dashboard_host": "0.0.0.0", + "dashboard_port": 8320 +} diff --git a/usage-dashboard/usage-dashboard.html b/usage-dashboard/usage-dashboard.html new file mode 100644 index 0000000..97089e3 --- /dev/null +++ b/usage-dashboard/usage-dashboard.html @@ -0,0 +1,644 @@ + + + + + + CLIProxyAPI Usage Dashboard + + + + +
+
+ 📊 +

CLIProxyAPI Usage Dashboard

+
+
+ + + + + + + + + + +
+
+ +
+ +
+
+
Requests / Tasks
+
0
+
Failed: 0
+
+
+
Total Tokens
+
0
+
Input + Output + Reasoning
+
+
+
Input Tokens
+
0
+
Cache hits counted separately
+
+
+
Output Tokens
+
0
+
Model response
+
+
+
Reasoning Tokens
+
0
+
reasoning
+
+
+ + +
+
+

Hourly Usage

+ +
+
+

Usage by Model

+ +
+
+ + +
+
+

Usage by Account

+
+ + + + + + + + + + + +
AccountRequestsTotal TokensInputOutputReasoningFailed
+
+
+
+

Account Quota

+
+ + + + + + + + + +
AccountStatus5h Remaining7d RemainingReset Time
+
+
+
+ + +
+

Recent Requests / Tasks

+
+ + + + + + + + + + + + + +
TimeAccountModelTotal TokensInputOutputReasoningLatencyStatus
+
+
+
+ + + + + + diff --git a/usage-dashboard/usage-dashboard.py b/usage-dashboard/usage-dashboard.py new file mode 100644 index 0000000..beef44d --- /dev/null +++ b/usage-dashboard/usage-dashboard.py @@ -0,0 +1,695 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import glob +import hashlib +import json +import os +import socket +import sqlite3 +import sys +import time +import threading +import urllib.error +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse +from zoneinfo import ZoneInfo + +_quota_refresh_lock = threading.Lock() +# Path to the companion HTML file (same directory as this script) +HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "usage-dashboard.html") +JSON_DIR = os.path.expanduser("~/.cli-proxy-api") +BASE_DIR = os.path.expanduser("~/cliproxyapi/usage-dashboard") +AUTH_DIR = os.path.expanduser("~/cliproxyapi") +DB_PATH = os.path.join(BASE_DIR, "usage.sqlite") +CONFIG_PATH = os.path.join(BASE_DIR, "config.json") +LOCAL_TZ = ZoneInfo("Asia/Ho_Chi_Minh") + + +DEFAULT_CONFIG = { + "cliproxy_host": "127.0.0.1", + "cliproxy_port": 8317, + "management_key": "123456", + "poll_interval_seconds": 2, + "quota_refresh_seconds": 300, + "dashboard_host": "0.0.0.0", + "dashboard_port": 8320, +} + + +def ensure_dirs(): + os.makedirs(BASE_DIR, exist_ok=True) + + +def load_config(): + ensure_dirs() + if not os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH, "w") as f: + json.dump(DEFAULT_CONFIG, f, indent=2) + os.chmod(CONFIG_PATH, 0o600) + with open(CONFIG_PATH) as f: + cfg = json.load(f) + merged = dict(DEFAULT_CONFIG) + merged.update(cfg) + merged["management_key"] = os.environ.get("CLIPROXY_MANAGEMENT_KEY", merged["management_key"]) + return merged + + +def db_connect(): + ensure_dirs() + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + return conn + + +def init_db(): + with db_connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + timestamp TEXT NOT NULL, + ts_epoch REAL NOT NULL, + local_date TEXT NOT NULL, + local_hour TEXT NOT NULL, + request_id TEXT, + auth_index TEXT, + source TEXT, + provider TEXT, + model TEXT, + endpoint TEXT, + auth_type TEXT, + api_key_hash TEXT, + failed INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + cached_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + raw_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts_epoch); + CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_events(local_date); + CREATE INDEX IF NOT EXISTS idx_usage_source ON usage_events(source); + CREATE INDEX IF NOT EXISTS idx_usage_auth ON usage_events(auth_index); + + CREATE TABLE IF NOT EXISTS quota_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + ts_epoch REAL NOT NULL, + email TEXT NOT NULL, + plan TEXT, + allowed INTEGER, + limit_reached INTEGER, + primary_used_percent INTEGER, + primary_remaining_percent INTEGER, + primary_reset_at TEXT, + secondary_used_percent INTEGER, + secondary_remaining_percent INTEGER, + secondary_reset_at TEXT, + credits_balance TEXT, + raw_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_quota_email_ts ON quota_snapshots(email, ts_epoch); + """ + ) + + +def parse_rfc3339(value): + if not value: + return dt.datetime.now(dt.timezone.utc) + text = value.replace("Z", "+00:00") + try: + parsed = dt.datetime.fromisoformat(text) + except ValueError: + return dt.datetime.now(dt.timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def resp_command(*parts): + data = [f"*{len(parts)}\r\n".encode()] + for part in parts: + b = str(part).encode() + data.append(f"${len(b)}\r\n".encode()) + data.append(b + b"\r\n") + return b"".join(data) + + +class RespClient: + def __init__(self, host, port, password, timeout=10): + if not password: + raise RuntimeError("management_key is required in config.json or CLIPROXY_MANAGEMENT_KEY") + self.sock = socket.create_connection((host, port), timeout=timeout) + self.file = self.sock.makefile("rb") + self.send("AUTH", password) + reply = self.read() + if not (isinstance(reply, str) and reply.upper() == "OK"): + raise RuntimeError(f"AUTH failed: {reply!r}") + + def close(self): + try: + self.file.close() + finally: + self.sock.close() + + def send(self, *parts): + self.sock.sendall(resp_command(*parts)) + + def read_line(self): + line = self.file.readline() + if not line: + raise EOFError("RESP connection closed") + return line.rstrip(b"\r\n") + + def read(self): + line = self.read_line() + prefix = line[:1] + payload = line[1:] + if prefix == b"+": + return payload.decode() + if prefix == b"-": + raise RuntimeError(payload.decode()) + if prefix == b":": + return int(payload) + if prefix == b"$": + length = int(payload) + if length == -1: + return None + data = self.file.read(length) + self.file.read(2) + return data.decode("utf-8", "replace") + if prefix == b"*": + count = int(payload) + if count == -1: + return None + return [self.read() for _ in range(count)] + raise RuntimeError(f"Unknown RESP prefix: {line!r}") + + def rpop(self, count=100): + result = [] + for _ in range(count): + self.send("RPOP", "queue") + item = self.read() + if item is None: + break + result.append(item) + return result + + +def event_key(payload, raw): + rid = payload.get("request_id") + if rid: + return rid + return hashlib.sha256(raw.encode()).hexdigest() + + +def insert_usage(raw_items): + inserted = 0 + with db_connect() as conn: + for raw in raw_items: + try: + payload = json.loads(raw) + except json.JSONDecodeError as e: + print(f"insert_usage: JSON decode error: {e} — raw: {raw[:120]!r}", file=sys.stderr, flush=True) + continue + ts_utc = parse_rfc3339(payload.get("timestamp")) + ts_local = ts_utc.astimezone(LOCAL_TZ) + tokens = payload.get("tokens") or {} + api_key = payload.get("api_key") or "" + api_hash = hashlib.sha256(api_key.encode()).hexdigest()[:12] if api_key else "" + values = { + "event_key": event_key(payload, raw), + "timestamp": ts_utc.isoformat(), + "ts_epoch": ts_utc.timestamp(), + "local_date": ts_local.strftime("%Y-%m-%d"), + "local_hour": ts_local.strftime("%Y-%m-%d %H:00"), + "request_id": payload.get("request_id"), + "auth_index": payload.get("auth_index"), + "source": payload.get("source"), + "provider": payload.get("provider"), + "model": payload.get("model"), + "endpoint": payload.get("endpoint"), + "auth_type": payload.get("auth_type"), + "api_key_hash": api_hash, + "failed": 1 if payload.get("failed") else 0, + "latency_ms": int(payload.get("latency_ms") or 0), + "input_tokens": int(tokens.get("input_tokens") or 0), + "output_tokens": int(tokens.get("output_tokens") or 0), + "reasoning_tokens": int(tokens.get("reasoning_tokens") or 0), + "cached_tokens": int(tokens.get("cached_tokens") or 0), + "total_tokens": int(tokens.get("total_tokens") or 0), + "raw_json": raw, + } + try: + conn.execute( + """ + INSERT INTO usage_events ( + event_key,timestamp,ts_epoch,local_date,local_hour,request_id,auth_index,source, + provider,model,endpoint,auth_type,api_key_hash,failed,latency_ms,input_tokens, + output_tokens,reasoning_tokens,cached_tokens,total_tokens,raw_json + ) VALUES ( + :event_key,:timestamp,:ts_epoch,:local_date,:local_hour,:request_id,:auth_index,:source, + :provider,:model,:endpoint,:auth_type,:api_key_hash,:failed,:latency_ms,:input_tokens, + :output_tokens,:reasoning_tokens,:cached_tokens,:total_tokens,:raw_json + ) + """, + values, + ) + inserted += 1 + except sqlite3.IntegrityError: + pass # duplicate event_key, expected + except Exception as e: + print(f"insert_usage: unexpected error: {e} — payload keys: {list(payload.keys())}", file=sys.stderr, flush=True) + return inserted + + +def latest_quota_age(): + with db_connect() as conn: + row = conn.execute("SELECT MAX(ts_epoch) AS ts FROM quota_snapshots").fetchone() + return None if row["ts"] is None else time.time() - row["ts"] + + +def auth_files(): + return sorted(glob.glob(os.path.join(JSON_DIR, "codex-*.json"))) + +def refresh_quota(force=False): + # Dùng lock để tránh nhiều request đồng thời + acquired = _quota_refresh_lock.acquire(blocking=False) + if not acquired: + print("refresh_quota: already running, skipping duplicate call", flush=True) + return 0 + + try: + cfg = load_config() + age = latest_quota_age() + if not force and age is not None and age < cfg["quota_refresh_seconds"]: + return 0 + + files = auth_files() + # ✅ LOG 1: Số lượng auth files tìm thấy + print(f"refresh_quota: found {len(files)} auth files in {JSON_DIR}", flush=True) + + now = dt.datetime.now(dt.timezone.utc) + inserted = 0 + with db_connect() as conn: + for path in files: + try: + auth = json.load(open(path)) + token = auth.get("access_token") + email = auth.get("email") or os.path.basename(path) + if not token: + # ✅ LOG 2: Skip no token + print(f"refresh_quota: skipping {email} (no access_token)", flush=True) + continue + # ✅ LOG 3: Đang fetch quota cho email nào + print(f"refresh_quota: fetching quota for {email}...", flush=True) + req = urllib.request.Request( + "https://chatgpt.com/backend-api/wham/usage", + headers={ + "Authorization": "Bearer " + token, + "Accept": "application/json", + "User-Agent": "codex-cli", + }, + ) + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.load(resp) + rl = data.get("rate_limit") or {} + primary = rl.get("primary_window") or {} + secondary = rl.get("secondary_window") or {} + primary_used = int(primary.get("used_percent") or 0) + secondary_used = int(secondary.get("used_percent") or 0) + conn.execute( + """ + INSERT INTO quota_snapshots ( + timestamp,ts_epoch,email,plan,allowed,limit_reached, + primary_used_percent,primary_remaining_percent,primary_reset_at, + secondary_used_percent,secondary_remaining_percent,secondary_reset_at, + credits_balance,raw_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + now.isoformat(), + now.timestamp(), + email, + data.get("plan_type"), + 1 if rl.get("allowed") else 0, + 1 if rl.get("limit_reached") else 0, + primary_used, + max(0, 100 - primary_used), + epoch_to_local(primary.get("reset_at")), + secondary_used, + max(0, 100 - secondary_used), + epoch_to_local(secondary.get("reset_at")), + str((data.get("credits") or {}).get("balance", "")), + json.dumps(data, ensure_ascii=False), + ), + ) + inserted += 1 + # ✅ LOG 4: Lưu thành công, hiển thị plan type + print(f"refresh_quota: saved quota for {email} (plan: {data.get('plan_type')})", flush=True) + except (OSError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, KeyError) as exc: + # ✅ LOG 5: Lỗi khi fetch (đã có sẵn, thêm flush=True) + print(f"quota refresh failed for {path}: {exc}", file=sys.stderr, flush=True) + # ✅ LOG 6: Tổng kết số quota snapshots đã insert + print(f"refresh_quota: inserted {inserted} quota snapshots", flush=True) + return inserted + finally: + _quota_refresh_lock.release() + +def epoch_to_local(value): + if not value: + return "" + return dt.datetime.fromtimestamp(int(value), LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def debug_collect(): + """Test one poll cycle and print raw queue items — does NOT write to DB.""" + cfg = load_config() + print(f"[debug] Config: {cfg}", flush=True) + print(f"[debug] Connecting to {cfg['cliproxy_host']}:{cfg['cliproxy_port']} ...", flush=True) + try: + client = RespClient(cfg["cliproxy_host"], cfg["cliproxy_port"], cfg["management_key"]) + except Exception as e: + print(f"[debug] Connection FAILED: {e}", file=sys.stderr, flush=True) + return + print("[debug] AUTH OK", flush=True) + try: + raw_items = client.rpop(10) + print(f"[debug] rpop returned {len(raw_items)} item(s)", flush=True) + for i, raw in enumerate(raw_items): + print(f"[debug] item[{i}]: {raw[:300]}", flush=True) + try: + payload = json.loads(raw) + print(f"[debug] parsed OK — keys: {list(payload.keys())}", flush=True) + tokens = payload.get("tokens") or {} + print(f"[debug] tokens: {tokens}", flush=True) + print(f"[debug] model: {payload.get('model')}, source: {payload.get('source')}", flush=True) + except Exception as e: + print(f"[debug] parse error: {e}", file=sys.stderr, flush=True) + finally: + client.close() + if not raw_items: + print("[debug] Queue is EMPTY — no events to collect. Make sure CLIProxy is running and receiving requests.", flush=True) + + +def collect_forever(): + init_db() + cfg = load_config() + last_quota = 0 + print(f"collector: connecting to {cfg['cliproxy_host']}:{cfg['cliproxy_port']}", flush=True) + while True: + try: + client = RespClient(cfg["cliproxy_host"], cfg["cliproxy_port"], cfg["management_key"]) + print("collector: connected and authenticated OK", flush=True) + try: + while True: + raw_items = client.rpop(100) + if raw_items: + print(f"collector: got {len(raw_items)} raw item(s) from queue", flush=True) + inserted = insert_usage(raw_items) + print(f"collector: inserted {inserted}/{len(raw_items)} events into DB", flush=True) + now = time.time() + # if now - last_quota >= cfg["quota_refresh_seconds"]: + # refresh_quota(force=True) + # last_quota = now + time.sleep(cfg["poll_interval_seconds"]) + finally: + client.close() + print("collector: connection closed", flush=True) + except Exception as exc: + import traceback + print(f"collector error: {exc}", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) + print("collector: retrying in 5s...", file=sys.stderr, flush=True) + time.sleep(5) + + +def range_bounds(name): + now = dt.datetime.now(LOCAL_TZ) + if name == "5h": + start = now - dt.timedelta(hours=5) + elif name == "1h": + start = now - dt.timedelta(hours=1) + elif name == "24h": + start = now - dt.timedelta(hours=24) + elif name == "7d": + start = now - dt.timedelta(days=7) + else: + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return start.astimezone(dt.timezone.utc).timestamp(), now.astimezone(dt.timezone.utc).timestamp() + + +def query_summary(range_name): + start, end = range_bounds(range_name) + with db_connect() as conn: + total = conn.execute( + """ + SELECT COUNT(*) requests, + COALESCE(SUM(total_tokens),0) total_tokens, + COALESCE(SUM(input_tokens),0) input_tokens, + COALESCE(SUM(output_tokens),0) output_tokens, + COALESCE(SUM(reasoning_tokens),0) reasoning_tokens, + COALESCE(SUM(cached_tokens),0) cached_tokens, + COALESCE(SUM(failed),0) failed + FROM usage_events WHERE ts_epoch BETWEEN ? AND ? + """, + (start, end), + ).fetchone() + accounts = conn.execute( + """ + SELECT COALESCE(source, auth_index, 'unknown') account, + COUNT(*) requests, + COALESCE(SUM(total_tokens),0) total_tokens, + COALESCE(SUM(input_tokens),0) input_tokens, + COALESCE(SUM(output_tokens),0) output_tokens, + COALESCE(SUM(reasoning_tokens),0) reasoning_tokens, + COALESCE(SUM(failed),0) failed + FROM usage_events WHERE ts_epoch BETWEEN ? AND ? + GROUP BY account ORDER BY total_tokens DESC + """, + (start, end), + ).fetchall() + models = conn.execute( + """ + SELECT COALESCE(model, 'unknown') model, + COUNT(*) requests, + COALESCE(SUM(total_tokens),0) total_tokens, + COALESCE(SUM(failed),0) failed + FROM usage_events WHERE ts_epoch BETWEEN ? AND ? + GROUP BY model ORDER BY total_tokens DESC LIMIT 12 + """, + (start, end), + ).fetchall() + hours = conn.execute( + """ + SELECT local_hour hour, + COUNT(*) requests, + COALESCE(SUM(total_tokens),0) total_tokens, + COALESCE(SUM(failed),0) failed + FROM usage_events WHERE ts_epoch BETWEEN ? AND ? + GROUP BY local_hour ORDER BY local_hour + """, + (start, end), + ).fetchall() + return { + "range": range_name, + "summary": dict(total), + "accounts": [dict(x) for x in accounts], + "models": [dict(x) for x in models], + "hours": [dict(x) for x in hours], + } + + +def latest_quotas(force=False): + if force: + refresh_quota(force=True) + with db_connect() as conn: + rows = conn.execute( + """ + SELECT q.* FROM quota_snapshots q + JOIN ( + SELECT email, MAX(ts_epoch) ts FROM quota_snapshots GROUP BY email + ) latest ON latest.email = q.email AND latest.ts = q.ts_epoch + ORDER BY email + """ + ).fetchall() + return [dict(row) for row in rows] + + +def recent_requests(limit=100): + with db_connect() as conn: + rows = conn.execute( + """ + SELECT timestamp, source, auth_index, model, endpoint, failed, latency_ms, + input_tokens, output_tokens, reasoning_tokens, cached_tokens, total_tokens, request_id + FROM usage_events ORDER BY ts_epoch DESC LIMIT ? + """, + (limit,), + ).fetchall() + result = [] + for row in rows: + item = dict(row) + item["local_time"] = parse_rfc3339(item["timestamp"]).astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M:%S") + result.append(item) + return result + + +def json_response(handler, payload, status=200): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + handler.wfile.write(body) + + +class DashboardHandler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + return + + def do_GET(self): + parsed = urlparse(self.path) + qs = parse_qs(parsed.query) + try: + if parsed.path == "/": + self.serve_html() + elif parsed.path == "/api/summary": + json_response(self, query_summary(qs.get("range", ["today"])[0])) + elif parsed.path == "/api/quota": + json_response(self, {"quotas": latest_quotas(force=qs.get("force", ["0"])[0] == "1")}) + elif parsed.path == "/api/requests": + limit = min(500, int(qs.get("limit", ["100"])[0])) + json_response(self, {"requests": recent_requests(limit)}) + elif parsed.path == "/api/health": + json_response(self, {"ok": True, "db": DB_PATH, "auth_files": len(auth_files())}) + else: + json_response(self, {"error": "not found"}, 404) + except Exception as exc: + json_response(self, {"error": str(exc)}, 500) + + def serve_html(self): + try: + with open(HTML_PATH, "rb") as f: + body = f.read() + except FileNotFoundError: + body = b"

Dashboard HTML not found

Expected: " + HTML_PATH.encode() + b"

" + self.send_response(500) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + + + +def serve(): + init_db() + cfg = load_config() + server = ThreadingHTTPServer((cfg["dashboard_host"], int(cfg["dashboard_port"])), DashboardHandler) + print(f"dashboard listening on http://{cfg['dashboard_host']}:{cfg['dashboard_port']}", flush=True) + server.serve_forever() + + +def print_report(range_name): + init_db() + summary = query_summary(range_name) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +def start(open_browser=True): + """Run init + collect (background thread) + serve in a single command.""" + # 1. Init DB and config + init_db() + cfg = load_config() + host = cfg["dashboard_host"] + port = int(cfg["dashboard_port"]) + + # When binding to 0.0.0.0, show localhost for browser and also the LAN IP + if host in ("0.0.0.0", ""): + local_url = f"http://127.0.0.1:{port}" + try: + lan_ip = socket.gethostbyname(socket.gethostname()) + except OSError: + lan_ip = None + lan_url = f"http://{lan_ip}:{port}" if lan_ip and lan_ip != "127.0.0.1" else None + else: + local_url = f"http://{host}:{port}" + lan_url = None + + # 2. Start collector in a daemon thread (dies automatically when main process exits) + collector_thread = threading.Thread(target=collect_forever, daemon=True, name="collector") + collector_thread.start() + print("collector started (background thread)", flush=True) + + # 3. Open browser after a short delay so the server is ready + if open_browser: + def _open(): + time.sleep(1.5) + webbrowser.open(local_url) + threading.Thread(target=_open, daemon=True).start() + + # 4. Start HTTP server (blocks — keeps the process alive) + server = ThreadingHTTPServer((host, port), DashboardHandler) + print(f"dashboard listening on {host}:{port}", flush=True) + print(f" local → {local_url}", flush=True) + if lan_url: + print(f" LAN → {lan_url}", flush=True) + print("Press Ctrl+C to stop.", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.", flush=True) + server.shutdown() + + +def main(): + parser = argparse.ArgumentParser(description="CLIProxyAPI usage dashboard") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("init", help="Initialise database and config") + sub.add_parser("collect", help="Run collector loop (foreground)") + sub.add_parser("debug", help="Test one poll cycle, print raw queue items (no DB write)") + sub.add_parser("serve", help="Run HTTP server only (no collector)") + start_p = sub.add_parser("start", help="Init + collect + serve in one command (recommended)") + start_p.add_argument("--no-browser", action="store_true", help="Do not open browser automatically") + quota_p = sub.add_parser("quota", help="Show current quota snapshots") + quota_p.add_argument("--force", action="store_true") + report_p = sub.add_parser("report", help="Print usage summary as JSON") + report_p.add_argument("range", choices=["today", "1h", "5h", "24h", "7d"]) + args = parser.parse_args() + if args.cmd == "init": + init_db() + load_config() + print(DB_PATH) + elif args.cmd == "collect": + collect_forever() + elif args.cmd == "debug": + debug_collect() + elif args.cmd == "serve": + serve() + elif args.cmd == "start": + start(open_browser=not args.no_browser) + elif args.cmd == "quota": + init_db() + print(json.dumps({"quotas": latest_quotas(force=args.force)}, ensure_ascii=False, indent=2)) + elif args.cmd == "report": + print_report(args.range) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/usage-dashboard/usage.sqlite b/usage-dashboard/usage.sqlite new file mode 100644 index 0000000..2919c5c Binary files /dev/null and b/usage-dashboard/usage.sqlite differ