Fix cumulative token accounting and live refresh
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
A detailed, local-first dashboard for Codex usage. It reads `~/.codex/sessions/**/*.jsonl` directly and never uploads session data.
|
||||
|
||||
Usage is derived from Codex's cumulative session counters—the same values used by the CLI exit summary—and active logs are rescanned every 30 seconds.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -58,4 +58,4 @@ function renderChart(){
|
||||
$('#axisNote').textContent='100% composition per active day · output remains visible at a minimum 1px';
|
||||
}
|
||||
}
|
||||
for(const id of ['from','to','project','model','source']) $('#'+id).addEventListener('change',()=>{document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.remove('active'));load()});document.querySelectorAll('.quick-ranges button').forEach(b=>b.onclick=()=>{const n=b.dataset.days,today=new Date(),local=d=>{const z=new Date(d.getTime()-d.getTimezoneOffset()*6e4);return z.toISOString().slice(0,10)};$('#to').value=n==='all'?'':local(today);if(n==='all')$('#from').value='';else{const from=new Date(today);from.setDate(from.getDate()-Number(n)+(Number(n)>0?1:0));$('#from').value=local(from)}document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.toggle('active',x===b));load()});$('#projectSearch').addEventListener('input',renderProjects);$('#projectSort').addEventListener('change',renderProjects);document.querySelectorAll('.chart-mode').forEach(b=>b.onclick=()=>{chartMode=b.dataset.mode;document.querySelectorAll('.chart-mode').forEach(x=>x.classList.toggle('active',x===b));renderChart()});$('#clear').onclick=()=>{for(const id of ['from','to','project','model','source'])$('#'+id).value='';document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.toggle('active',x.dataset.days==='all'));load()};$('#rescan').onclick=async()=>{$('#rescan').disabled=true;$('#rescan').textContent='Scanning…';await fetch('/api/rescan',{method:'POST'});$('#rescan').disabled=false;$('#rescan').textContent='↻ Rescan data';load()};load();
|
||||
for(const id of ['from','to','project','model','source']) $('#'+id).addEventListener('change',()=>{document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.remove('active'));load()});document.querySelectorAll('.quick-ranges button').forEach(b=>b.onclick=()=>{const n=b.dataset.days,today=new Date(),local=d=>{const z=new Date(d.getTime()-d.getTimezoneOffset()*6e4);return z.toISOString().slice(0,10)};$('#to').value=n==='all'?'':local(today);if(n==='all')$('#from').value='';else{const from=new Date(today);from.setDate(from.getDate()-Number(n)+(Number(n)>0?1:0));$('#from').value=local(from)}document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.toggle('active',x===b));load()});$('#projectSearch').addEventListener('input',renderProjects);$('#projectSort').addEventListener('change',renderProjects);document.querySelectorAll('.chart-mode').forEach(b=>b.onclick=()=>{chartMode=b.dataset.mode;document.querySelectorAll('.chart-mode').forEach(x=>x.classList.toggle('active',x===b));renderChart()});$('#clear').onclick=()=>{for(const id of ['from','to','project','model','source'])$('#'+id).value='';document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.toggle('active',x.dataset.days==='all'));load()};$('#rescan').onclick=async()=>{$('#rescan').disabled=true;$('#rescan').textContent='Scanning…';await fetch('/api/rescan',{method:'POST'});$('#rescan').disabled=false;$('#rescan').textContent='↻ Rescan data';load()};load();setInterval(()=>{if(!document.hidden)load()},30_000);
|
||||
|
||||
@@ -33,14 +33,20 @@ async function files(dir) {
|
||||
|
||||
async function parse(file) {
|
||||
let session={ id:path.basename(file,'.jsonl'), started:'', cwd:'unknown', project:'unknown', source:'unknown', models:new Set(), usage:empty(), events:[] };
|
||||
let model='unknown';
|
||||
let model='unknown', previousTotal=null;
|
||||
const rl=readline.createInterface({input:fs.createReadStream(file),crlfDelay:Infinity});
|
||||
for await (const line of rl) try {
|
||||
const x=JSON.parse(line), p=x.payload||{};
|
||||
if(x.type==='session_meta'){ session.id=p.id||session.id; session.started=p.timestamp||x.timestamp; session.cwd=p.cwd||'unknown'; session.project=path.basename(session.cwd)||session.cwd; session.source=typeof p.source==='string'?p.source:(p.source?.subagent?'subagent':'app'); }
|
||||
if(x.type==='turn_context' && p.model){ model=p.model; session.models.add(model); }
|
||||
if(x.type==='event_msg' && p.type==='token_count' && p.info?.last_token_usage){
|
||||
const t=p.info.last_token_usage, u={input:t.input_tokens||0,cached:t.cached_input_tokens||0,output:t.output_tokens||0,reasoning:t.reasoning_output_tokens||0,total:t.total_tokens||0,turns:0,duration:0};
|
||||
if(x.type==='event_msg' && p.type==='token_count' && (p.info?.total_token_usage||p.info?.last_token_usage)){
|
||||
// `total_token_usage` is Codex's authoritative session counter and is what
|
||||
// the CLI prints on exit. Deriving deltas from it avoids double-counting
|
||||
// occasional replayed/revised `last_token_usage` records.
|
||||
const t=p.info.total_token_usage||p.info.last_token_usage, cumulative=!!p.info.total_token_usage;
|
||||
const value=k=>Math.max(0,(t[k]||0)-(cumulative&&previousTotal?(previousTotal[k]||0):0));
|
||||
const u={input:value('input_tokens'),cached:value('cached_input_tokens'),output:value('output_tokens'),reasoning:value('reasoning_output_tokens'),total:value('total_tokens'),turns:0,duration:0};
|
||||
if(cumulative) previousTotal=t;
|
||||
u.cost=cost(u,model); u.priced=prices[model]?u.total:0; add(session.usage,u); session.events.push({at:x.timestamp||session.started,model,...u});
|
||||
}
|
||||
if(x.type==='event_msg' && p.type==='task_complete'){ session.usage.turns++; session.usage.duration+=(p.duration_ms||0); }
|
||||
@@ -48,8 +54,12 @@ async function parse(file) {
|
||||
session.models=[...session.models]; return session;
|
||||
}
|
||||
|
||||
let cache={loading:true,scannedAt:null,sessions:[],error:null};
|
||||
async function scan(){ cache.loading=true; try { const list=await files(SESSIONS); const sessions=[]; for(let i=0;i<list.length;i+=12) sessions.push(...await Promise.all(list.slice(i,i+12).map(parse))); cache={loading:false,scannedAt:new Date().toISOString(),sessions,error:null}; } catch(e){cache={...cache,loading:false,error:e.message};} }
|
||||
let cache={loading:true,scannedAt:null,sessions:[],error:null}, scanPromise=null;
|
||||
async function scan(){
|
||||
if(scanPromise)return scanPromise;
|
||||
scanPromise=(async()=>{cache.loading=true;try{const list=await files(SESSIONS);const sessions=[];for(let i=0;i<list.length;i+=12)sessions.push(...await Promise.all(list.slice(i,i+12).map(parse)));cache={loading:false,scannedAt:new Date().toISOString(),sessions,error:null};}catch(e){cache={...cache,loading:false,error:e.message};}finally{scanPromise=null;}})();
|
||||
return scanPromise;
|
||||
}
|
||||
|
||||
function stats(url){
|
||||
const from=url.searchParams.get('from'), to=url.searchParams.get('to'), modelFilter=url.searchParams.get('model'), projectFilter=url.searchParams.get('project'), sourceFilter=url.searchParams.get('source');
|
||||
@@ -81,4 +91,4 @@ const server=http.createServer(async(req,res)=>{
|
||||
const types={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8'};
|
||||
send(res,200,fs.readFileSync(file),types[path.extname(file)]||'application/octet-stream');
|
||||
});
|
||||
scan().then(()=>server.listen(PORT,'127.0.0.1',()=>console.log(`Codex Lens → http://127.0.0.1:${PORT}`)));
|
||||
scan().then(()=>{server.listen(PORT,'127.0.0.1',()=>console.log(`Codex Lens → http://127.0.0.1:${PORT}`));setInterval(scan,30_000).unref();});
|
||||
|
||||
Reference in New Issue
Block a user