From 34042244552e418ce8dffb105479bb3403fa77b2 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Thu, 21 May 2026 13:28:04 +0700 Subject: [PATCH] update .gitignore --- .gitignore | 1 + usage-dashboard/config.example.json | 9 + usage-dashboard/usage-dashboard.html | 644 +++++++++++++++++++++++++ usage-dashboard/usage-dashboard.py | 695 +++++++++++++++++++++++++++ usage-dashboard/usage.sqlite | Bin 0 -> 94208 bytes 5 files changed, 1349 insertions(+) create mode 100644 usage-dashboard/config.example.json create mode 100644 usage-dashboard/usage-dashboard.html create mode 100644 usage-dashboard/usage-dashboard.py create mode 100644 usage-dashboard/usage.sqlite 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 0000000000000000000000000000000000000000..2919c5c371f9830a5d5717b5eb11f8550466163f GIT binary patch literal 94208 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 b<0L{+VE(~U!dWbam)HNlJM+50|Ns97m5q$% literal 0 HcmV?d00001