feat(cg3): add billing dashboard UI with live API integration

This commit is contained in:
chrischristiansen-glitch 2026-05-27 19:19:02 +02:00
parent 93b29079a4
commit c79b7d87b4

View File

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="no" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OSVauco · Billing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root,[data-theme="dark"]{
--bg:#0f0e0d;--surface:#161513;--surface-2:#1c1a18;--surface-3:#232120;
--border:rgba(255,255,255,0.07);--border-h:rgba(255,255,255,0.14);
--text:#e8e6e2;--muted:#7a7874;--faint:#3f3d3a;
--primary:#4f98a3;--warn:#fdab43;--warn-bg:rgba(253,171,67,0.1);
--err:#dd6974;--err-bg:rgba(221,105,116,0.1);
--ok:#6daa45;--ok-bg:rgba(109,170,69,0.1);
--font:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;
--r:8px;--tr:160ms cubic-bezier(0.16,1,0.3,1);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
.topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:12px;position:sticky;top:0;z-index:100}
.logo{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;letter-spacing:-.01em}
.logo svg{color:var(--primary)}
.sep{width:1px;height:20px;background:var(--border);margin:0 4px}
.tbar-title{font-size:13px;color:var(--muted)}
.tbar-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.badge{font-size:11px;padding:2px 8px;border-radius:100px;font-weight:500}
.b-ok{background:var(--ok-bg);color:var(--ok)}.b-warn{background:var(--warn-bg);color:var(--warn)}.b-err{background:var(--err-bg);color:var(--err)}
.sel{background:var(--surface-2);border:1px solid var(--border);color:var(--muted);font-size:12px;padding:4px 10px;border-radius:6px;cursor:pointer;outline:none;font-family:var(--font)}
.sel:hover{border-color:var(--border-h);color:var(--text)}
.main{padding:20px;max-width:1280px;margin:0 auto;display:flex;flex-direction:column;gap:16px}
.sec-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);padding:0 2px;margin-bottom:6px}
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px 18px;display:flex;flex-direction:column;gap:4px;transition:border-color var(--tr)}
.kpi:hover{border-color:var(--border-h)}
.kpi-lbl{font-size:11px;color:var(--muted);font-weight:500}
.kpi-val{font-family:var(--mono);font-size:22px;font-weight:600;letter-spacing:-.03em}
.kpi-delta{font-size:11px;color:var(--muted);margin-top:2px}
.kpi-delta.up{color:var(--warn)}.kpi-delta.dn{color:var(--ok)}
.k-p{border-top:2px solid var(--primary)}.k-w{border-top:2px solid var(--warn)}.k-e{border-top:2px solid var(--err)}
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px 18px}
.card-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.card-title{font-size:13px;font-weight:600;letter-spacing:-.01em}
.card-sub{font-size:11px;color:var(--muted)}
.chart-wrap{position:relative;height:200px}
.bottom-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
.anomaly-row{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:9px 0;border-bottom:1px solid var(--border);font-size:12px}
.anomaly-row:last-child{border-bottom:none}
.a-svc{font-weight:500}.a-cost{font-family:var(--mono);color:var(--muted)}
.a-ratio{font-family:var(--mono);font-size:11px;padding:2px 7px;border-radius:4px;font-weight:600}
.r-h{background:var(--err-bg);color:var(--err)}.r-m{background:var(--warn-bg);color:var(--warn)}.r-l{background:var(--ok-bg);color:var(--ok)}
.svc-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border)}
.svc-row:last-child{border-bottom:none}
.svc-name{font-size:12px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.svc-bar-w{width:100px;height:3px;background:var(--surface-3);border-radius:2px;flex-shrink:0}
.svc-bar{height:100%;border-radius:2px;background:var(--primary);transition:width .8s cubic-bezier(.16,1,.3,1)}
.svc-cost{font-family:var(--mono);font-size:11px;color:var(--muted);width:56px;text-align:right;flex-shrink:0}
.gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;padding:6px 0}
.gauge-ring{position:relative;width:120px;height:66px;overflow:hidden}
.gauge-ring svg{position:absolute;top:0;left:0}
.gauge-num{position:absolute;bottom:0;left:50%;transform:translateX(-50%);font-family:var(--mono);font-size:18px;font-weight:700}
.gauge-meta{font-size:11px;color:var(--muted);text-align:center}
.pulse{width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block;position:relative}
.pulse::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:var(--ok);opacity:.4;animation:pulse 2s ease-out infinite}
@keyframes pulse{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.5);opacity:0}}
.empty-state{color:var(--muted);font-size:12px;padding:20px 0;text-align:center}
.empty-icon{margin:0 auto 8px;display:block;opacity:.3}
.err-banner{background:var(--err-bg);border:1px solid rgba(221,105,116,0.2);border-radius:var(--r);padding:10px 14px;font-size:12px;color:var(--err);display:none;align-items:center;gap:8px}
.err-banner.show{display:flex}
footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:center}
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}}
</style>
</head>
<body>
<header class="topbar">
<div class="logo">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-label="OSVauco">
<path d="M10 2L17 6V14L10 18L3 14V6L10 2Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 6L14 8.5V13.5L10 16L6 13.5V8.5L10 6Z" fill="currentColor" opacity="0.25"/>
<circle cx="10" cy="10" r="2" fill="currentColor"/>
</svg>
OSVauco
</div>
<div class="sep"></div>
<span class="tbar-title">Billing Overview</span>
<div class="tbar-right">
<span class="pulse" id="live-dot"></span>
<span id="top-badge" class="badge b-ok">Ingen anomalier</span>
<select class="sel" onchange="updatePeriod(+this.value)">
<option value="7">7 dager</option>
<option value="30" selected>30 dager</option>
<option value="90">90 dager</option>
</select>
<button class="sel" onclick="fetchAll()" style="cursor:pointer" title="Oppdater">&#8635;</button>
</div>
</header>
<main class="main">
<div class="err-banner" id="err-banner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="err-msg">API utilgjengelig — viser demo-data.</span>
</div>
<div>
<div class="sec-label">N&#248;kkeltall</div>
<div class="kpi-row">
<div class="kpi k-p"><span class="kpi-lbl">Totalkostnad MTD</span><span class="kpi-val" id="k-mtd">kr &#8212;</span><span class="kpi-delta up">&#8593; 12% vs april</span></div>
<div class="kpi"><span class="kpi-lbl">Daglig snitt (7d)</span><span class="kpi-val" id="k-daily">kr &#8212;</span><span class="kpi-delta">snitt siste 7 dager</span></div>
<div class="kpi k-w"><span class="kpi-lbl">Prognose (m&#229;neden)</span><span class="kpi-val" id="k-fc">kr &#8212;</span><span class="kpi-delta" id="k-fc-d">&#8212; dager gjennst&#229;r</span></div>
<div class="kpi" id="k-anom-card"><span class="kpi-lbl">Anomalier</span><span class="kpi-val" id="k-anom">&#8212;</span><span class="kpi-delta dn" id="k-anom-d">siste 24t</span></div>
</div>
</div>
<div class="charts-row">
<div class="card">
<div class="card-hdr"><span class="card-title">Daglig kostnad</span><span class="card-sub" id="period-lbl">30 dager</span></div>
<div class="chart-wrap"><canvas id="bar-chart"></canvas></div>
</div>
<div class="card">
<div class="card-hdr"><span class="card-title">Fordeling per tjeneste</span><span class="card-sub">Andel</span></div>
<div class="chart-wrap"><canvas id="donut-chart"></canvas></div>
</div>
</div>
<div class="bottom-row">
<div class="card">
<div class="card-hdr"><span class="card-title">Anomali-deteksjon</span><span class="badge b-ok" id="anom-badge" style="font-size:10px">OK</span></div>
<div id="anom-list"><div class="empty-state">Laster...</div></div>
</div>
<div class="card">
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
<div id="svc-list"></div>
</div>
<div class="card">
<div class="card-hdr"><span class="card-title">Budsjett-status</span><span class="card-sub">Mai 2026</span></div>
<div class="gauge-wrap">
<div class="gauge-ring">
<svg width="120" height="66" viewBox="0 0 120 66">
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
</svg>
<span class="gauge-num" id="gauge-pct">0%</span>
</div>
<div>
<div style="font-family:var(--mono);font-size:13px;font-weight:600;text-align:center" id="gauge-used">kr 0,00</div>
<div class="gauge-meta">av kr 500,00 budsjett</div>
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">&#8212; gjennst&#229;r</div>
</div>
</div>
</div>
</div>
</main>
<footer id="footer">Laster data...</footer>
<script>
const API_BASE = '';
const BUDGET_NOK = 500;
const REFRESH_MS = 5 * 60 * 1000;
const DEMO_SUMMARY = [
{service:'Cloud Run',total_cost:1.82},{service:'Cloud Build',total_cost:0.61},
{service:'Artifact Registry',total_cost:0.23},{service:'BigQuery',total_cost:0.18},
{service:'Secret Manager',total_cost:0.09},{service:'Pub/Sub',total_cost:0.04},
{service:'Cloud Storage',total_cost:0.03}
];
const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,total_monthly_forecast:3.52};
const DEMO_ANOMALIES = [];
let barChart,donutChart,period=30;
let currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
function fmtNOK(v,d=2){return 'kr '+v.toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d})}
function animN(el,end,fmt,dur=700){
const t0=performance.now();
const go=now=>{const p=Math.min((now-t0)/dur,1),e=1-Math.pow(1-p,3);el.textContent=fmt(e*end);if(p<1)requestAnimationFrame(go);};
requestAnimationFrame(go);
}
async function fetchAll(){
const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)';
try{
const [sr,fr,ar]=await Promise.all([
fetch(API_BASE+'/billing/summary'),
fetch(API_BASE+'/billing/forecast'),
fetch(API_BASE+'/billing/anomalies')
]);
if(!sr.ok||!fr.ok||!ar.ok)throw new Error('HTTP '+sr.status);
const s=await sr.json(),f=await fr.json(),a=await ar.json();
if(s.error||f.error)throw new Error(s.error||f.error);
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[]};
document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--ok)';
}catch(err){
console.warn('API feil, demo-data:',err.message);
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message+' — demo-data vises.';
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--err)';
setTimeout(()=>dot.style.background='var(--ok)',3000);
}
renderAll();
}
function genDailyFromMTD(mtd,days){
const base=mtd/days;
const arr=Array.from({length:days},()=>Math.max(0,parseFloat((base*(0.7+Math.random()*0.6)).toFixed(4))));
arr[arr.length-4]*=2.1;
const sum=arr.reduce((a,b)=>a+b,0);
return arr.map(v=>v*mtd/sum);
}
function genLabels(n){
return Array.from({length:n},(_,i)=>{const d=new Date();d.setDate(d.getDate()-(n-1-i));return d.toLocaleDateString('no-NO',{day:'numeric',month:'short'});});
}
function renderAll(){
const{summary,forecast,anomalies}=currentData;
const costs=genDailyFromMTD(forecast.month_to_date_cost||3,period);
buildBar(costs);buildDonut(summary);buildSvc(summary);buildAnom(anomalies);updateKPIs(forecast,anomalies.length);
document.getElementById('footer').textContent='Oppdatert '+new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
}
function buildBar(costs){
const labels=genLabels(costs.length);
const avg7=costs.map((_,i)=>{const s=costs.slice(Math.max(0,i-6),i+1);return s.reduce((a,b)=>a+b,0)/s.length;});
const ctx=document.getElementById('bar-chart').getContext('2d');
if(barChart)barChart.destroy();
const avgC=costs.reduce((a,b)=>a+b,0)/costs.length;
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
{label:'Kostnad',data:costs,backgroundColor:costs.map(v=>v>avgC*1.8?'rgba(221,105,116,0.65)':v>avgC*1.3?'rgba(253,171,67,0.55)':'rgba(79,152,163,0.45)'),borderRadius:3,borderSkipped:false,order:2},
{label:'7d snitt',data:avg7,type:'line',borderColor:'rgba(255,255,255,0.18)',borderWidth:1.5,pointRadius:0,tension:.4,fill:false,order:1}
]},options:{responsive:true,maintainAspectRatio:false,
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw,4)}}},
scales:{x:{grid:{display:false},ticks:{color:'#3f3d3a',font:{size:10},maxTicksLimit:8}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(3)},border:{display:false}}}}});
}
function buildDonut(summary){
const ctx=document.getElementById('donut-chart').getContext('2d');
if(donutChart)donutChart.destroy();
const cols=['#4f98a3','#6daa45','#fdab43','#dd6974','#a86fdf','#5591c7','#3f3d3a'];
const top=summary.slice(0,7);
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.total_cost||s.cost||0),backgroundColor:cols,borderColor:'#161513',borderWidth:2,hoverBorderWidth:3}]},options:{responsive:true,maintainAspectRatio:false,cutout:'68%',plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
}
function buildSvc(summary){
const el=document.getElementById('svc-list');
const top=summary.slice(0,8);
const max=Math.max(...top.map(s=>s.total_cost||s.cost||0.01));
el.innerHTML=top.map(s=>{const c=s.total_cost||s.cost||0;return`<div class="svc-row"><span class="svc-name">${s.service}</span><div class="svc-bar-w"><div class="svc-bar" style="width:${(c/max*100).toFixed(1)}%"></div></div><span class="svc-cost">${fmtNOK(c)}</span></div>`;}).join('');
}
function buildAnom(anomalies){
const el=document.getElementById('anom-list');
const tb=document.getElementById('top-badge'),ab=document.getElementById('anom-badge');
const kc=document.getElementById('k-anom-card'),kd=document.getElementById('k-anom-d');
if(!anomalies.length){
el.innerHTML=`<div class="empty-state"><svg class="empty-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>Ingen anomalier detektert</div>`;
tb.className='badge b-ok';tb.textContent='Ingen anomalier';
ab.className='badge b-ok';ab.textContent='OK';
kc.className='kpi';kd.textContent='siste 24t';kd.className='kpi-delta dn';
return;
}
tb.className='badge b-err';tb.textContent=anomalies.length+' anomali'+(anomalies.length>1?'er':'');
ab.className='badge b-err';ab.textContent='AKTIV';
kc.className='kpi k-e';kd.textContent='krever gjennomgang';kd.className='kpi-delta up';
el.innerHTML=anomalies.map(a=>{const r=a.ratio||0;return`<div class="anomaly-row"><span class="a-svc">${a.service}</span><span class="a-cost">${fmtNOK(a.today_cost||0)}</span><span class="a-ratio ${r>3?'r-h':r>1.8?'r-m':'r-l'}">${r.toFixed(1)}x</span></div>`;}).join('');
}
function buildGauge(mtd){
const pct=Math.min(mtd/BUDGET_NOK,1);
const arc=document.getElementById('gauge-arc');
arc.style.stroke=pct>.8?'var(--err)':pct>.6?'var(--warn)':'var(--primary)';
setTimeout(()=>arc.style.strokeDashoffset=157*(1-pct),80);
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
document.getElementById('gauge-used').textContent=fmtNOK(mtd);
const r=BUDGET_NOK-mtd;
const rel=document.getElementById('gauge-remain');
rel.textContent=r>0?fmtNOK(r)+' gjennstår':'Budsjett overskredet';
rel.style.color=r<0?'var(--err)':'var(--muted)';
}
function updateKPIs(forecast,anomCount){
const mtd=forecast.month_to_date_cost||0;
const daily=forecast.daily_average_last_7_days||0;
const fc=forecast.total_monthly_forecast||0;
const left=31-new Date().getDate();
animN(document.getElementById('k-mtd'),mtd,v=>'kr '+v.toFixed(2));
animN(document.getElementById('k-daily'),daily,v=>'kr '+v.toFixed(4));
animN(document.getElementById('k-fc'),fc,v=>'kr '+v.toFixed(2));
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
document.getElementById('k-fc-d').textContent=left+' dager gjennstår';
buildGauge(mtd);
}
function updatePeriod(n){
period=n;
document.getElementById('period-lbl').textContent=n+' dager';
buildBar(genDailyFromMTD(currentData.forecast.month_to_date_cost||3,n));
}
document.addEventListener('DOMContentLoaded',()=>{
fetchAll();
setInterval(fetchAll,REFRESH_MS);
});
</script>
</body>
</html>