agentusage 1.1.0

Local-first agent usage tracking
//! Browser presentation for the local usage dashboard.
//!
//! Keeping the page in its own module means the HTTP server owns routing and
//! data access while this module owns markup, styling, and browser behavior.

/// Render the dashboard document served from `GET /`.
pub fn index_html() -> &'static str {
    INDEX_HTML
}

const INDEX_HTML: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agentusage</title>
<style>
:root{color-scheme:dark;--bg:#181923;--panel:#1e1f2b;--border:#454557;--muted:#8b8b9e;--accent:#e9b4c9;--text:#ddd}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px ui-monospace,SFMono-Regular,Menlo,monospace}
header{padding:18px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
header strong{color:#fff;font-size:16px}.controls{display:flex;gap:8px;flex-wrap:wrap}button{background:#292a3a;color:#eee;border:1px solid #666;border-radius:4px;padding:7px 11px;cursor:pointer}button:hover,button.active{border-color:var(--accent);color:var(--accent)}.card-actions{display:flex;justify-content:flex-end;margin:-4px 0 8px}.card-actions button{font-size:12px;padding:5px 8px}
main{padding:18px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,520px),1fr));gap:16px}.card{border:1px solid #555568;border-radius:8px;padding:16px;background:var(--panel);min-width:0}.card h2{margin:0 0 12px;color:var(--accent);font-size:18px}.card h3{margin:22px 0 8px;color:#d9a7c0;font-size:15px}.muted{color:var(--muted)}.error{color:#f09a9a}
.metric{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #30313f}.metric:last-of-type{border-bottom:0}.metric b{color:#fff}.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:12px}th,td{border-bottom:1px solid #38394a;padding:7px 8px;text-align:right;white-space:nowrap}th{color:#9e9eb2;font-weight:normal}th:first-child,td:first-child{text-align:left}td:first-child{color:var(--accent);max-width:230px;overflow:hidden;text-overflow:ellipsis}.status{font-size:12px;color:var(--muted);margin:0 0 12px}.loading{grid-column:1/-1;padding:24px;text-align:center}
.trend{margin-top:22px}.trend h3{margin-top:0}.chart{width:100%;height:auto;display:block;overflow:visible}.chart-grid{stroke:#363746;stroke-width:1}.chart-axis{fill:var(--muted);font-size:10px}.chart-line{fill:none;stroke:var(--accent);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}.model-line{fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;opacity:.9}.chart-area{fill:url(#trend-fill);opacity:.35}.chart-dot{fill:var(--panel);stroke:var(--accent);stroke-width:2}.model-dot{fill:var(--panel);stroke-width:1.5}.legend{display:flex;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:11px;margin-top:7px}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--legend-color,var(--accent));margin-right:5px}.chart-empty{height:150px;display:grid;place-items:center;border:1px dashed #454557;border-radius:6px;color:var(--muted);font-size:12px}
@media(max-width:600px){header{padding:14px 16px}main{padding:12px}.card{padding:13px}}
</style>
</head>
<body>
<header><strong>● Agentusage</strong><span class="controls" aria-label="Date range">
<button data-window="today">Today</button><button data-window="7d">7 Days</button><button data-window="30d">30 Days</button><button data-window="all">All Time</button>
</span></header>
<main id="app"><p class="muted loading">Loading provider data…</p></main>
<script>
let windowName='today';
const app=document.querySelector('#app');
const buttons=[...document.querySelectorAll('[data-window]')];
const formatTokens=value=>Number(value||0).toLocaleString();
const escapeHtml=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
buttons.forEach(button=>button.addEventListener('click',()=>{windowName=button.dataset.window;load()}));
function syncButtons(){buttons.forEach(button=>button.classList.toggle('active',button.dataset.window===windowName))}
function modelBurnTable(models){const entries=Object.entries(models||{});if(!entries.length)return '<p class="muted">No model token usage in this window.</p>';const rows=entries.map(([name,v])=>'<tr><td title="'+escapeHtml(name)+'">'+escapeHtml(name)+'</td><td>'+formatTokens(v.input_tokens)+'</td><td>'+formatTokens(v.output_tokens)+'</td><td>'+formatTokens(v.cache_read_tokens)+'</td><td>'+formatTokens(v.cache_write_tokens)+'</td><td>'+formatTokens(v.total_tokens)+'</td></tr>').join('');return '<div class="table-wrap"><table><thead><tr><th>model</th><th>input</th><th>output</th><th>cache_read</th><th>cache_write</th><th>total</th></tr></thead><tbody>'+rows+'</tbody></table></div>'}
function trendChart(points){const width=760,height=250,left=48,right=16,top=14,bottom=30;const palette=['#e9b4c9','#8ed1c5','#f6c177','#9db5ff','#c9a7e9','#f28f8f','#8bd3dd','#b8d47d'];const modelNames=[...new Set(points.flatMap(p=>Object.keys(p.models||{})))];const values=points.flatMap(p=>[p.total_tokens,...modelNames.map(name=>p.models?.[name]||0)]);const max=Math.max(...values,1);const x=i=>left+(points.length===1?0:(width-left-right)*i/(points.length-1));const y=value=>top+(height-top-bottom)*(1-value/max);const pathFor=values=>points.map((_,i)=>(i?'L':'M')+x(i).toFixed(1)+' '+y(values[i]).toFixed(1)).join(' ');const totalValues=points.map(p=>p.total_tokens);const area=pathFor(totalValues)+' L '+x(points.length-1).toFixed(1)+' '+(height-bottom)+' L '+x(0).toFixed(1)+' '+(height-bottom)+' Z';const modelLines=modelNames.map((name,index)=>{const color=palette[index%palette.length];const values=points.map(p=>p.models?.[name]||0);const line='<path class="model-line" stroke="'+color+'" d="'+pathFor(values)+'"/><g>'+points.filter((p,i)=>values[i]>0).map((p,i)=>{const pointIndex=points.indexOf(p);return '<circle class="model-dot" stroke="'+color+'" cx="'+x(pointIndex)+'" cy="'+y(values[pointIndex])+'" r="2"><title>'+escapeHtml(name)+' · '+escapeHtml(p.date)+': '+formatTokens(values[pointIndex])+' tokens</title></circle>'}).join('')+'</g>';return {name,color,line}});const labels=points.filter((_,i)=>i===0||i===points.length-1||i===Math.floor((points.length-1)/2)).map((p)=>{const i=points.indexOf(p);return '<text class="chart-axis" x="'+x(i)+'" y="'+(height-8)+'" text-anchor="middle">'+escapeHtml(p.date.slice(5))+'</text>'}).join('');const dots=points.filter(p=>p.total_tokens>0).map((p)=>{const i=points.indexOf(p);return '<circle class="chart-dot" cx="'+x(i)+'" cy="'+y(p.total_tokens)+'" r="3"><title>'+escapeHtml(p.date)+': '+formatTokens(p.total_tokens)+' total tokens</title></circle>'}).join('');const grid=[0,.5,1].map(v=>'<line class="chart-grid" x1="'+left+'" x2="'+(width-right)+'" y1="'+y(max*v)+'" y2="'+y(max*v)+'"/>').join('');const legend='<span style="--legend-color:var(--accent)">total</span>'+modelLines.map(model=>'<span style="--legend-color:'+model.color+'">'+escapeHtml(model.name)+'</span>').join('');return '<div class="trend"><h3>Token usage trend</h3><svg class="chart" viewBox="0 0 '+width+' '+height+'" role="img" aria-label="Daily token usage by model"><defs><linearGradient id="trend-fill" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#e9b4c9"/><stop offset="1" stop-color="#e9b4c9" stop-opacity="0"/></linearGradient></defs>'+grid+'<path class="chart-area" d="'+area+'"/><path class="chart-line" d="'+pathFor(totalValues)+'"/>'+modelLines.map(model=>model.line).join('')+dots+labels+'</svg><div class="legend">'+legend+'</div></div>'}
function addExportButton(card,providerName){const actions=document.createElement('div');actions.className='card-actions';const button=document.createElement('button');button.type='button';button.textContent='Download PNG';button.setAttribute('aria-label','Download '+providerName+' card as PNG');button.addEventListener('click',()=>downloadCard(card,providerName,button));actions.append(button);card.prepend(actions)}
function downloadCard(card,providerName,button){const width=Math.ceil(card.getBoundingClientRect().width);const height=Math.ceil(card.scrollHeight);const scale=2;const styles=':root{--accent:#e9b4c9}*{box-sizing:border-box}body{margin:0;background:#1e1f2b;color:#ddd;font:15px ui-monospace,SFMono-Regular,Menlo,monospace}.card{border:1px solid #555568;border-radius:8px;padding:16px;background:#1e1f2b;color:#ddd;width:'+width+'px;min-width:0}.card h2{margin:0 0 12px;color:#e9b4c9;font-size:18px}.card h3{margin:22px 0 8px;color:#d9a7c0;font-size:15px}.card-actions{display:none}.muted{color:#8b8b9e}.error{color:#f09a9a}.metric{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #30313f}.metric:last-of-type{border-bottom:0}.metric span{color:#ddd}.metric b{color:#fff}.table-wrap{overflow:visible}table{border-collapse:collapse;width:100%;font-size:12px;color:#ddd}th,td{border-bottom:1px solid #38394a;padding:7px 8px;text-align:right;white-space:nowrap}th{color:#b9b9c8;font-weight:normal}th:first-child,td:first-child{text-align:left}td:first-child{color:#e9b4c9;max-width:230px;overflow:hidden;text-overflow:ellipsis}.status{font-size:12px;color:#b9b9c8;margin:0 0 12px}.trend{margin-top:22px}.trend h3{margin-top:0}.chart{width:100%;height:auto;display:block;overflow:visible}.chart-grid{stroke:#363746;stroke-width:1}.chart-axis{fill:#b9b9c8;font-size:10px}.chart-line{fill:none;stroke:#e9b4c9;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}.model-line{fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;opacity:.9}.chart-area{fill:url(#trend-fill);opacity:.35}.chart-dot{fill:#1e1f2b;stroke:#e9b4c9;stroke-width:2}.model-dot{fill:#1e1f2b;stroke-width:1.5}.legend{display:flex;gap:14px;flex-wrap:wrap;color:#b9b9c8;font-size:11px;margin-top:7px}.legend span:before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--legend-color,#e9b4c9);margin-right:5px}.chart-empty{height:150px;display:grid;place-items:center;border:1px dashed #656578;border-radius:6px;color:#b9b9c8;font-size:12px}';const svg='<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="'+width+'" height="'+height+'" viewBox="0 0 '+width+' '+height+'"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml"><style>'+styles+'</style><section class="card">'+card.innerHTML+'</section></div></foreignObject></svg>';button.disabled=true;button.textContent='Preparing…';const blob=new Blob([svg],{type:'image/svg+xml;charset=utf-8'});const url=URL.createObjectURL(blob);const image=new Image();image.onload=()=>{const canvas=document.createElement('canvas');canvas.width=width*scale;canvas.height=height*scale;const context=canvas.getContext('2d');context.scale(scale,scale);context.drawImage(image,0,0);URL.revokeObjectURL(url);canvas.toBlob(png=>{const link=document.createElement('a');link.download='agentusage-'+providerName.toLowerCase().replace(/[^a-z0-9]+/g,'-')+'-'+windowName+'.png';link.href=URL.createObjectURL(png);link.click();setTimeout(()=>URL.revokeObjectURL(link.href),1000);button.disabled=false;button.textContent='Download PNG'},'image/png')};image.onerror=()=>{URL.revokeObjectURL(url);button.disabled=false;button.textContent='Download PNG';alert('Could not export this card in the current browser.')};image.src=url}
async function getJson(url){const response=await fetch(url);if(!response.ok)throw new Error('Request failed ('+response.status+')');return response.json()}
async function load(){syncButtons();app.innerHTML='<p class="muted loading">Loading provider data…</p>';try{const providers=await getJson('/api/providers');app.innerHTML='';for(const p of providers){const card=document.createElement('section');card.className='card';if(!p.available){card.innerHTML='<h2>'+escapeHtml(p.name)+'</h2><p class="muted">Unavailable</p>';addExportButton(card,p.name);app.append(card);continue}try{const [s,points]=await Promise.all([getJson('/api/summary?provider='+encodeURIComponent(p.name)+'&window='+windowName),getJson('/api/trend?provider='+encodeURIComponent(p.name)+'&window='+windowName)]);card.innerHTML='<h2>'+escapeHtml(p.name)+'</h2><p class="status">Window: '+windowName+'</p><div class="metric"><span>tokens</span><b>'+formatTokens(s.total_tokens)+'</b></div><div class="metric"><span>requests</span><b>'+formatTokens(s.requests)+'</b></div><div class="metric"><span>sessions</span><b>'+formatTokens(s.sessions)+'</b></div><div class="metric"><span>cost</span><b>$'+Number(s.cost_usd).toFixed(6)+'</b></div>'+(points.length?trendChart(points):'<div class="trend"><h3>Token usage trend</h3><div class="chart-empty">No token usage in this window.</div></div>')+'<h3>Model burn</h3>'+modelBurnTable(s.models)}catch(error){card.innerHTML='<h2>'+escapeHtml(p.name)+'</h2><p class="error">'+escapeHtml(error.message)+'</p>'}addExportButton(card,p.name);app.append(card)}}catch(error){app.innerHTML='<p class="error loading">'+escapeHtml(error.message)+'</p>'}}
load();
</script>
</body>
</html>"##;

#[cfg(test)]
mod tests {
    use super::index_html;

    #[test]
    fn dashboard_contains_expected_mount_and_api_calls() {
        let page = index_html();
        assert!(page.contains("id=\"app\""));
        assert!(page.contains("/api/providers"));
        assert!(page.contains("/api/summary"));
        assert!(page.contains("/api/trend"));
        assert!(page.contains("Token usage trend"));
        assert!(page.contains("model-line"));
        assert!(page.contains("Download PNG"));
        assert!(page.contains("downloadCard"));
    }
}