feat(CG4-credits): credits-panel i billing-dashboard + auto-poll hvert 5 min

This commit is contained in:
chrischristiansen-glitch 2026-06-13 06:35:41 +02:00
parent af1d546c9b
commit f58ec09d4c

View File

@ -69,12 +69,15 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
.dropdown-content a:hover{background:var(--surface-3)}
.dropdown:hover .dropdown-content{display:block}
/* ── module grid (CG6 drag) ── */
/* ── module grid ── */
.module-grid{display:flex;flex-direction:column;gap:12px}
.kpi-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
/* ── credits kpi row ── */
.credits-kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
/* ── draggable module ── */
.module{
position:relative;
@ -118,9 +121,20 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
.ki-blue{background:var(--blue-bg)}.ki-blue .material-symbols-outlined{color:var(--blue)}
.ki-green{background:var(--green-bg)}.ki-green .material-symbols-outlined{color:var(--green)}
.ki-yellow{background:var(--yellow-bg)}.ki-yellow .material-symbols-outlined{color:var(--yellow)}
.ki-red{background:var(--red-bg)}.ki-red .material-symbols-outlined{color:var(--red)}
.kpi-val{font-size:22px;font-weight:700;font-family:var(--mono);letter-spacing:-.03em}
.kpi-delta{font-size:12px;color:var(--muted)}
/* ── credits runway bar ── */
.runway-bar-wrap{margin-top:14px}
.runway-label{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:6px}
.runway-bar-bg{height:8px;background:var(--surface-3);border-radius:4px;overflow:hidden}
.runway-bar-fill{height:100%;border-radius:4px;transition:width 1.2s cubic-bezier(.16,1,.3,1)}
.credits-warning{display:none;margin-top:12px;padding:10px 14px;border-radius:8px;font-size:12px;align-items:center;gap:8px}
.credits-warning.show{display:flex}
.credits-warning.crit{background:var(--red-bg);color:var(--red);border:1px solid rgba(255,92,124,.2)}
.credits-warning.med{background:var(--yellow-bg);color:var(--yellow);border:1px solid rgba(255,180,84,.2)}
/* ── chart ── */
.chart-wrap{position:relative;height:200px}
.empty-state{color:var(--muted);font-size:13px;padding:24px 0;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
@ -165,7 +179,7 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:center}
/* ── responsive ── */
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}}
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){
.topbar{height:auto;flex-wrap:wrap;padding:8px 12px;gap:8px}
.tb-project{display:none}
@ -178,13 +192,14 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
.nav-item.active{border-left:none;border:1px solid var(--blue)}
.content{padding:16px 12px}
.kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.credits-kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.page-title{font-size:18px}
}
@media(max-width:420px){.kpi-row{grid-template-columns:1fr}}
@media(max-width:420px){.kpi-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:1fr}}
@media print{
.topbar,.sidebar,#budget-modal{display:none!important}
.module{border:1px solid #ddd}
.kpi-row,.charts-row,.bottom-row{grid-template-columns:1fr!important}
.kpi-row,.charts-row,.bottom-row,.credits-kpi-row{grid-template-columns:1fr!important}
}
</style>
</head>
@ -257,7 +272,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
</div>
<!-- MODULE GRID: alle kort er draggable og collapsible -->
<div class="module-grid" id="module-grid">
<!-- KPI-rad -->
@ -303,13 +317,98 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">trending_up</span></div>
</div>
<div class="kpi-val" id="k-fc"></div>
<div class="kpi-delta"><span id="k-fc-d">— dager gjenbstår</span></div>
<div class="kpi-delta"><span id="k-fc-d">— dager gjenstår</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ CG4-CREDITS: Kreditt-panel ═══ -->
<div class="module" id="mod-credits" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-credits')" ondragover="dragOver(event)" ondrop="drop(event,'mod-credits')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">💳 GCP Kreditter</span>
<span class="module-sub" id="credits-sub">Laster...</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="fetchCredits()" title="Oppdater kreditter">
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
</button>
<button class="module-btn" onclick="toggleModule('mod-credits')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<!-- 4 KPI-kort -->
<div class="credits-kpi-row" style="margin-bottom:16px">
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Kreditter brukt</span>
<div class="kpi-icon ki-blue"><span class="material-symbols-outlined">credit_card</span></div>
</div>
<div class="kpi-val" id="cr-used"></div>
<div class="kpi-delta" id="cr-used-pct">av total</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Gjenstår</span>
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">savings</span></div>
</div>
<div class="kpi-val" id="cr-remaining"></div>
<div class="kpi-delta">estimert saldo</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Daglig burn</span>
<div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">local_fire_department</span></div>
</div>
<div class="kpi-val" id="cr-burn"></div>
<div class="kpi-delta">USD/dag (snitt 7d)</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Runway</span>
<div class="kpi-icon ki-red" id="cr-runway-icon"><span class="material-symbols-outlined">schedule</span></div>
</div>
<div class="kpi-val" id="cr-runway"></div>
<div class="kpi-delta" id="cr-exhaustion">dager igjen</div>
</div>
</div>
</div>
<!-- Runway-bar -->
<div class="runway-bar-wrap">
<div class="runway-label">
<span>Kredittforbruk</span>
<span id="cr-bar-label">$0 / $—</span>
</div>
<div class="runway-bar-bg">
<div class="runway-bar-fill" id="cr-bar-fill" style="width:0%;background:var(--blue)"></div>
</div>
</div>
<!-- Advarsel-banner -->
<div class="credits-warning" id="cr-warning">
<span class="material-symbols-outlined" style="font-size:16px">warning</span>
<span id="cr-warning-msg"></span>
</div>
<!-- Kreditter per type -->
<div id="cr-types" style="margin-top:14px"></div>
</div>
</div>
<!-- ═══ /CG4-CREDITS ═══ -->
<!-- Charts-rad -->
<div class="module" id="mod-charts" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-charts')" ondragover="dragOver(event)" ondrop="drop(event,'mod-charts')">
@ -382,7 +481,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<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" id="gauge-budget-total">av kr 500,00 budsjett</div>
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenbstår</div>
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenstår</div>
</div>
</div>
</div>
@ -390,7 +489,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
</div>
</div>
<!-- Anomalier (CG6) -->
<!-- Anomalier -->
<div class="module" id="mod-anomalies" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-anomalies')" ondragover="dragOver(event)" ondrop="drop(event,'mod-anomalies')">
<div class="module-handle-left">
@ -411,7 +510,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
</div>
</div>
<!-- Anbefalinger (CG4) -->
<!-- Anbefalinger -->
<div class="module" id="mod-recommendations" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-recommendations')" ondragover="dragOver(event)" ondrop="drop(event,'mod-recommendations')">
<div class="module-handle-left">
@ -432,7 +531,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
</div>
</div>
<!-- Tokenbruk (CG3e) -->
<!-- Tokenbruk -->
<div class="module" id="mod-tokens" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-tokens')" ondragover="dragOver(event)" ondrop="drop(event,'mod-tokens')">
<div class="module-handle-left">
@ -488,14 +587,13 @@ let currentBudget = parseFloat(localStorage.getItem(LS_BUDGET) || '500');
let barChart, donutChart, period = 30;
let currentData = null;
// TODO Fase-C: erstatt med Firestore per-bruker
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}];
const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,total_monthly_forecast:3.52,remaining_days_in_month:21};
const DEMO_ANOMALIES = [];
const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'];
function fmtNOK(v,d=2){return 'kr '+Math.abs(Number(v)||0).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d});}
function fmtUSD(v,d=2){return '$'+(Math.abs(Number(v)||0)).toLocaleString('en-US',{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*Math.abs(end));if(p<1)requestAnimationFrame(go);};requestAnimationFrame(go);}
function serviceColor(i){return SVC_COLORS[i%SVC_COLORS.length];}
@ -516,7 +614,7 @@ async function checkAuth(){
setInterval(fetchAll,REFRESH_MS);
}
// ── budget (localStorage) ─────────────────────────────────────────────────
// ── budget ────────────────────────────────────────────────────────────────
function openBudgetModal(){document.getElementById('budget-input').value=currentBudget;document.getElementById('budget-modal').style.display='flex';}
function closeBudgetModal(){document.getElementById('budget-modal').style.display='none';}
function saveBudget(){
@ -528,7 +626,7 @@ function saveBudget(){
if(currentData)buildGauge(currentData.forecast.month_to_date_cost||0);
}
// ── module drag & drop ────────────────────────────────────────────────────
// ── drag & drop ───────────────────────────────────────────────────────────
let dragSrc=null;
function dragStart(e,id){dragSrc=id;e.dataTransfer.effectAllowed='move';document.getElementById(id).classList.add('dragging');}
document.addEventListener('dragend',()=>{document.querySelectorAll('.module').forEach(m=>{m.classList.remove('dragging','drag-over');});saveOrder();});
@ -552,10 +650,8 @@ function saveOrder(){
localStorage.setItem(LS_ORDER,JSON.stringify(ids));
}
function restoreLayout(){
// Restore collapsed state
const collapsed=JSON.parse(localStorage.getItem(LS_COLLAPSED)||'[]');
collapsed.forEach(id=>{const m=document.getElementById(id);if(m)m.classList.add('collapsed');});
// Restore order
const order=JSON.parse(localStorage.getItem(LS_ORDER)||'[]');
if(!order.length)return;
const grid=document.getElementById('module-grid');
@ -599,6 +695,111 @@ async function fetchAll(){
renderAll();
fetchTokens();
fetchRecommendations();
fetchCredits(); // CG4-credits: hentes parallelt med alt annet
}
// ── CG4-credits: fetchCredits ─────────────────────────────────────────────
async function fetchCredits(){
document.getElementById('credits-sub').textContent='Oppdaterer...';
try{
const r = await apiFetch(API_BASE+'/billing/credits?days=90');
if(!r.ok) throw new Error('HTTP '+r.status);
const d = await r.json();
renderCredits(d);
} catch(e){
document.getElementById('credits-sub').textContent='Ikke tilgjengelig';
document.getElementById('cr-used').textContent='—';
document.getElementById('cr-remaining').textContent='—';
document.getElementById('cr-burn').textContent='—';
document.getElementById('cr-runway').textContent='—';
document.getElementById('cr-exhaustion').textContent=e.message;
console.warn('[credits]',e.message);
}
}
function renderCredits(d){
// --- KPI-tall ---
const used = d.credits_used_total_usd ?? 0;
const remaining = d.credits_remaining_usd ?? null;
const total = d.credit_total_usd ?? null;
const burn = d.daily_credit_burn_usd ?? 0;
const runway = d.credit_runway_days ?? null;
const exhausted = d.credit_exhaustion_date ?? null;
const pct = total ? Math.min(used/total,1) : null;
// subtitle
document.getElementById('credits-sub').textContent =
runway != null ? `Runway: ${runway} dager` : 'Sett GOOGLE_CREDIT_TOTAL_USD for runway';
// KPI: brukt
animN(document.getElementById('cr-used'), used, v => fmtUSD(v));
document.getElementById('cr-used-pct').textContent =
(pct!=null) ? `${Math.round(pct*100)}% av ${fmtUSD(total,0)}` : 'total ukjent — sett env-var';
// KPI: gjenstår
if(remaining!=null){
animN(document.getElementById('cr-remaining'), remaining, v => fmtUSD(v));
} else {
document.getElementById('cr-remaining').textContent = '—';
}
// KPI: daglig burn
animN(document.getElementById('cr-burn'), burn, v => fmtUSD(v,4));
// KPI: runway
const runwayEl = document.getElementById('cr-runway');
const iconEl = document.getElementById('cr-runway-icon');
const exhaustEl = document.getElementById('cr-exhaustion');
if(runway!=null){
animN(runwayEl, runway, v => Math.round(v)+' d');
exhaustEl.textContent = exhausted ? 'tom ca. '+exhausted : 'dager igjen';
// Ikon-farge basert på kritikalitet
iconEl.className = 'kpi-icon ' + (runway<30 ? 'ki-red' : runway<60 ? 'ki-yellow' : 'ki-green');
} else {
runwayEl.textContent = '—';
exhaustEl.textContent = 'sett GOOGLE_CREDIT_TOTAL_USD';
iconEl.className = 'kpi-icon ki-blue';
}
// --- Runway-bar ---
const fill = document.getElementById('cr-bar-fill');
const barLbl= document.getElementById('cr-bar-label');
if(pct!=null){
setTimeout(()=>fill.style.width=(pct*100).toFixed(1)+'%',80);
fill.style.background = pct>0.8 ? 'var(--red)' : pct>0.6 ? 'var(--yellow)' : 'var(--blue)';
barLbl.textContent = fmtUSD(used)+' / '+fmtUSD(total,0);
} else {
fill.style.width='0%';
barLbl.textContent = fmtUSD(used)+' brukt';
}
// --- Advarsel-banner ---
const warn = document.getElementById('cr-warning');
const warnMsg = document.getElementById('cr-warning-msg');
const rawWarn = d.warning ?? '';
if(rawWarn){
warnMsg.textContent = rawWarn;
warn.className = 'credits-warning show ' + (runway!=null && runway<30 ? 'crit' : 'med');
} else {
warn.className = 'credits-warning';
}
// --- Kreditter per type (credits_by_type) ---
const types = d.credits_by_type ?? [];
const typesEl = document.getElementById('cr-types');
if(types.length){
typesEl.innerHTML =
'<div style="font-size:11px;font-weight:500;color:var(--muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.06em">Kreditter per type</div>' +
types.map(t =>
`<div class="svc-row">
<div class="svc-dot" style="background:var(--blue)"></div>
<span class="svc-name">${t.type??t.credit_type??'Ukjent'}</span>
<span class="svc-cost">${fmtUSD(t.amount??t.total_amount??0)}</span>
</div>`
).join('');
} else {
typesEl.innerHTML = '';
}
}
// ── render ────────────────────────────────────────────────────────────────
@ -627,7 +828,6 @@ function buildBar(history){
}
document.getElementById('bar-empty').style.display='none';
document.getElementById('bar-chart').style.display='block';
// Daglig delta fra MTD
const labels=[],costs=[];
history.forEach((e,i)=>{
const d=new Date(e.date);
@ -705,7 +905,7 @@ function buildGauge(mtd){
document.getElementById('gauge-budget-total').textContent=`av ${fmtNOK(currentBudget,0)} budsjett`;
const r=currentBudget-mtd;
const rel=document.getElementById('gauge-remain');
rel.textContent=r>0?fmtNOK(r)+' gjenbstår':'Budsjett overskredet';
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
rel.style.color=r<0?'var(--red)':'var(--muted)';
}
@ -718,7 +918,7 @@ function updateKPIs(forecast){
if(daily>0)animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v,4));
else document.getElementById('k-daily').textContent='kr —';
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
document.getElementById('k-fc-d').textContent=left+' dager gjenbstår';
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
buildGauge(mtd);
}
@ -732,7 +932,7 @@ function exportCSV(){
document.body.appendChild(a);a.click();document.body.removeChild(a);
}
// ── anbefalinger (CG4) ────────────────────────────────────────────────────
// ── anbefalinger ──────────────────────────────────────────────────────────
function fetchRecommendations(){
const ICONS={high:'🔴',medium:'🟡',low:'🟢',anomaly:'⚡',waste:'♻️',budget:'💸'};
fetch(API_BASE+'/billing/recommendations')
@ -757,7 +957,7 @@ function fetchRecommendations(){
.catch(()=>{document.getElementById('recommendations-body').innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger tilgjengelig</div>';});
}
// ── tokenbruk (CG3e) ──────────────────────────────────────────────────────
// ── tokenbruk ─────────────────────────────────────────────────────────────
function fetchTokens(){
fetch(API_BASE+'/billing/tokens/summary')
.then(r=>r.ok?r.json():Promise.reject(r.status))