feat(billing): CG4b — Chart.js donut + modul-tabell per token-forbruk

This commit is contained in:
chrischristiansen-glitch 2026-06-11 12:25:03 +02:00
parent 2404ed47cb
commit 87345b151d

View File

@ -253,6 +253,7 @@
.md-viewer hr{border:none;border-top:1px solid var(--border);margin:20px 0}
.md-viewer-loading{display:flex;align-items:center;justify-content:center;height:200px;color:var(--text-faint);font-family:var(--mono);font-size:13px;gap:10px}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head>
<body>
<div class="shell">
@ -593,16 +594,62 @@ async function loadBilling(){
h+='</div>'
}else{ h+='<div class="sec-title" style="color:var(--green)">✅ Ingen anomalier</div>' }
const mods = mod?.modules||mod?.data||[]
const cols = ['#7c5cff','#00d4a8','#ffb547','#ff5c7c','#3ecf8e','#5c8fff','#ff9f7c','#c084fc']
if(mods.length){
const cols=['#7c5cff','#00d4a8','#ffb547','#ff5c7c','#3ecf8e']
h+='<div class="sec-title" style="margin-top:24px">Token-forbruk per modul</div><table class="mod-table"><thead><tr><th>Modul</th><th>Tokens</th><th>Input</th><th>Output</th></tr></thead><tbody>'
h+='<div class="sec-title" style="margin-top:24px">Token-forbruk per modul</div>'
h+='<table class="mod-table"><thead><tr><th>Modul</th><th>Tokens</th><th>Input</th><th>Output</th></tr></thead><tbody>'
mods.forEach((m,i)=>{ h+=`<tr><td><span class="mod-dot" style="background:${cols[i%cols.length]}"></span>${esc(m.module||m.name||'—')}</td><td>${(m.total_tokens||0).toLocaleString('no')}</td><td>${(m.input_tokens||0).toLocaleString('no')}</td><td>${(m.output_tokens||0).toLocaleString('no')}</td></tr>` })
h+='</tbody></table>'
h+=`<div style="display:flex;justify-content:center;margin-top:28px">
<canvas id="mod-donut" width="260" height="260"></canvas>
</div>`
el.innerHTML = h
renderDonut(mods, cols)
return
}
el.innerHTML=h
}catch(e){ el.innerHTML=`<div class="empty-state"><div class="empty-icon"></div><div class="empty-title">Feil</div><div class="empty-desc">${e.message}</div></div>` }
}
function renderDonut(mods, cols) {
const canvas = document.getElementById('mod-donut')
if (!canvas) return
if (canvas._chart) canvas._chart.destroy()
canvas._chart = new Chart(canvas, {
type: 'doughnut',
data: {
labels: mods.map(m => m.module || m.name || '—'),
datasets: [{
data: mods.map(m => m.total_tokens || 0),
backgroundColor: mods.map((_, i) => cols[i % cols.length]),
borderColor: '#0a0a0f',
borderWidth: 3,
hoverOffset: 6,
}]
},
options: {
cutout: '68%',
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#8b909c',
font: { size: 11, family: '-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif' },
padding: 14,
boxWidth: 10,
boxHeight: 10,
}
},
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed.toLocaleString('no')} tokens`
}
}
}
}
})
}
// BUILD PANEL
async function loadBuild(){
const el=document.getElementById('build-body')
@ -707,20 +754,14 @@ function mdShowGrid(){
function renderMd(text){
let s=esc(text)
// code blocks first
s=s.replace(/```[^\n]*\n([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
// headings
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>')
s=s.replace(/^## (.+)$/gm,'<h2>$1</h2>')
s=s.replace(/^# (.+)$/gm,'<h1>$1</h1>')
// bold
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
// hr
s=s.replace(/^---$/gm,'<hr>')
// blockquote
s=s.replace(/^&gt; (.+)$/gm,'<blockquote>$1</blockquote>')
// tables (simple)
s=s.replace(/((?:^\|.+\|\n)+)/gm, tbl=>{
const rows=tbl.trim().split('\n').filter(r=>!/^\|[-| :]+\|$/.test(r))
if(!rows.length) return tbl
@ -734,11 +775,9 @@ function renderMd(text){
out+='</tbody></table>'
return out
})
// lists
s=s.replace(/^[-*] (.+)$/gm,'<li>$1</li>')
s=s.replace(/(<li>.*<\/li>)/s, m=>'<ul>'+m+'</ul>')
s=s.replace(/^\d+\. (.+)$/gm,'<li>$1</li>')
// paragraphs — wrap loose lines
s=s.replace(/^(?!<[htublp|]).+$/gm, line=>line.trim()?`<p>${line}</p>`:'')
return s
}