tokenmeter-dashboard 0.2.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.
- tokenmeter/__init__.py +1 -0
- tokenmeter/__main__.py +3 -0
- tokenmeter/index.html +573 -0
- tokenmeter/pricing.json +34 -0
- tokenmeter/server.py +483 -0
- tokenmeter_dashboard-0.2.0.dist-info/METADATA +168 -0
- tokenmeter_dashboard-0.2.0.dist-info/RECORD +11 -0
- tokenmeter_dashboard-0.2.0.dist-info/WHEEL +5 -0
- tokenmeter_dashboard-0.2.0.dist-info/entry_points.txt +2 -0
- tokenmeter_dashboard-0.2.0.dist-info/licenses/LICENSE +21 -0
- tokenmeter_dashboard-0.2.0.dist-info/top_level.txt +1 -0
tokenmeter/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from tokenmeter.server import __version__
|
tokenmeter/__main__.py
ADDED
tokenmeter/index.html
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Tokenmeter</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light;
|
|
10
|
+
--bg: #f4f4f2; --surface: #fcfcfb; --line: #e3e2dd; --line-soft: #efeeea;
|
|
11
|
+
--text: #0b0b0b; --text-2: #52514e; --text-3: #8a8983;
|
|
12
|
+
--claude: #2a78d6; --codex: #eb6834; --copilot: #1baf7a;
|
|
13
|
+
--mix-1: #d9d4f5; --mix-2: #a89ee6; --mix-3: #7466cc; --mix-4: #4a3aa7;
|
|
14
|
+
--good: #008300; --warn: #c98500; --bad: #e34948;
|
|
15
|
+
--shadow: 0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.04);
|
|
16
|
+
}
|
|
17
|
+
:root[data-theme="dark"] {
|
|
18
|
+
color-scheme: dark;
|
|
19
|
+
--bg: #111110; --surface: #1a1a19; --line: #2c2c2a; --line-soft: #232321;
|
|
20
|
+
--text: #ffffff; --text-2: #c3c2b7; --text-3: #85847c;
|
|
21
|
+
--claude: #3987e5; --codex: #d95926; --copilot: #199e70;
|
|
22
|
+
--mix-1: #4a4470; --mix-2: #6b62a8; --mix-3: #9085e9; --mix-4: #c3bcf5;
|
|
23
|
+
--good: #3fb950; --warn: #d9a441; --bad: #e66767;
|
|
24
|
+
--shadow: none;
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
28
|
+
.wrap { max-width: 1320px; margin: 0 auto; padding: 20px 16px 48px; }
|
|
29
|
+
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; }
|
|
30
|
+
.brand { display: flex; align-items: center; gap: 10px; margin-right: auto; }
|
|
31
|
+
.brand h1 { font-size: 18px; margin: 0; letter-spacing: -.01em; }
|
|
32
|
+
.brand svg { width: 26px; height: 26px; }
|
|
33
|
+
.live { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); }
|
|
34
|
+
.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--good); animation: pulse 2s infinite; }
|
|
35
|
+
.live.stale i { background: var(--warn); animation: none; }
|
|
36
|
+
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(0,131,0,.5); } 70% { box-shadow: 0 0 0 7px rgba(0,131,0,0); } 100% { box-shadow: 0 0 0 0 rgba(0,131,0,0); } }
|
|
37
|
+
.controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
38
|
+
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--surface); }
|
|
39
|
+
.seg button { border: 0; background: transparent; color: var(--text-2); padding: 6px 11px; cursor: pointer; font: inherit; font-size: 13px; }
|
|
40
|
+
.seg button + button { border-left: 1px solid var(--line); }
|
|
41
|
+
.seg button.on { background: var(--text); color: var(--bg); }
|
|
42
|
+
a.btn { text-decoration: none; display: inline-block; }
|
|
43
|
+
select, .btn { border: 1px solid var(--line); background: var(--surface); color: var(--text); border-radius: 8px; padding: 6px 10px; font: inherit; font-size: 13px; max-width: 220px; cursor: pointer; }
|
|
44
|
+
.btn:hover, select:hover { border-color: var(--text-3); }
|
|
45
|
+
.grid { display: grid; gap: 14px; margin-bottom: 14px; }
|
|
46
|
+
.kpis { grid-template-columns: repeat(6, 1fr); }
|
|
47
|
+
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
|
|
48
|
+
@media (max-width: 560px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
|
|
49
|
+
.two { grid-template-columns: 1fr 1fr; }
|
|
50
|
+
@media (max-width: 860px) { .two { grid-template-columns: 1fr; } }
|
|
51
|
+
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; box-shadow: var(--shadow); min-width: 0; }
|
|
52
|
+
.kpi .label { font-size: 12px; color: var(--text-2); margin-bottom: 6px; }
|
|
53
|
+
.kpi .value { font-size: 26px; font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
|
54
|
+
.kpi .sub { font-size: 12px; color: var(--text-3); margin-top: 6px; font-variant-numeric: tabular-nums; }
|
|
55
|
+
.kpi .sub b { color: var(--text-2); font-weight: 500; }
|
|
56
|
+
.card h2 { font-size: 14px; margin: 0 0 12px; font-weight: 600; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
57
|
+
.card h2 .legend { margin-left: auto; display: flex; gap: 14px; font-weight: 400; font-size: 12px; color: var(--text-2); }
|
|
58
|
+
.card h2 .hint { margin-left: auto; font-weight: 400; color: var(--text-3); font-size: 12px; }
|
|
59
|
+
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
60
|
+
.legend i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
|
61
|
+
.chart-wrap { position: relative; }
|
|
62
|
+
svg.chart { width: 100%; height: 260px; display: block; overflow: visible; }
|
|
63
|
+
svg.chart text { fill: var(--text-3); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
64
|
+
svg.chart .grid line { stroke: var(--line-soft); }
|
|
65
|
+
svg.chart .axis line { stroke: var(--line); }
|
|
66
|
+
svg.chart g.col:hover rect.bar { opacity: .85; }
|
|
67
|
+
svg.chart rect.hit { fill: transparent; }
|
|
68
|
+
.tip { position: absolute; pointer-events: none; background: var(--text); color: var(--bg); padding: 8px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; transform: translate(-50%, calc(-100% - 10px)); opacity: 0; transition: opacity .1s; z-index: 5; font-variant-numeric: tabular-nums; }
|
|
69
|
+
.tip .row { display: flex; gap: 10px; justify-content: space-between; }
|
|
70
|
+
.tip i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-right: 6px; }
|
|
71
|
+
.mix { display: flex; height: 22px; border-radius: 6px; overflow: hidden; gap: 2px; background: var(--line-soft); margin: 4px 0 14px; }
|
|
72
|
+
.mix div { min-width: 2px; transition: width .3s; }
|
|
73
|
+
.mixlist { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
|
|
74
|
+
.mixlist div { font-size: 12px; color: var(--text-2); }
|
|
75
|
+
.mixlist b { display: block; color: var(--text); font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
76
|
+
.mixlist i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; margin-right: 6px; vertical-align: -1px; }
|
|
77
|
+
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 13px; }
|
|
78
|
+
th, td { text-align: left; padding: 8px 8px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
|
|
79
|
+
th { font-weight: 500; color: var(--text-2); font-size: 12px; cursor: pointer; user-select: none; position: sticky; top: 0; background: var(--surface); }
|
|
80
|
+
th:hover, th.on { color: var(--text); }
|
|
81
|
+
td.n, th.n { text-align: right; }
|
|
82
|
+
td.mono, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
83
|
+
.tool { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: 1px; }
|
|
84
|
+
td .bar { display: inline-block; height: 6px; border-radius: 3px; background: var(--text); opacity: .18; vertical-align: middle; margin-left: 8px; }
|
|
85
|
+
tr.new td { animation: flash 1.6s ease-out; }
|
|
86
|
+
@keyframes flash { from { background: color-mix(in srgb, var(--claude) 18%, transparent); } to { background: transparent; } }
|
|
87
|
+
tr.prow { cursor: pointer; } tr.prow:hover td { background: var(--line-soft); }
|
|
88
|
+
td.wrap { white-space: normal; min-width: 180px; max-width: 420px; color: var(--text); }
|
|
89
|
+
tr.pfull td { white-space: normal; background: var(--line-soft); padding: 12px 14px; }
|
|
90
|
+
.full { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; max-height: 240px; overflow: auto; }
|
|
91
|
+
tr.pfull code, .notice code, footer code { font-family: ui-monospace, Menlo, monospace; background: var(--bg); padding: 1px 5px; border-radius: 4px; user-select: all; }
|
|
92
|
+
.trunc { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
|
93
|
+
td.commit { max-width: 180px; } td.repo { max-width: 110px; }
|
|
94
|
+
.scroll { overflow: auto; max-height: 420px; }
|
|
95
|
+
.muted { color: var(--text-3); font-size: 12px; }
|
|
96
|
+
footer { margin-top: 20px; color: var(--text-3); font-size: 12px; display: flex; gap: 16px; flex-wrap: wrap; }
|
|
97
|
+
.empty { padding: 30px; text-align: center; color: var(--text-3); white-space: normal; }
|
|
98
|
+
.hide { display: none !important; }
|
|
99
|
+
.notice { background: var(--surface); border: 1px dashed var(--line); border-radius: 10px; padding: 10px 14px; font-size: 12px; color: var(--text-2); margin-bottom: 14px; }
|
|
100
|
+
.notice.alert { border-color: var(--bad); color: var(--text); }
|
|
101
|
+
.meter { height: 8px; border-radius: 4px; background: var(--line-soft); overflow: hidden; margin: 6px 0 4px; }
|
|
102
|
+
.meter div { height: 100%; border-radius: 4px; background: var(--text); transition: width .3s; }
|
|
103
|
+
.meter.warn div { background: var(--warn); } .meter.bad div { background: var(--bad); }
|
|
104
|
+
.limits { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
|
105
|
+
.limits h3 { font-size: 12px; font-weight: 600; margin: 0 0 8px; }
|
|
106
|
+
.limits .row { font-size: 12px; color: var(--text-2); display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; }
|
|
107
|
+
.limits .row b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
108
|
+
.sess { display: grid; gap: 10px; }
|
|
109
|
+
.sess .item { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; font-size: 12px; }
|
|
110
|
+
.sess .name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
111
|
+
.sess .meter { grid-column: 1 / -1; margin: 2px 0 0; }
|
|
112
|
+
.kpi .meter { margin-top: 8px; }
|
|
113
|
+
</style>
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
<div class="wrap">
|
|
117
|
+
<header>
|
|
118
|
+
<div class="brand">
|
|
119
|
+
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true"><rect x="1" y="1" width="24" height="24" rx="7" fill="var(--text)"/><rect x="6" y="14" width="3" height="6" rx="1" fill="var(--bg)"/><rect x="11.5" y="9" width="3" height="11" rx="1" fill="var(--bg)"/><rect x="17" y="5" width="3" height="15" rx="1" fill="var(--bg)"/></svg>
|
|
120
|
+
<h1>Tokenmeter</h1>
|
|
121
|
+
<span class="live" id="live"><i></i><span id="liveText">live</span></span>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="controls">
|
|
124
|
+
<div class="seg" id="range"><button data-v="today">Today</button><button data-v="7">7d</button><button data-v="30" class="on">30d</button><button data-v="all">All</button></div>
|
|
125
|
+
<div class="seg" id="tool"><button data-v="all" class="on">All</button><button data-v="claude">Claude Code</button><button data-v="codex">Codex</button><button data-v="copilot">Copilot</button></div>
|
|
126
|
+
<select id="user" class="hide"><option value="">Everyone</option></select>
|
|
127
|
+
<select id="project"><option value="">All projects</option></select>
|
|
128
|
+
<select id="model"><option value="">All models</option></select>
|
|
129
|
+
<button class="btn" id="theme" title="Toggle theme">◐</button>
|
|
130
|
+
<a class="btn" id="csv" href="/api/export.csv" download>Export CSV</a>
|
|
131
|
+
</div>
|
|
132
|
+
</header>
|
|
133
|
+
|
|
134
|
+
<div class="notice hide" id="firstRun">No transcripts found yet. Tokenmeter reads <code id="frClaude"></code>, <code id="frCodex"></code> and <code id="frCopilot"></code>. Send one prompt in Claude Code, Codex or Copilot CLI and it will appear here within a few seconds. If your logs live elsewhere, set <code>CLAUDE_CONFIG_DIR</code>, <code>CODEX_HOME</code> or <code>COPILOT_DB</code> before starting the server.</div>
|
|
135
|
+
<div class="notice alert hide" id="alerts"></div>
|
|
136
|
+
<div class="notice hide" id="unpriced"></div>
|
|
137
|
+
|
|
138
|
+
<div class="grid kpis">
|
|
139
|
+
<div class="card kpi"><div class="label" id="kCostLabel">Spend</div><div class="value" id="kCost">–</div><div class="sub" id="kCostSub"></div><div class="meter hide" id="kBudget"><div></div></div></div>
|
|
140
|
+
<div class="card kpi"><div class="label">Total tokens</div><div class="value" id="kTokens">–</div><div class="sub" id="kTokensSub"></div></div>
|
|
141
|
+
<div class="card kpi"><div class="label">Prompts</div><div class="value" id="kPrompts">–</div><div class="sub" id="kPromptsSub"></div></div>
|
|
142
|
+
<div class="card kpi"><div class="label">Cache hit rate</div><div class="value" id="kCache">–</div><div class="sub" id="kCacheSub"></div></div>
|
|
143
|
+
<div class="card kpi"><div class="label">Output tokens</div><div class="value" id="kOut">–</div><div class="sub" id="kOutSub"></div></div>
|
|
144
|
+
<div class="card kpi"><div class="label">Model turns</div><div class="value" id="kTurns">–</div><div class="sub" id="kTurnsSub"></div></div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div class="card" id="expensiveCard" style="margin-bottom:14px"><h2>Most expensive prompts <span class="hint">click a row for the full text</span></h2><div class="scroll"><table id="expensive"></table></div></div>
|
|
148
|
+
|
|
149
|
+
<div class="card" id="promptsCard" style="margin-bottom:14px">
|
|
150
|
+
<h2>Prompts <span class="muted" id="promptCount"></span><span class="hint">Click a row for the full text and a resume command · click a header to sort</span></h2>
|
|
151
|
+
<div class="scroll" style="max-height:520px"><table id="prompts"></table></div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div class="grid two">
|
|
155
|
+
<div class="card"><h2>Usage limits <span class="hint">rolling windows, independent of the range filter</span></h2><div class="limits" id="limits"></div></div>
|
|
156
|
+
<div class="card"><h2>Active sessions <span class="hint">context fill, last 24 hours</span></h2><div class="sess" id="sessions"></div></div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="card" id="chartCard" style="margin-bottom:14px">
|
|
160
|
+
<h2>
|
|
161
|
+
<span>Usage over time</span>
|
|
162
|
+
<div class="seg" id="metric" style="font-size:12px"><button data-v="tokens" class="on">Tokens</button><button data-v="cost">Cost</button><button data-v="prompts">Prompts</button></div>
|
|
163
|
+
<button class="btn" id="tableToggle" style="padding:3px 8px;font-size:12px">Table</button>
|
|
164
|
+
<div class="legend" id="legend"></div>
|
|
165
|
+
</h2>
|
|
166
|
+
<div class="chart-wrap" id="chartWrap"><svg class="chart" id="chart"></svg><div class="tip" id="tip"></div></div>
|
|
167
|
+
<div class="scroll hide" id="chartTable"></div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div class="grid two" id="mixRow">
|
|
171
|
+
<div class="card">
|
|
172
|
+
<h2>Token mix</h2>
|
|
173
|
+
<div class="mix" id="mix"></div>
|
|
174
|
+
<div class="mixlist" id="mixList"></div>
|
|
175
|
+
<h2 style="margin-top:18px">Cost mix <span class="muted" style="font-weight:400">same tokens, weighted by price</span></h2>
|
|
176
|
+
<div class="mix" id="costMix"></div>
|
|
177
|
+
<div class="mixlist" id="costMixList"></div>
|
|
178
|
+
<p class="muted" style="margin:14px 0 0">Cache reads are cheap, cache writes and output are not. Thinking tokens are included in output.</p>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="card"><h2>By model</h2><div class="scroll"><table id="byModel"></table></div></div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="grid two">
|
|
184
|
+
<div class="card"><h2>Cache misses <span class="hint" id="missHint"></span></h2><div class="scroll"><table id="misses"></table></div></div>
|
|
185
|
+
<div class="card"><h2>Cost per commit <span class="hint">turns between one commit and the next, per repo</span></h2><div class="scroll"><table id="commits"></table></div></div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="grid two">
|
|
189
|
+
<div class="card"><h2>By project</h2><div class="scroll"><table id="byProject"></table></div></div>
|
|
190
|
+
<div class="card"><h2>Top sessions</h2><div class="scroll"><table id="bySession"></table></div></div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="card">
|
|
194
|
+
<h2>Live feed <span class="muted" id="feedCount"></span></h2>
|
|
195
|
+
<div class="scroll"><table id="feed"></table></div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<footer><span id="srcClaude"></span><span id="srcCodex"></span><span id="srcCopilot"></span><span id="scanned"></span></footer>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<script>
|
|
202
|
+
const $ = id => document.getElementById(id);
|
|
203
|
+
const TOOLS = { claude: 'Claude Code', codex: 'Codex', copilot: 'Copilot' };
|
|
204
|
+
const DEFAULTS = { range: '30', tool: 'all', metric: 'tokens' };
|
|
205
|
+
const state = { range: '30', tool: 'all', project: '', model: '', user: '', metric: 'tokens', table: false, data: null, commits: {}, version: null, seen: new Set(), open: new Set(), sort: {} };
|
|
206
|
+
const fmt = n => n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(Math.round(n));
|
|
207
|
+
const usd = n => n == null ? '–' : n === 0 ? '$0' : n >= 100 ? '$' + Math.round(n).toLocaleString() : n >= 1 ? '$' + n.toFixed(2) : '$' + n.toFixed(3);
|
|
208
|
+
const pct = n => isFinite(n) ? (n * 100).toFixed(n < 0.1 ? 1 : 0) + '%' : '–';
|
|
209
|
+
const total = r => r.in + r.cr + r.cw5 + r.cw1h + r.out;
|
|
210
|
+
const short = p => (p || 'unknown').replace(/^\/Users\/[^/]+/, '~').replace(/^\/home\/[^/]+/, '~').replace(/^-Users-[^-]+/, '~');
|
|
211
|
+
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
212
|
+
const ago = ts => { const m = (Date.now() - new Date(ts)) / 60000; return m < 1 ? 'just now' : m < 60 ? Math.round(m) + 'm ago' : m < 1440 ? Math.round(m / 60) + 'h ago' : Math.round(m / 1440) + 'd ago'; };
|
|
213
|
+
const when = ts => new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
214
|
+
const toolDot = t => `<span class="tool" style="background:var(--${t})"></span>${TOOLS[t] || t}`;
|
|
215
|
+
const prefixLookup = (table, key) => { let best = null; for (const k in table) if (key.startsWith(k) && (!best || k.length > best.length)) best = k; return best ? table[best] : null; };
|
|
216
|
+
const sum = (arr, f) => arr.reduce((a, r) => a + (f(r) || 0), 0);
|
|
217
|
+
|
|
218
|
+
function bounds() {
|
|
219
|
+
const now = new Date();
|
|
220
|
+
if (state.range === 'all') return [null, now];
|
|
221
|
+
const s = new Date(now); s.setHours(0, 0, 0, 0);
|
|
222
|
+
if (state.range !== 'today') s.setDate(s.getDate() - (+state.range - 1));
|
|
223
|
+
return [s, now];
|
|
224
|
+
}
|
|
225
|
+
const inRange = (ts, [s, e]) => { const t = new Date(ts); return (!s || t >= s) && t <= e; };
|
|
226
|
+
const baseFilter = r => (state.tool === 'all' || r.tool === state.tool) && (!state.user || r.user === state.user) && (!state.project || r.project === state.project);
|
|
227
|
+
const passes = (r, b) => inRange(r.ts, b) && baseFilter(r) && (!state.model || r.model === state.model);
|
|
228
|
+
const prevBounds = ([s, e]) => s ? [new Date(s - (e - s)), s] : null;
|
|
229
|
+
|
|
230
|
+
function attribute(recs, prompts) {
|
|
231
|
+
const bySession = new Map();
|
|
232
|
+
for (const p of prompts) { if (!bySession.has(p.session)) bySession.set(p.session, []); bySession.get(p.session).push({ p, turns: 0, tokens: 0, cost: 0, priced: true, last: p.ts }); }
|
|
233
|
+
const owner = new Map();
|
|
234
|
+
for (const r of recs) {
|
|
235
|
+
const list = bySession.get(r.session); if (!list) continue;
|
|
236
|
+
let g = null; for (const x of list) { if (x.p.ts <= r.ts) g = x; else break; }
|
|
237
|
+
if (!g) continue;
|
|
238
|
+
g.turns++; g.tokens += total(r); g.cost += r.cost || 0; if (r.cost == null) g.priced = false; if (r.ts > g.last) g.last = r.ts; owner.set(r, g);
|
|
239
|
+
}
|
|
240
|
+
const groups = [...bySession.values()].flat().map(g => Object.assign(g, { dur: new Date(g.last) - new Date(g.p.ts), text: g.p.text || '', ts: g.p.ts, tool: g.p.tool, project: g.p.project }));
|
|
241
|
+
return { groups, owner };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function table(id, cols, rows, opts = {}) {
|
|
245
|
+
const st = state.sort[id] || (state.sort[id] = { k: opts.sort || cols[0].k, dir: -1 });
|
|
246
|
+
const col = cols.find(c => c.k === st.k) || cols[0];
|
|
247
|
+
rows = [...rows].sort((a, b) => { const x = a[col.k], y = b[col.k]; const r = typeof x === 'number' || typeof y === 'number' ? (x || 0) - (y || 0) : String(x ?? '').localeCompare(String(y ?? '')); return r * st.dir; });
|
|
248
|
+
if (opts.limit) rows = rows.slice(0, opts.limit);
|
|
249
|
+
const head = cols.map(c => `<th class="${c.n ? 'n' : ''} ${c.k === col.k ? 'on' : ''}" data-k="${c.k}">${c.label}${c.k === col.k ? (st.dir < 0 ? ' ▼' : ' ▲') : ''}</th>`).join('');
|
|
250
|
+
const body = rows.map((r, i) => `<tr ${opts.attrs ? opts.attrs(r, i) : ''}>${cols.map(c => `<td class="${c.n ? 'n' : ''} ${c.cls || ''}" ${c.title ? `title="${esc(c.title(r))}"` : ''}>${c.fmt ? c.fmt(r[c.k], r) : esc(r[c.k])}</td>`).join('')}</tr>${opts.extra ? opts.extra(r, i) : ''}`).join('');
|
|
251
|
+
const el = $(id);
|
|
252
|
+
el.innerHTML = rows.length ? `<tr>${head}</tr>${body}` : `<tr><td class="empty">${opts.empty || 'No data in this range'}</td></tr>`;
|
|
253
|
+
el.querySelectorAll('th').forEach(th => th.onclick = () => { if (st.k === th.dataset.k) st.dir *= -1; else { st.k = th.dataset.k; st.dir = -1; } render(); });
|
|
254
|
+
return rows;
|
|
255
|
+
}
|
|
256
|
+
const costCell = (v, r) => r.priced === false ? '<span class="muted">unpriced</span>' : usd(v);
|
|
257
|
+
const tokCell = max => (v, r) => `${fmt(v)}<span class="bar" style="width:${Math.round(v / max * 48)}px"></span>`;
|
|
258
|
+
|
|
259
|
+
function render() {
|
|
260
|
+
const d = state.data; if (!d) return;
|
|
261
|
+
const b = bounds();
|
|
262
|
+
const recs = d.records.filter(r => passes(r, b));
|
|
263
|
+
const prompts = d.prompts.filter(p => inRange(p.ts, b) && baseFilter(p));
|
|
264
|
+
const pb = prevBounds(b);
|
|
265
|
+
const prev = pb ? d.records.filter(r => passes(r, pb)) : null;
|
|
266
|
+
const tokens = sum(recs, total), cost = sum(recs, r => r.cost), out = sum(recs, r => r.out);
|
|
267
|
+
const cacheIn = sum(recs, r => r.cr), freshIn = sum(recs, r => r.in + r.cw5 + r.cw1h);
|
|
268
|
+
const unpriced = recs.filter(r => r.cost == null);
|
|
269
|
+
const delta = (cur, prevVal) => prev && prevVal > 0 ? `<b>${cur >= prevVal ? '+' : ''}${pct((cur - prevVal) / prevVal)}</b> vs previous period` : '';
|
|
270
|
+
renderCost(recs, cost, prev);
|
|
271
|
+
$('kTokens').textContent = fmt(tokens);
|
|
272
|
+
$('kTokensSub').innerHTML = delta(tokens, prev && sum(prev, total)) || Object.keys(TOOLS).filter(t => state.tool === 'all' || t === state.tool).map(t => `${fmt(sum(recs.filter(r => r.tool === t), total))} ${TOOLS[t]}`).join(' · ');
|
|
273
|
+
$('kPrompts').textContent = prompts.length.toLocaleString();
|
|
274
|
+
$('kPromptsSub').innerHTML = prompts.length ? `<b>${fmt(tokens / prompts.length)}</b> tokens per prompt` : '';
|
|
275
|
+
$('kCache').textContent = pct(cacheIn / (cacheIn + freshIn));
|
|
276
|
+
$('kCacheSub').innerHTML = `<b>${fmt(cacheIn)}</b> read from cache`;
|
|
277
|
+
$('kOut').textContent = fmt(out);
|
|
278
|
+
$('kOutSub').innerHTML = `<b>${fmt(sum(recs, r => r.reason))}</b> thinking`;
|
|
279
|
+
$('kTurns').textContent = recs.length.toLocaleString();
|
|
280
|
+
$('kTurnsSub').innerHTML = `<b>${recs.filter(r => r.sub).length}</b> in subagents`;
|
|
281
|
+
const unknownModels = [...new Set(unpriced.map(r => r.model))];
|
|
282
|
+
$('unpriced').classList.toggle('hide', !unknownModels.length);
|
|
283
|
+
$('unpriced').innerHTML = `${unpriced.length} turns excluded from cost: no pricing for <b>${unknownModels.map(esc).join(', ')}</b>. Add them to <code>pricing.json</code> next to server.py.`;
|
|
284
|
+
$('alerts').classList.toggle('hide', !d.alerts.length);
|
|
285
|
+
$('alerts').innerHTML = d.alerts.slice(-3).map(a => `<b>${esc(a.title)}</b> ${esc(a.body)} <span class="muted">${ago(a.ts * 1000)}</span>`).join('<br>');
|
|
286
|
+
const att = attribute(recs, prompts);
|
|
287
|
+
renderLimits();
|
|
288
|
+
renderSessions();
|
|
289
|
+
renderChart(recs, prompts, b);
|
|
290
|
+
renderMix(recs);
|
|
291
|
+
const groups = keyFn => { const m = new Map(); for (const r of recs) { const k = keyFn(r); const g = m.get(k) || { k, tool: r.tool, turns: 0, tokens: 0, cost: 0, priced: true, share: 0 }; g.turns++; g.tokens += total(r); g.cost += r.cost || 0; if (r.cost == null) g.priced = false; if (g.tool !== r.tool) g.tool = 'mixed'; m.set(k, g); } const rows = [...m.values()]; rows.forEach(g => g.share = g.tokens / Math.max(1, tokens)); return rows; };
|
|
292
|
+
const nameCell = (v, r) => `${r.tool !== 'mixed' ? `<span class="tool" style="background:var(--${r.tool})"></span>` : ''}${esc(v)}`;
|
|
293
|
+
const groupCols = (label, rows) => [{ k: 'k', label, fmt: nameCell, cls: 'trunc', title: r => r.k }, { k: 'turns', label: 'Turns', n: 1 }, { k: 'tokens', label: 'Tokens', n: 1, fmt: tokCell(Math.max(1, ...rows.map(x => x.tokens))) }, { k: 'cost', label: 'Cost', n: 1, fmt: costCell }, { k: 'share', label: '% of tokens', n: 1, fmt: pct }];
|
|
294
|
+
let rows = groups(r => r.model); table('byModel', groupCols('Model', rows), rows, { sort: 'tokens' });
|
|
295
|
+
rows = groups(r => short(r.project)); table('byProject', groupCols('Project', rows), rows, { sort: 'tokens' });
|
|
296
|
+
const firstPrompt = new Map();
|
|
297
|
+
for (const p of d.prompts) if (p.text && !firstPrompt.has(p.session)) firstPrompt.set(p.session, p.text.replace(/\s+/g, ' ').slice(0, 60));
|
|
298
|
+
rows = groups(r => firstPrompt.get(r.session) || ((r.session || '').slice(0, 8) + ' · ' + short(r.project))); table('bySession', groupCols('Session', rows), rows, { sort: 'tokens', limit: 20 });
|
|
299
|
+
renderPrompts(att.groups, prompts.length);
|
|
300
|
+
renderExpensive(att.groups);
|
|
301
|
+
renderMisses(recs, att.owner);
|
|
302
|
+
renderCommits(recs);
|
|
303
|
+
renderFeed(recs);
|
|
304
|
+
$('csv').href = '/api/export.csv' + (b[0] ? '?since=' + b[0].toISOString() : '');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderCost(recs, cost, prev) {
|
|
308
|
+
const d = state.data;
|
|
309
|
+
const days = { today: 1, 7: 7, 30: 30 }[state.range] || (d.records.length ? Math.max(30, (Date.now() - new Date(d.records[0].ts)) / 86400000) : 30);
|
|
310
|
+
const users = state.user ? 1 : Math.max(1, d.users.length);
|
|
311
|
+
const plan = Object.entries(d.plans || {}).filter(([k]) => state.tool === 'all' || k === state.tool).reduce((a, [, v]) => a + v, 0) * days / 30 * users;
|
|
312
|
+
const unpriced = recs.filter(r => r.cost == null).length;
|
|
313
|
+
const prevCost = prev ? sum(prev, r => r.cost) : 0;
|
|
314
|
+
const apiLine = `API-equivalent <b>${usd(cost)}</b>${unpriced ? ` <span class="muted">(${unpriced} unpriced turns excluded)</span>` : ''}`;
|
|
315
|
+
if (plan > 0) {
|
|
316
|
+
$('kCostLabel').textContent = 'Subscription spend';
|
|
317
|
+
$('kCost').textContent = usd(plan);
|
|
318
|
+
$('kCostSub').innerHTML = apiLine + (cost ? `, <b>${(cost / plan).toFixed(1)}×</b> what you paid` : '');
|
|
319
|
+
} else {
|
|
320
|
+
$('kCostLabel').textContent = 'API-equivalent cost';
|
|
321
|
+
$('kCost').textContent = usd(cost);
|
|
322
|
+
$('kCostSub').innerHTML = (prevCost > 0 ? `<b>${cost >= prevCost ? '+' : ''}${pct((cost - prevCost) / prevCost)}</b> vs previous period` : 'at API list prices, from pricing.json') + (unpriced ? ` · ${unpriced} unpriced turns excluded` : '');
|
|
323
|
+
}
|
|
324
|
+
const budget = state.range === 'today' ? d.budget.daily : state.range === '30' ? d.budget.monthly : null;
|
|
325
|
+
$('kBudget').classList.toggle('hide', !budget);
|
|
326
|
+
if (budget) { const f = cost / budget; $('kBudget').className = 'meter' + (f > 1 ? ' bad' : f > 0.8 ? ' warn' : ''); $('kBudget').firstElementChild.style.width = Math.min(100, f * 100) + '%'; $('kCostSub').innerHTML += `<br><b>${pct(f)}</b> of ${usd(budget)} ${state.range === 'today' ? 'daily' : 'monthly'} budget`; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderLimits() {
|
|
330
|
+
const d = state.data, now = Date.now();
|
|
331
|
+
const mine = d.records.filter(r => !state.user || r.user === state.user);
|
|
332
|
+
const window = (tool, ms) => { const rs = mine.filter(r => r.tool === tool && now - new Date(r.ts) < ms); return { tokens: sum(rs, total), cost: sum(rs, r => r.cost) }; };
|
|
333
|
+
const meter = (f, label, right) => `<div class="row"><span>${label}</span><b>${right}</b></div><div class="meter ${f > 0.9 ? 'bad' : f > 0.7 ? 'warn' : ''}"><div style="width:${Math.min(100, f * 100)}%"></div></div>`;
|
|
334
|
+
const local = tool => { const h = window(tool, 5 * 3600e3), w = window(tool, 7 * 86400e3); return `<div class="row"><span>Last 5 hours</span><b>${fmt(h.tokens)} · ${usd(h.cost)}</b></div><div class="row"><span>Last 7 days</span><b>${fmt(w.tokens)} · ${usd(w.cost)}</b></div>`; };
|
|
335
|
+
const resets = t => { const ms = (typeof t === 'number' ? t * 1000 : new Date(t)) - now; return ms <= 0 ? 'reset due' : ms < 3600e3 ? `resets in ${Math.round(ms / 60000)}m` : ms < 86400e3 ? `resets in ${(ms / 3600e3).toFixed(1)}h` : `resets in ${(ms / 86400e3).toFixed(1)}d`; };
|
|
336
|
+
const blocks = [];
|
|
337
|
+
if (state.tool === 'all' || state.tool === 'claude') {
|
|
338
|
+
let body = '';
|
|
339
|
+
const c = d.limits.claude;
|
|
340
|
+
if (c && typeof c === 'object') for (const [k, v] of Object.entries(c)) if (v && typeof v === 'object' && 'utilization' in v) body += meter(v.utilization / 100, k.replace(/_/g, ' '), `${Math.round(v.utilization)}%${v.resets_at ? ' · ' + resets(v.resets_at) : ''}`);
|
|
341
|
+
body += local('claude');
|
|
342
|
+
if (!c) body += `<p class="muted" style="margin:8px 0 0">Anthropic does not write the plan limit % to disk. Tokenmeter shows it when a Claude Code OAuth token is available in the keychain.</p>`;
|
|
343
|
+
blocks.push(`<div><h3>${toolDot('claude')}</h3>${body}</div>`);
|
|
344
|
+
}
|
|
345
|
+
if (state.tool === 'all' || state.tool === 'codex') {
|
|
346
|
+
const c = d.limits.codex;
|
|
347
|
+
let body = c ? c.windows.map(w => { const stale = w.resets_at * 1000 < now; return meter(stale ? 0 : w.used_percent / 100, w.window_minutes === 300 ? '5-hour window' : w.window_minutes === 10080 ? 'Weekly window' : `${w.window_minutes} min window`, stale ? `was ${Math.round(w.used_percent)}%, window has reset` : `${Math.round(w.used_percent)}% · ${resets(w.resets_at)}`); }).join('') + `<p class="muted" style="margin:8px 0 0">${esc(c.plan || '')} plan · last reported by Codex ${ago(c.ts)}; refreshes on your next Codex turn</p>` : '<p class="muted" style="margin:8px 0 0">Codex reports its 5-hour and weekly limits in each session log. None recorded yet.</p>';
|
|
348
|
+
body += local('codex');
|
|
349
|
+
blocks.push(`<div><h3>${toolDot('codex')}</h3>${body}</div>`);
|
|
350
|
+
}
|
|
351
|
+
if ((state.tool === 'all' && mine.some(r => r.tool === 'copilot')) || state.tool === 'copilot') blocks.push(`<div><h3>${toolDot('copilot')}</h3>${local('copilot')}</div>`);
|
|
352
|
+
$('limits').innerHTML = blocks.join('');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function renderSessions() {
|
|
356
|
+
const d = state.data, now = Date.now();
|
|
357
|
+
const last = new Map();
|
|
358
|
+
for (const r of d.records) if (baseFilter(r) && now - new Date(r.ts) < 86400e3 && !r.sub) last.set(r.session, r);
|
|
359
|
+
const firstPrompt = new Map();
|
|
360
|
+
for (const p of d.prompts) if (p.text && !firstPrompt.has(p.session)) firstPrompt.set(p.session, p.text.replace(/\s+/g, ' ').slice(0, 70));
|
|
361
|
+
const rows = [...last.values()].sort((a, b) => b.ts.localeCompare(a.ts)).slice(0, 8).map(r => {
|
|
362
|
+
const ctx = r.in + r.cr + r.cw5 + r.cw1h;
|
|
363
|
+
const win = (r.tool === 'codex' && d.context_windows.codex) || prefixLookup(d.context_windows, r.model) || 200000;
|
|
364
|
+
const f = ctx / win;
|
|
365
|
+
return `<div class="item"><span class="name" title="${esc(r.session)}">${toolDot(r.tool)} ${esc(firstPrompt.get(r.session) || short(r.project))}</span><span class="muted">${esc(r.model)} · ${ago(r.ts)}</span><div class="meter ${f > 0.9 ? 'bad' : f > 0.7 ? 'warn' : ''}"><div style="width:${Math.min(100, f * 100)}%"></div></div><span class="muted">${fmt(ctx)} / ${fmt(win)} context (${pct(f)})</span><span class="muted">${esc(short(r.project))}</span></div>`;
|
|
366
|
+
});
|
|
367
|
+
$('sessions').innerHTML = rows.join('') || '<div class="empty">No sessions active in the last 24 hours</div>';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buckets([s, e]) {
|
|
371
|
+
const hourly = state.range === 'today';
|
|
372
|
+
const first = state.data.records.find(baseFilter);
|
|
373
|
+
const start = s ? new Date(s) : new Date(first ? first.ts : e);
|
|
374
|
+
if (!s) start.setHours(0, 0, 0, 0);
|
|
375
|
+
const keys = [];
|
|
376
|
+
for (let t = new Date(start); t <= e && keys.length < 800; hourly ? t.setHours(t.getHours() + 1) : t.setDate(t.getDate() + 1)) keys.push(new Date(t));
|
|
377
|
+
const keyOf = ts => { const t = new Date(ts); t.setMinutes(0, 0, 0); if (!hourly) t.setHours(0); return t.getTime(); };
|
|
378
|
+
return { keys, keyOf, hourly };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function renderChart(recs, prompts, b) {
|
|
382
|
+
const tools = Object.keys(TOOLS).filter(t => state.tool === 'all' || t === state.tool);
|
|
383
|
+
$('legend').innerHTML = tools.map(t => `<span><i style="background:var(--${t})"></i>${TOOLS[t]}</span>`).join('');
|
|
384
|
+
const { keys, keyOf, hourly } = buckets(b);
|
|
385
|
+
const byKey = new Map(keys.map(k => [k.getTime(), Object.fromEntries(tools.map(t => [t, 0]))]));
|
|
386
|
+
const src = state.metric === 'prompts' ? prompts : recs;
|
|
387
|
+
const val = state.metric === 'tokens' ? total : state.metric === 'cost' ? (r => r.cost || 0) : (() => 1);
|
|
388
|
+
for (const r of src) { const k = keyOf(r.ts); if (byKey.has(k) && r.tool in byKey.get(k)) byKey.get(k)[r.tool] += val(r); }
|
|
389
|
+
const rows = keys.map(k => ({ k, v: byKey.get(k.getTime()) }));
|
|
390
|
+
const tot = r => tools.reduce((a, t) => a + r.v[t], 0);
|
|
391
|
+
const max = Math.max(1e-9, ...rows.map(tot));
|
|
392
|
+
const W = $('chartWrap').clientWidth || 800, H = 260, padL = 44, padR = 8, padT = 10, padB = 26;
|
|
393
|
+
const iw = W - padL - padR, ih = H - padT - padB;
|
|
394
|
+
const n = rows.length, gap = n > 60 ? 1 : 2, bw = Math.max(1, iw / n - gap);
|
|
395
|
+
const fmtV = v => state.metric === 'cost' ? usd(v) : fmt(v);
|
|
396
|
+
const y = v => padT + ih - (v / max) * ih;
|
|
397
|
+
const ticks = [0, .25, .5, .75, 1].map(f => f * max);
|
|
398
|
+
const lab = k => hourly ? k.getHours().toString().padStart(2, '0') + ':00' : (k.getDate() === 1 || n <= 14 || k.getTime() === keys[0].getTime() ? k.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : k.getDate());
|
|
399
|
+
const every = Math.ceil(n / Math.max(4, Math.floor(iw / 60)));
|
|
400
|
+
let svg = `<g class="grid">${ticks.map(t => `<line x1="${padL}" x2="${W - padR}" y1="${y(t)}" y2="${y(t)}"/><text x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${fmtV(t)}</text>`).join('')}</g>`;
|
|
401
|
+
svg += `<g class="axis"><line x1="${padL}" x2="${W - padR}" y1="${padT + ih}" y2="${padT + ih}"/></g>`;
|
|
402
|
+
rows.forEach((r, i) => {
|
|
403
|
+
const x = padL + i * (iw / n) + gap / 2;
|
|
404
|
+
let top = padT + ih;
|
|
405
|
+
svg += `<g class="col" data-i="${i}">`;
|
|
406
|
+
tools.forEach((t, j) => {
|
|
407
|
+
const h = (r.v[t] / max) * ih; if (!h) return;
|
|
408
|
+
const isTop = tools.slice(j + 1).every(u => !r.v[u]);
|
|
409
|
+
top -= h + (top < padT + ih ? 2 : 0);
|
|
410
|
+
svg += `<rect class="bar" x="${x}" y="${Math.max(padT, top)}" width="${bw}" height="${h}" rx="${isTop ? Math.min(4, bw / 2) : 0}" fill="var(--${t})"/>`;
|
|
411
|
+
});
|
|
412
|
+
svg += `<rect class="hit" x="${x - gap / 2}" y="${padT}" width="${bw + gap}" height="${ih}"/>`;
|
|
413
|
+
if (i % every === 0) svg += `<text x="${x + bw / 2}" y="${H - 8}" text-anchor="middle">${lab(r.k)}</text>`;
|
|
414
|
+
svg += '</g>';
|
|
415
|
+
});
|
|
416
|
+
const el = $('chart'); el.setAttribute('viewBox', `0 0 ${W} ${H}`); el.innerHTML = svg;
|
|
417
|
+
const tip = $('tip');
|
|
418
|
+
el.querySelectorAll('g.col').forEach(g => {
|
|
419
|
+
g.onmousemove = ev => {
|
|
420
|
+
const r = rows[+g.dataset.i];
|
|
421
|
+
const rect = $('chartWrap').getBoundingClientRect();
|
|
422
|
+
tip.style.left = (ev.clientX - rect.left) + 'px'; tip.style.top = (ev.clientY - rect.top) + 'px'; tip.style.opacity = 1;
|
|
423
|
+
const label = hourly ? r.k.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }) : r.k.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
424
|
+
tip.innerHTML = `<b>${label}</b>` + tools.map(t => `<div class="row"><span><i style="background:var(--${t})"></i>${TOOLS[t]}</span><b>${fmtV(r.v[t])}</b></div>`).join('') + `<div class="row" style="margin-top:4px;opacity:.8"><span>Total</span><b>${fmtV(tot(r))}</b></div>`;
|
|
425
|
+
};
|
|
426
|
+
g.onmouseleave = () => tip.style.opacity = 0;
|
|
427
|
+
});
|
|
428
|
+
$('chartTable').innerHTML = `<table><tr><th>${hourly ? 'Hour' : 'Day'}</th>${tools.map(t => `<th class="n">${TOOLS[t]}</th>`).join('')}<th class="n">Total</th></tr>${rows.filter(tot).map(r => `<tr><td>${hourly ? r.k.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : r.k.toLocaleDateString()}</td>${tools.map(t => `<td class="n">${fmtV(r.v[t])}</td>`).join('')}<td class="n">${fmtV(tot(r))}</td></tr>`).join('')}</table>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function renderMix(recs) {
|
|
432
|
+
const P = state.data.pricing;
|
|
433
|
+
const priced = recs.filter(r => P[r.model]);
|
|
434
|
+
const parts = [['Input', r => r.in, r => r.in * P[r.model].input, 'var(--mix-1)'], ['Cache read', r => r.cr, r => r.cr * P[r.model].cache_read, 'var(--mix-2)'], ['Cache write', r => r.cw5 + r.cw1h, r => r.cw5 * P[r.model].cache_write_5m + r.cw1h * P[r.model].cache_write_1h, 'var(--mix-3)'], ['Output', r => r.out, r => r.out * P[r.model].output, 'var(--mix-4)']]
|
|
435
|
+
.map(([name, f, g, c]) => ({ name, c, v: sum(recs, f), usd: sum(priced, g) / 1e6 }));
|
|
436
|
+
const bar = (id, listId, key, fmtV) => {
|
|
437
|
+
const t = Math.max(1e-9, sum(parts, p => p[key]));
|
|
438
|
+
$(id).innerHTML = parts.map(p => `<div title="${p.name} ${fmtV(p[key])}" style="width:${(p[key] / t * 100).toFixed(2)}%;background:${p.c}"></div>`).join('');
|
|
439
|
+
$(listId).innerHTML = parts.map(p => `<div><i style="background:${p.c}"></i>${p.name}<b>${fmtV(p[key])} <span class="muted">${pct(p[key] / t)}</span></b></div>`).join('');
|
|
440
|
+
};
|
|
441
|
+
bar('mix', 'mixList', 'v', fmt);
|
|
442
|
+
bar('costMix', 'costMixList', 'usd', usd);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const promptCols = () => [
|
|
446
|
+
{ k: 'ts', label: 'Time', cls: 'mono', fmt: when },
|
|
447
|
+
{ k: 'tool', label: 'Tool', fmt: toolDot },
|
|
448
|
+
{ k: 'project', label: 'Project', cls: 'trunc', title: r => r.project, fmt: short },
|
|
449
|
+
{ k: 'text', label: 'Prompt', cls: 'wrap', fmt: v => v ? esc(v.replace(/\s+/g, ' ').slice(0, 110)) + (v.length > 110 ? '…' : '') : '<span class="muted">context-only turn, no prompt text</span>' },
|
|
450
|
+
{ k: 'turns', label: 'Turns', n: 1 },
|
|
451
|
+
{ k: 'tokens', label: 'Tokens', n: 1, fmt: fmt },
|
|
452
|
+
{ k: 'cost', label: 'Cost', n: 1, fmt: costCell },
|
|
453
|
+
{ k: 'dur', label: 'Took', n: 1, fmt: ms => ms < 1000 ? '' : ms < 60000 ? Math.round(ms / 1000) + 's' : Math.round(ms / 60000) + 'm' },
|
|
454
|
+
];
|
|
455
|
+
const resumeCmd = p => ({ claude: `claude --resume ${p.session}`, codex: `codex resume ${p.session}`, copilot: `copilot --resume ${p.session}` })[p.tool];
|
|
456
|
+
const promptExtra = cols => g => `<tr class="pfull ${state.open.has(g.p.id) ? '' : 'hide'}"><td colspan="${cols}"><div class="full">${esc(g.text) || '<span class="muted">No plain-text prompt was recorded for this turn.</span>'}</div><div class="muted" style="margin-top:8px">Resume this session: <code>${esc(resumeCmd(g.p))}</code> · prompt id <code>${esc(g.p.id)}</code>${g.p.user !== state.data.me ? ` · by <b>${esc(g.p.user)}</b>` : ''}</div></td></tr>`;
|
|
457
|
+
function wireExpand(id, rows) {
|
|
458
|
+
$(id).querySelectorAll('tr.prow').forEach((tr, i) => tr.onclick = () => {
|
|
459
|
+
const pid = rows[i].p.id;
|
|
460
|
+
state.open.has(pid) ? state.open.delete(pid) : state.open.add(pid);
|
|
461
|
+
tr.nextElementSibling.classList.toggle('hide', !state.open.has(pid));
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function renderPrompts(groups, totalPrompts) {
|
|
465
|
+
$('promptCount').textContent = groups.length ? `· ${Math.min(150, groups.length)} of ${totalPrompts.toLocaleString()}` : '';
|
|
466
|
+
wireExpand('prompts', table('prompts', promptCols(), groups, { sort: 'ts', limit: 150, attrs: () => 'class="prow"', extra: promptExtra(8), empty: 'No prompts in this range' }));
|
|
467
|
+
}
|
|
468
|
+
function renderExpensive(groups) {
|
|
469
|
+
const cols = promptCols().filter(c => ['ts', 'tool', 'text', 'turns', 'tokens', 'cost'].includes(c.k));
|
|
470
|
+
wireExpand('expensive', table('expensive', cols, groups.filter(g => g.cost > 0), { sort: 'cost', limit: 10, attrs: () => 'class="prow"', extra: promptExtra(6), empty: 'No priced prompts in this range' }));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function renderMisses(recs, owner) {
|
|
474
|
+
const P = state.data.pricing;
|
|
475
|
+
const bySession = new Map();
|
|
476
|
+
for (const r of recs) { if (r.sub) continue; if (!bySession.has(r.session)) bySession.set(r.session, []); bySession.get(r.session).push(r); }
|
|
477
|
+
const misses = [];
|
|
478
|
+
for (const list of bySession.values()) {
|
|
479
|
+
for (let i = 1; i < list.length; i++) {
|
|
480
|
+
const prev = list[i - 1], r = list[i], cw = r.cw5 + r.cw1h;
|
|
481
|
+
if (prev.cr > 50000 && r.cr < prev.cr * 0.5 && cw > 20000) {
|
|
482
|
+
const p = P[r.model], g = owner.get(r);
|
|
483
|
+
misses.push({ ts: r.ts, tool: r.tool, lost: prev.cr - r.cr, rewritten: cw, cost: p ? (r.cw5 * p.cache_write_5m + r.cw1h * p.cache_write_1h) / 1e6 : 0, priced: !!p, text: g ? g.text : '', project: r.project, p: g && g.p, g });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
$('missHint').textContent = misses.length ? `${misses.length} misses · ${usd(sum(misses, m => m.cost))} spent re-writing cache` : 'none detected in this range';
|
|
488
|
+
const cols = [{ k: 'ts', label: 'Time', cls: 'mono', fmt: when }, { k: 'tool', label: 'Tool', fmt: toolDot }, { k: 'text', label: 'Prompt in progress', cls: 'wrap', fmt: (v, r) => v ? esc(v.replace(/\s+/g, ' ').slice(0, 80)) : `<span class="muted">${esc(short(r.project))}</span>` }, { k: 'lost', label: 'Cache lost', n: 1, fmt: fmt }, { k: 'rewritten', label: 'Re-written', n: 1, fmt: fmt }, { k: 'cost', label: 'Cost', n: 1, fmt: costCell }];
|
|
489
|
+
const rows = table('misses', cols, misses, { sort: 'cost', limit: 12, attrs: r => r.g ? 'class="prow"' : '', extra: r => r.g ? promptExtra(6)(r.g) : '', empty: 'No cache misses in this range. A miss is a turn where the cached prefix shrank by half and a large prefix was re-written, usually after editing history or a long idle gap.' });
|
|
490
|
+
wireExpand('misses', rows.filter(r => r.g));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function renderCommits(recs) {
|
|
494
|
+
const rows = [];
|
|
495
|
+
for (const [project, commits] of Object.entries(state.commits)) {
|
|
496
|
+
if (!commits.length) continue;
|
|
497
|
+
const sorted = [...commits].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
498
|
+
const groups = new Map();
|
|
499
|
+
for (const r of recs.filter(r => r.project === project && r.user === state.data.me)) {
|
|
500
|
+
const c = sorted.find(c => c.ts >= r.ts) || { sha: 'uncommitted', ts: '', subject: 'Not committed yet' };
|
|
501
|
+
const g = groups.get(c.sha) || { sha: c.sha, subject: c.subject, ts: c.ts || r.ts, project: short(project), turns: 0, tokens: 0, cost: 0, priced: true };
|
|
502
|
+
g.turns++; g.tokens += total(r); g.cost += r.cost || 0; if (r.cost == null) g.priced = false; groups.set(c.sha, g);
|
|
503
|
+
}
|
|
504
|
+
rows.push(...groups.values());
|
|
505
|
+
}
|
|
506
|
+
const cols = [{ k: 'subject', label: 'Commit', cls: 'trunc commit', title: r => `${r.sha}\n${r.subject}`, fmt: (v, r) => `${r.sha === 'uncommitted' ? '' : `<span class="mono muted">${r.sha.slice(0, 7)}</span> `}${esc(v.slice(0, 44))}${v.length > 44 ? '…' : ''}` }, { k: 'project', label: 'Repo', cls: 'trunc repo', fmt: v => esc(v.split('/').pop()), title: r => r.project }, { k: 'ts', label: 'Date', cls: 'mono', fmt: v => new Date(v).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }, { k: 'turns', label: 'Turns', n: 1 }, { k: 'tokens', label: 'Tokens', n: 1, fmt: fmt }, { k: 'cost', label: 'Cost', n: 1, fmt: costCell }];
|
|
507
|
+
table('commits', cols, rows, { sort: 'cost', limit: 15, empty: Object.keys(state.commits).length ? 'No turns in this range fall inside a git repository' : 'None of the projects with usage is a git repository on this machine' });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function renderFeed(recs) {
|
|
511
|
+
const rows = recs.slice(-80).map(r => Object.assign({ cw: r.cw5 + r.cw1h, id: r.ts + r.session + r.out }, r));
|
|
512
|
+
$('feedCount').textContent = rows.length ? `· latest ${rows.length} of ${recs.length.toLocaleString()} turns` : '';
|
|
513
|
+
const cols = [{ k: 'ts', label: 'Time', cls: 'mono', fmt: v => new Date(v).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) }, { k: 'tool', label: 'Tool', fmt: (v, r) => toolDot(v) + (r.sub ? ' <span class="muted">sub</span>' : '') }, { k: 'model', label: 'Model', cls: 'mono' }, { k: 'project', label: 'Project', cls: 'trunc', title: r => r.project, fmt: short }, { k: 'in', label: 'Input', n: 1, fmt: fmt }, { k: 'cr', label: 'Cache read', n: 1, fmt: fmt }, { k: 'cw', label: 'Cache write', n: 1, fmt: fmt }, { k: 'out', label: 'Output', n: 1, fmt: fmt }, { k: 'cost', label: 'Cost', n: 1, fmt: v => v == null ? '<span class="muted">–</span>' : usd(v) }];
|
|
514
|
+
table('feed', cols, rows, { sort: 'ts', attrs: r => { const isNew = state.seen.size && !state.seen.has(r.id); state.seen.add(r.id); return isNew ? 'class="new"' : ''; }, empty: 'No turns yet. Send a prompt in Claude Code, Codex or Copilot and it will appear here.' });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function fillSelects() {
|
|
518
|
+
const d = state.data;
|
|
519
|
+
const opts = (sel, values, cur, label) => { sel.innerHTML = sel.options[0].outerHTML + values.map(v => `<option value="${esc(v)}" ${v === cur ? 'selected' : ''}>${esc(label ? label(v) : v)}</option>`).join(''); };
|
|
520
|
+
const count = f => [...d.records.reduce((m, r) => m.set(f(r), (m.get(f(r)) || 0) + total(r)), new Map()).entries()].sort((a, b) => b[1] - a[1]).map(e => e[0]);
|
|
521
|
+
opts($('project'), count(r => r.project), state.project, short);
|
|
522
|
+
opts($('model'), count(r => r.model), state.model);
|
|
523
|
+
$('user').classList.toggle('hide', d.users.length < 2);
|
|
524
|
+
opts($('user'), d.users, state.user, u => u === d.me ? `${u} (me)` : u);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function load() {
|
|
528
|
+
try {
|
|
529
|
+
const v = await (await fetch('/api/version')).json();
|
|
530
|
+
if (v.version === state.version) return setLive(true);
|
|
531
|
+
const d = await (await fetch('/api/usage')).json();
|
|
532
|
+
state.data = d; state.version = d.version;
|
|
533
|
+
fetch('/api/commits').then(r => r.json()).then(c => { state.commits = c; render(); }).catch(() => {});
|
|
534
|
+
fillSelects(); render();
|
|
535
|
+
$('firstRun').classList.toggle('hide', d.records.length > 0);
|
|
536
|
+
$('frClaude').textContent = d.sources.claude + '/projects'; $('frCodex').textContent = d.sources.codex + '/sessions'; $('frCopilot').textContent = d.sources.copilot;
|
|
537
|
+
$('srcClaude').innerHTML = `Claude Code: <code>${esc(d.sources.claude)}/projects</code>`;
|
|
538
|
+
$('srcCodex').innerHTML = `Codex: <code>${esc(d.sources.codex)}/sessions</code>`;
|
|
539
|
+
$('srcCopilot').innerHTML = `Copilot: <code>${esc(d.sources.copilot)}</code>`;
|
|
540
|
+
$('scanned').textContent = `${d.records.length.toLocaleString()} turns indexed · updated ${new Date(d.generated * 1000).toLocaleTimeString()}`;
|
|
541
|
+
setLive(true);
|
|
542
|
+
} catch (e) { setLive(false); }
|
|
543
|
+
}
|
|
544
|
+
function setLive(ok) { $('live').classList.toggle('stale', !ok); $('liveText').textContent = ok ? 'live' : 'server unreachable'; }
|
|
545
|
+
function readHash() {
|
|
546
|
+
const h = new URLSearchParams(location.hash.slice(1));
|
|
547
|
+
for (const k of ['range', 'tool', 'metric', 'project', 'model', 'user']) if (h.get(k)) state[k] = h.get(k);
|
|
548
|
+
for (const id of ['range', 'tool', 'metric']) $(id).querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.v === state[id]));
|
|
549
|
+
if (h.get('theme')) applyTheme(h.get('theme'));
|
|
550
|
+
if (h.get('view')) setTimeout(() => $(h.get('view'))?.scrollIntoView(), 300);
|
|
551
|
+
}
|
|
552
|
+
function writeHash() {
|
|
553
|
+
const h = new URLSearchParams();
|
|
554
|
+
for (const k of ['range', 'tool', 'metric', 'project', 'model', 'user']) if (state[k] && state[k] !== DEFAULTS[k]) h.set(k, state[k]);
|
|
555
|
+
history.replaceState(null, '', h.toString() ? '#' + h : location.pathname);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const id of ['range', 'tool', 'metric']) $(id).addEventListener('click', e => {
|
|
559
|
+
const btn = e.target.closest('button'); if (!btn) return;
|
|
560
|
+
$(id).querySelectorAll('button').forEach(b => b.classList.toggle('on', b === btn));
|
|
561
|
+
state[id] = btn.dataset.v; writeHash(); render();
|
|
562
|
+
});
|
|
563
|
+
for (const id of ['project', 'model', 'user']) $(id).onchange = e => { state[id] = e.target.value; writeHash(); render(); };
|
|
564
|
+
$('tableToggle').onclick = () => { state.table = !state.table; $('chartWrap').classList.toggle('hide', state.table); $('chartTable').classList.toggle('hide', !state.table); $('tableToggle').textContent = state.table ? 'Chart' : 'Table'; };
|
|
565
|
+
const applyTheme = t => { document.documentElement.dataset.theme = t; try { localStorage.setItem('tm-theme', t); } catch (e) {} };
|
|
566
|
+
$('theme').onclick = () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
|
|
567
|
+
let saved = null; try { saved = localStorage.getItem('tm-theme'); } catch (e) {}
|
|
568
|
+
applyTheme(saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
|
|
569
|
+
addEventListener('resize', () => state.data && render());
|
|
570
|
+
readHash(); load(); setInterval(load, 4000);
|
|
571
|
+
</script>
|
|
572
|
+
</body>
|
|
573
|
+
</html>
|