add more statistics

This commit is contained in:
2026-07-11 16:44:45 +02:00
parent 8b89b59f9c
commit 55172d747e
6 changed files with 54 additions and 16 deletions
+22 -7
View File
@@ -1,34 +1,49 @@
const $=s=>document.querySelector(s), fmt=n=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(n||0), money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:n<10?2:0}).format(n||0), pct=n=>`${(n||0).toFixed(1)}%`;
const $=s=>document.querySelector(s), fmt=n=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(n||0), fmt2=n=>new Intl.NumberFormat('en',{notation:'compact',minimumFractionDigits:2,maximumFractionDigits:2}).format(n||0), money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:n<10?2:0}).format(n||0), pct=n=>`${(n||0).toFixed(1)}%`;
let data, chartMode='models';
const palette=['#7357ff','#20a4a8','#ff805d','#e4b63f','#4b8cff','#d45bba','#72a33a','#9b6b43','#32343a','#a5a2ff','#19b875','#e85d5d'];
const modelColor=name=>palette[Math.max(0,data.models.findIndex(x=>x.name===name))%palette.length];
async function load(){
const q=new URLSearchParams(); for(const id of ['from','to','project','model']) if($('#'+id).value) q.set(id,$('#'+id).value);
const q=new URLSearchParams(); for(const id of ['from','to','project','model','source']) if($('#'+id).value) q.set(id,$('#'+id).value);
$('#subtitle').textContent='Reading local session history…';
try{data=await fetch('/api/stats?'+q).then(r=>r.json());if(data.meta?.schemaVersion!==2)throw new Error('The Codex Lens server is outdated. Stop it and run npm start again.');render()}catch(e){$('#subtitle').innerHTML=`<span class="error">${e.message}</span>`}
}
function options(el,values,label){const old=el.value;el.innerHTML=`<option value="">All ${label}</option>`+values.map(x=>`<option>${esc(x)}</option>`).join('');el.value=old}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function render(){
const {total:t,meta}=data; options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models');
const {total:t,meta}=data; options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models');options($('#source'),data.filters.sources||[],'sources');
$('#subtitle').textContent=`${meta.files} sessions · ${meta.events.toLocaleString()} model calls · scanned ${new Date(meta.scannedAt).toLocaleTimeString()}`;
$('#cost').textContent=money(t.cost);const coverage=t.total?t.priced/t.total*100:0;$('#coverage').textContent=`${pct(coverage)} token pricing coverage · standard API rates`;
$('#tokens').textContent=fmt(t.total);$('#tokenMix').textContent=`${fmt(t.input)} input · ${fmt(t.output)} output`;
const uncached=Math.max(0,t.input-t.cached);
$('#tokens').textContent=fmt2(t.total);$('#tokenMix').textContent=`${fmt2(uncached)} normal input · ${fmt2(t.cached)} cached · ${fmt2(t.output)} output`;
const cr=t.input?t.cached/t.input*100:0;$('#cache').textContent=pct(cr);$('#cacheSaved').textContent=`${fmt(t.cached)} cached input tokens`;
const matchedSessions=data.meta.matchedSessions??data.meta.files??data.sessions.length;$('#activity').textContent=`${matchedSessions} / ${t.turns.toLocaleString()}`;$('#duration').textContent=`${(t.duration/36e5).toFixed(1)} active task hours`;
const uncached=Math.max(0,t.input-t.cached), peak=data.days.reduce((a,d)=>d.total>(a?.total||0)?d:a,null);
const peak=data.days.reduce((a,d)=>d.total>(a?.total||0)?d:a,null);
$('#uncached').textContent=fmt(uncached);$('#uncachedShare').textContent=`${pct(t.input?uncached/t.input*100:0)} of input`;
$('#reasoning').textContent=fmt(t.reasoning);$('#reasoningShare').textContent=`${pct(t.output?t.reasoning/t.output*100:0)} of output`;
$('#avgSession').textContent=fmt(t.total/(matchedSessions||1));$('#avgCost').textContent=`${money(t.cost/(matchedSessions||1))} API-equiv.`;
$('#peakDay').textContent=peak?new Date(peak.name+'T12:00:00').toLocaleDateString(undefined,{month:'short',day:'numeric'}):'—';$('#peakTokens').textContent=peak?`${fmt(peak.total)} tokens`:'No activity';
$('#activeDays').textContent=data.days.length;$('#streak').textContent=`Longest streak ${longestStreak(data.days)} days`;
$('#modelCount').textContent=data.models.length;$('#topModel').textContent=data.models[0]?`${data.models[0].name} leads`:'No models';
const daily=data.days.map(x=>x.total).sort((a,b)=>a-b), median=daily.length?(daily[Math.floor((daily.length-1)/2)]+daily[Math.ceil((daily.length-1)/2)])/2:0;
$('#avgCall').textContent=fmt(t.total/(meta.events||1));$('#callsPerDay').textContent=`${(meta.events/(data.days.length||1)).toFixed(1)} calls / active day`;
$('#medianDay').textContent=fmt(median);$('#dailyAverage').textContent=`${fmt(t.total/(data.days.length||1))} daily average`;
$('#outputRate').textContent=pct(t.total?t.output/t.total*100:0);
const cacheSaving=data.models.reduce((sum,m)=>{const p=data.prices[m.name];return sum+(p?m.cached*(p.input-p.cached)/1e6:0)},0);$('#cacheSaving').textContent=money(cacheSaving);
renderChart();
const mm=Math.max(...data.models.map(x=>x.total),1);$('#modelBars').innerHTML='<div class="bar-legend"><span class="shade-light"></span>Uncached input <span class="shade-base"></span>Cached input <span class="shade-dark"></span>Output</div>'+data.models.slice(0,9).map(m=>{const base=modelColor(m.name),uncached=Math.max(0,m.input-m.cached),sum=uncached+m.cached+m.output||1,tip=[m.name,`Uncached input ${fmt(uncached)} (${pct(uncached/sum*100)})`,`Cached input ${fmt(m.cached)} (${pct(m.cached/sum*100)})`,`Output ${fmt(m.output)} (${pct(m.output/sum*100)})`,`Reasoning output ${fmt(m.reasoning)}`,`Cache hit rate ${pct(m.input?m.cached/m.input*100:0)}`,`API-equivalent cost ${money(m.cost)}`].join('&#10;');return `<div class="model-row"><span class="model-pill" style="color:${base};background:color-mix(in srgb,${base} 12%,white)">${esc(m.name)}</span><div class="bar" data-tip="${tip}"><div class="bar-fill" style="width:${m.total/mm*100}%;--model:${base}"><i class="uncached-part" style="width:${uncached/sum*100}%"></i><i class="cached-part" style="width:${m.cached/sum*100}%"></i><i class="output-part" style="width:${m.output/sum*100}%"></i></div></div><b>${fmt(m.total)}</b></div>`}).join('');
const mixSum=uncached+t.cached+t.output||1, a=uncached/mixSum*100,b=t.cached/mixSum*100;$('#donut').style.background=`conic-gradient(var(--purple) 0 ${a}%,var(--blue) ${a}% ${a+b}%,var(--orange) ${a+b}% 100%)`;$('#donutTotal').textContent=fmt(t.total);$('#mixLegend').innerHTML=[['Uncached input',uncached,'var(--purple)'],['Cached input',t.cached,'var(--blue)'],['Output',t.output,'var(--orange)'],['Reasoning output',t.reasoning,'#222']].map(x=>`<div class="mix-item"><i style="background:${x[2]}"></i>${x[0]} <b>${fmt(x[1])}</b></div>`).join('');
$('#projectsBody').innerHTML=data.projects.slice(0,30).map(x=>`<tr><td>${esc(x.name)}</td><td>${fmt(x.total)}</td><td>${fmt(x.input)}</td><td>${fmt(x.output)}</td><td>${pct(x.input?x.cached/x.input*100:0)}</td><td>${money(x.cost)}</td><td>${x.turns}</td></tr>`).join('');
renderProjects();
$('#sessionsBody').innerHTML=data.sessions.map(x=>`<tr><td>${new Date(x.started).toLocaleDateString()}</td><td>${esc(x.project)}</td><td><span class="model-pill">${esc(x.model||'unknown')}</span></td><td>${fmt(x.total)}</td><td>${fmt(x.cached)}</td><td>${fmt(x.output)}</td><td>${money(x.cost)}</td></tr>`).join('');
}
function renderProjects(){
const max=Math.max(...data.projects.map(x=>x.total),1), top=data.projects.slice(0,10);
$('#projectChart').innerHTML=top.map((x,i)=>`<button class="project-bar" data-project="${esc(x.name)}" title="Filter dashboard to ${esc(x.name)}"><span>${esc(x.name)}</span><i><b style="width:${x.total/max*100}%;background:${palette[i%palette.length]}"></b></i><strong>${fmt(x.total)}</strong><small>${money(x.cost)}</small></button>`).join('')||'<p>No project activity in this range</p>';
document.querySelectorAll('.project-bar').forEach(b=>b.onclick=()=>{$('#project').value=b.dataset.project;load();scrollTo({top:0,behavior:'smooth'})});
const query=$('#projectSearch').value.toLowerCase(), sort=$('#projectSort').value;
const rows=data.projects.filter(x=>x.name.toLowerCase().includes(query)).sort((a,b)=>sort==='cost'?b.cost-a.cost:sort==='cache'?(b.cached/(b.input||1))-(a.cached/(a.input||1)):sort==='output'?b.output-a.output:b.total-a.total);
$('#projectsBody').innerHTML=rows.slice(0,100).map(x=>`<tr class="clickable" data-project="${esc(x.name)}"><td>${esc(x.name)}</td><td>${fmt(x.total)}</td><td>${fmt(x.input)}</td><td>${fmt(x.output)}</td><td>${pct(x.input?x.cached/x.input*100:0)}</td><td>${money(x.cost)}</td><td>${x.turns}</td></tr>`).join('');
document.querySelectorAll('#projectsBody tr').forEach(r=>r.onclick=()=>{$('#project').value=r.dataset.project;load();scrollTo({top:0,behavior:'smooth'})});
}
function longestStreak(days){let best=0,run=0,prev=0;for(const d of days){const n=new Date(d.name+'T00:00:00Z').getTime()/864e5;run=n===prev+1?run+1:1;best=Math.max(best,run);prev=n}return best}
function renderChart(){
const max=Math.max(...data.days.map(x=>x.total),1), chart=$('#chart');
@@ -43,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']) $('#'+id).addEventListener('change',load);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'])$('#'+id).value='';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();
+5 -1
View File
@@ -3,6 +3,9 @@
.mini-cards span{font-size:11px;color:var(--muted)}
.mini-cards b{font-family:Georgia,serif;font-size:22px;font-weight:500}
.mini-cards small{font-size:10px;color:var(--muted)}
.quick-ranges{display:flex;gap:6px;margin:28px 0 -24px}.quick-ranges button{font-size:11px;padding:6px 10px}.quick-ranges .active{background:#1b1c19;color:#fff;border-color:#1b1c19}
.insight-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:10px}.insight-strip article{padding:15px 17px;border-top:1px solid var(--line);display:grid;gap:4px}.insight-strip span,.insight-strip small{font-size:10px;color:var(--muted)}.insight-strip b{font:500 20px Georgia,serif}
.project-chart{display:grid;gap:8px;margin-bottom:22px}.project-bar{display:grid;grid-template-columns:minmax(110px,180px) 1fr 75px 65px;align-items:center;gap:12px;border:0;padding:5px;background:transparent;text-align:left}.project-bar:hover{background:#f0eee7}.project-bar>span{overflow:hidden;text-overflow:ellipsis}.project-bar>i{height:10px;background:#e7e5dd;border-radius:8px;overflow:hidden}.project-bar>i b{height:100%;display:block;border-radius:8px}.project-bar strong,.project-bar small{text-align:right}.table-tools{display:flex;justify-content:flex-end;gap:8px;margin-bottom:10px}.table-tools input{min-width:220px}.clickable{cursor:pointer}.clickable:hover{background:#f0eee7}
.chart{height:280px}
.day{min-width:4px;display:flex;flex-direction:column-reverse;justify-content:flex-start;align-items:stretch}
.day span{flex:none;border-radius:1px;transition:filter .15s}
@@ -29,4 +32,5 @@
.bar:hover:after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);z-index:20;background:#1b1c19;color:#fff;padding:9px 11px;border-radius:6px;white-space:pre;line-height:1.6;font:10px ui-monospace,monospace;box-shadow:0 8px 24px #0003;pointer-events:none}
.bar:hover .bar-fill{filter:saturate(1.25) brightness(.92)}
@media(max-width:1200px){.mini-cards{grid-template-columns:repeat(3,1fr)}}
@media(max-width:620px){.mini-cards{grid-template-columns:1fr 1fr}}
@media(max-width:900px){.insight-strip{grid-template-columns:1fr 1fr}}
@media(max-width:620px){.mini-cards{grid-template-columns:1fr 1fr}.quick-ranges{overflow:auto}.project-bar{grid-template-columns:90px 1fr 60px}.project-bar small{display:none}.table-tools{display:grid}.table-tools input{min-width:0}.insight-strip{grid-template-columns:1fr 1fr}}
+4 -2
View File
@@ -1,11 +1,13 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Codex Lens</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="fancy.css"></head><body>
<aside><div class="brand"><span class="mark"></span><div>CODEX <b>LENS</b></div></div><nav><a class="active">Overview</a><a href="#models">Models</a><a href="#projects">Projects</a><a href="#sessions">Sessions</a></nav><div class="aside-foot"><span class="pulse"></span> Local data only<br><small>Nothing leaves this machine</small></div></aside>
<main><header><div><p class="eyebrow">LOCAL INTELLIGENCE</p><h1>Your work, in focus.</h1><p id="subtitle">Scanning Codex sessions…</p></div><button id="rescan">↻ Rescan data</button></header>
<section class="filters"><label>From<input id="from" type="date"></label><label>To<input id="to" type="date"></label><label>Project<select id="project"><option value="">All projects</option></select></label><label>Model<select id="model"><option value="">All models</option></select></label><button id="clear">Clear</button></section>
<section class="quick-ranges" aria-label="Quick date ranges"><button data-days="0">Today</button><button data-days="7">7 days</button><button data-days="30">30 days</button><button data-days="90">90 days</button><button data-days="all" class="active">All time</button></section>
<section class="filters"><label>From<input id="from" type="date"></label><label>To<input id="to" type="date"></label><label>Project<select id="project"><option value="">All projects</option></select></label><label>Model<select id="model"><option value="">All models</option></select></label><label>Source<select id="source"><option value="">All sources</option></select></label><button id="clear">Clear</button></section>
<section class="cards"><article class="hero-card"><span>API-equivalent cost</span><strong id="cost"></strong><small id="coverage">Based on current standard pricing</small></article><article><span>Total tokens</span><strong id="tokens"></strong><small id="tokenMix"></small></article><article><span>Cache hit rate</span><strong id="cache"></strong><small id="cacheSaved"></small></article><article><span>Sessions / turns</span><strong id="activity"></strong><small id="duration"></small></article></section>
<section class="mini-cards"><article><span>Uncached input</span><b id="uncached"></b><small id="uncachedShare"></small></article><article><span>Reasoning output</span><b id="reasoning"></b><small id="reasoningShare"></small></article><article><span>Average session</span><b id="avgSession"></b><small id="avgCost"></small></article><article><span>Busiest day</span><b id="peakDay"></b><small id="peakTokens"></small></article><article><span>Active days</span><b id="activeDays"></b><small id="streak"></small></article><article><span>Models used</span><b id="modelCount"></b><small id="topModel"></small></article></section>
<section class="insight-strip"><article><span>Average model call</span><b id="avgCall"></b><small id="callsPerDay"></small></article><article><span>Median active day</span><b id="medianDay"></b><small id="dailyAverage"></small></article><article><span>Output intensity</span><b id="outputRate"></b><small>Output as share of all tokens</small></article><article><span>Estimated cache saving</span><b id="cacheSaving"></b><small>Versus standard input rates</small></article></section>
<section class="panel wide"><div class="panel-head"><div><p class="eyebrow">MODEL TIMELINE</p><h2>Which models powered each day</h2></div><div class="chart-actions"><button class="chart-mode active" data-mode="models">Models</button><button class="chart-mode" data-mode="tokens">Token mix</button></div></div><div id="modelLegend" class="model-legend"></div><div id="chart" class="chart"></div><div class="axis-note" id="axisNote">Stacked totals by model · hover any day for details</div></section>
<div class="grid"><section class="panel" id="models"><div class="panel-head"><div><p class="eyebrow">MODEL MIX</p><h2>Models</h2></div></div><div id="modelBars"></div></section><section class="panel"><div class="panel-head"><div><p class="eyebrow">COMPOSITION</p><h2>Where tokens go</h2></div></div><div class="donut-wrap"><div id="donut" class="donut"><div><b id="donutTotal"></b><small>tokens</small></div></div><div id="mixLegend"></div></div></section></div>
<section class="panel wide" id="projects"><div class="panel-head"><div><p class="eyebrow">WORKSPACES</p><h2>Project leaderboard</h2></div></div><div class="table-wrap"><table><thead><tr><th>Project</th><th>Tokens</th><th>Input</th><th>Output</th><th>Cache</th><th>Est. cost</th><th>Turns</th></tr></thead><tbody id="projectsBody"></tbody></table></div></section>
<section class="panel wide" id="projects"><div class="panel-head"><div><p class="eyebrow">WORKSPACES</p><h2>Project overview</h2></div><small>Click a bar to drill down</small></div><div id="projectChart" class="project-chart"></div><div class="table-tools"><input id="projectSearch" type="search" placeholder="Search projects…"><select id="projectSort"><option value="tokens">Sort by tokens</option><option value="cost">Sort by cost</option><option value="cache">Sort by cache rate</option><option value="output">Sort by output</option></select></div><div class="table-wrap"><table><thead><tr><th>Project</th><th>Tokens</th><th>Input</th><th>Output</th><th>Cache</th><th>Est. cost</th><th>Turns</th></tr></thead><tbody id="projectsBody"></tbody></table></div></section>
<section class="panel wide" id="sessions"><div class="panel-head"><div><p class="eyebrow">DEEP DIVE</p><h2>Heaviest sessions</h2></div><small>Top 100 for current filter</small></div><div class="table-wrap"><table><thead><tr><th>Started</th><th>Project</th><th>Model</th><th>Tokens</th><th>Cached</th><th>Output</th><th>Est. cost</th></tr></thead><tbody id="sessionsBody"></tbody></table></div></section>
<footer>Codex Lens · Estimates are API-equivalent, not actual ChatGPT/Codex charges. Reasoning tokens are included in output token counts.</footer></main><script src="app.js"></script></body></html>