tokentab web dashboard
Web dashboard
Web dashboard的证据链
这一页拆本地 HTTP server、浏览器页面和终端渲染怎样复用同一份 Report。为了避免变成行号清单,本页每个源码定位都绑定具体设计含义:在 sources/tokentab/tokentab/web/server.py:24 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:29 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:33 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:36 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:43 这一处,页面把源码证据和设计判断绑定起来。
24def _make_handler(default_period: str): 25 class Handler(BaseHTTPRequestHandler): 26 def log_message(self, *_args): 27 pass # quiet — we don't want a request log spewing in the terminal 28 29 def do_GET(self): 30 parsed = urlparse(self.path) 31 32 try: 33 if parsed.path == "/api/report": 34 qs = parse_qs(parsed.query) 35 period = (qs.get("period") or [default_period])[0] 36 records = collect_all() 37 report = build_report(records, parse_range(period)) 38 body = json.dumps(report_to_dict(report)).encode("utf-8") 39 self._send(200, "application/json", body) 40 return 41 42 if parsed.path == "/": 43 self._send(200, "text/html; charset=utf-8", render_page().encode("utf-8")) 44 return 45 46 self._send(404, "text/plain", b"not found") 47 except Exception as err: # never let one bad request kill the server 48 self._send(500, "text/plain", f"error: {err}".encode("utf-8")) 49 50 def _send(self, code: int, ctype: str, body: bytes): 51 self.send_response(code) 52 self.send_header("Content-Type", ctype) 53 self.send_header("Content-Length", str(len(body))) 54 self.end_headers() 55 self.wfile.write(body) 56 57 return Handler 58 59 60def start_web(port: int, open_browser: bool, period: str) -> None: 61 handler = _make_handler(period) 62 63 # port 0 (or a taken port) -> let the OS pick a free one 64 try: 65 server = ThreadingHTTPServer(("127.0.0.1", port), handler) 66 except OSError: 67 server = ThreadingHTTPServer(("127.0.0.1", 0), handler) 68 69 actual_port = server.server_address[1] 70 link = f"http://localhost:{actual_port}" 71 print(f"tokentab is running at {link}") 72 print("press ctrl-c to stop") 73 74 if open_browser: 75 threading.Timer(0.4, lambda: _safe_open(link)).start() 76 77 try: 78 server.serve_forever() 79 except KeyboardInterrupt: 80 print("\nstopped") 81 server.shutdown() 82 83 84def _safe_open(link: str) -> None: 85 try: 86 webbrowser.open(link) 87 except Exception: 88 pass # opening a browser is a nicety, not a requirement
核心实现,逐段拆
▸页面 HTML 保持自包含
在 sources/tokentab/tokentab/web/page.py:11 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/page.py:18 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/page.py:24 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/page.py:28 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/page.py:44 这一处,页面把源码证据和设计判断绑定起来。这一段的关键不是单个语句,而是它和上下游契约如何相互约束:输入必须先被压到统一字段,后续 pricing、aggregate、serialize 才能保持无状态且可复用。
11PAGE_HTML = r"""<!doctype html> 12<html lang="en"> 13<head> 14<meta charset="utf-8" /> 15<meta name="viewport" content="width=device-width, initial-scale=1" /> 16<title>tokentab</title> 17<style> 18 :root { 19 --stock: #f6f2e9; 20 --stock-line: #ece5d6; 21 --ink: #1f2a44; 22 --ink-soft: #5c6474; 23 --ink-faint: #9a9384; 24 --rule: #d9d0bd; 25 --stamp: #9c2b28; 26 --stamp-soft: #b8514d; 27 --pos: #2f5d50; 28 /* system stacks only — the tool stays fully offline, no font CDN */ 29 --serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; 30 --mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, "DejaVu Sans Mono", Consolas, monospace; 31 } 32 * { box-sizing: border-box; } 33 html, body { margin: 0; padding: 0; } 34 body { 35 background: #e8e2d4; 36 color: var(--ink); 37 font-family: var(--mono); 38 font-size: 13.5px; 39 line-height: 1.5; 40 -webkit-font-smoothing: antialiased; 41 padding: 40px 18px 70px; 42 } 43 44 .bill { 45 max-width: 720px; 46 margin: 0 auto; 47 background: var(--stock); 48 border: 1px solid var(--rule); 49 /* faint horizontal ledger lines, like ruled accounting paper */ 50 background-image: repeating-linear-gradient( 51 var(--stock) 0, var(--stock) 33px, var(--stock-line) 33px, var(--stock-line) 34px 52 ); 53 box-shadow: 0 30px 60px -34px rgba(40,34,20,0.55); 54 padding: 0; 55 } 56 .inner { padding: 40px 46px 44px; } 57 58 /* ---- letterhead ---- */ 59 .head { 60 display: flex;
▸终端 rich 表格
在 sources/tokentab/tokentab/report/render.py:30 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:43 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:68 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:79 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:100 这一处,页面把源码证据和设计判断绑定起来。这一段的关键不是单个语句,而是它和上下游契约如何相互约束:输入必须先被压到统一字段,后续 pricing、aggregate、serialize 才能保持无状态且可复用。
30def render_report(report: Report, console: Console) -> None: 31 totals = report.totals 32 33 if report.range.frm.timestamp() == 0: 34 range_label = "all time" 35 else: 36 range_label = f"{_fmt_day(report.range.frm)} → {_fmt_day(report.range.to)}" 37 38 console.print() 39 console.print(Text(" tokentab ", style="bold white") + Text(range_label, style="grey58")) 40 console.print() 41 42 # ---- headline totals ---- 43 console.print(Text("Totals", style="bold")) 44 parts = Text(" ") 45 parts.append("cost ", style="grey58") 46 parts.append(money(totals.cost) + " · ", style="green") 47 parts.append("tokens ", style="grey58") 48 parts.append(compact_tokens(totals.tokens.total()) + " · ") 49 parts.append("calls ", style="grey58") 50 parts.append(f"{totals.calls:,}" + " · ") 51 parts.append("cache hit ", style="grey58") 52 parts.append(pct(totals.cache_hit_rate)) 53 console.print(parts) 54 console.print() 55 56 if totals.calls == 0: 57 console.print(Text(" No usage found for this window.", style="yellow")) 58 console.print( 59 Text( 60 " Either nothing ran in this period, or none of the supported tools have logs on disk yet.", 61 style="grey58", 62 ) 63 ) 64 console.print() 65 return 66 67 # ---- by tool ---- 68 console.print(Text("By tool", style="bold")) 69 t = Table(box=SIMPLE_HEAVY, show_edge=True, pad_edge=False) 70 t.add_column("Tool") 71 t.add_column("Cost", justify="right", style="green") 72 t.add_column("Tokens", justify="right") 73 t.add_column("Share", justify="right") 74 for r in report.by_tool: 75 t.add_row(label_for(r.label), money(r.cost), compact_tokens(r.tokens), share(r.cost, totals.cost)) 76 console.print(t) 77 78 # ---- top models ---- 79 console.print(Text("Top models", style="bold")) 80 t = Table(box=SIMPLE_HEAVY, pad_edge=False) 81 t.add_column("Model") 82 t.add_column("Cost", justify="right", style="green") 83 t.add_column("Tokens", justify="right") 84 t.add_column("Calls", justify="right") 85 for r in report.by_model[:8]: 86 t.add_row(r.label, money(r.cost), compact_tokens(r.tokens), str(r.calls)) 87 console.print(t) 88 89 # ---- top projects ---- 90 if len(report.by_project) > 1: 91 console.print(Text("Top projects", style="bold")) 92 t = Table(box=SIMPLE_HEAVY, pad_edge=False) 93 t.add_column("Project") 94 t.add_column("Cost", justify="right", style="green") 95 t.add_column("Tokens", justify="right") 96 for r in report.by_project[:8]: 97 t.add_row(r.label, money(r.cost), compact_tokens(r.tokens)) 98 console.print(t) 99 100 # ---- by activity ---- 101 console.print(Text("By activity", style="bold")) 102 t = Table(box=SIMPLE_HEAVY, pad_edge=False) 103 t.add_column("Activity") 104 t.add_column("Cost", justify="right", style="green") 105 t.add_column("Share", justify="right") 106 for r in report.by_activity: 107 t.add_row(r.label, money(r.cost), share(r.cost, totals.cost)) 108 console.print(t) 109 110 # ---- per day ---- 111 if len(report.by_day) > 1: 112 console.print(Text("Per day", style="bold")) 113 max_cost = max((d.cost for d in report.by_day), default=0.0) 114 for d in report.by_day[-14:]: 115 line = Text(" ") 116 line.append(d.date + " ", style="grey58") 117 line.append(_bar(d.cost, max_cost, 24)) 118 line.append(" ") 119 line.append(money(d.cost), style="green") 120 console.print(line) 121 console.print()
▸项目脚本入口风险
在 sources/tokentab/pyproject.toml:1 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:9 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:13 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:14 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:20 这一处,页面把源码证据和设计判断绑定起来。这一段的关键不是单个语句,而是它和上下游契约如何相互约束:输入必须先被压到统一字段,后续 pricing、aggregate、serialize 才能保持无状态且可复用。
1[project] 2name = "tokentab" 3version = "0.1.0" 4description = "Adds up what your AI coding tools cost by reading their local session logs. Supports Claude Code, Codex, and Gemini CLI. CLI + web dashboard, runs offline." 5readme = "README.md" 6requires-python = ">=3.10" 7license = { text = "MIT" } 8keywords = ["cli", "claude-code", "codex", "gemini", "token-usage", "cost-tracking", "ai-coding"] 9dependencies = [ 10 "rich>=13.0", 11] 12 13[project.scripts] 14tokentab = "tokentab.cli:main" 15 16[build-system] 17requires = ["hatchling"] 18build-backend = "hatchling.build" 19 20[tool.hatch.build.targets.wheel] 21packages = ["tokentab"]
关键流程
在 sources/tokentab/pyproject.toml:1 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:9 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:13 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:14 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:20 这一处,页面把源码证据和设计判断绑定起来。这条流程的优点是每层只承认一种输入形状;风险是入口文件一旦没有走到这个链路,底层解析器再干净也无法被用户正常调用。
边界与失败
边界主要集中在日志格式漂移、模型价格表滞后、缓存 token 含义不一致和 provider 文件不可读。源码用 try_json、walk_files、provider try/except 和 unknown model warning 做了软失败处理;这适合本地观测工具,因为用户更需要“尽量给出已有数据”,而不是因为一个工具坏了让整份账单失败。