@@ -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 =
+ '
Kreditter per type
' +
+ types.map(t =>
+ `
+
+
${t.type??t.credit_type??'Ukjent'}
+
${fmtUSD(t.amount??t.total_amount??0)}
+
`
+ ).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='
check_circleIngen anbefalinger tilgjengelig
';});
}
-// ── tokenbruk (CG3e) ──────────────────────────────────────────────────────
+// ── tokenbruk ─────────────────────────────────────────────────────────────
function fetchTokens(){
fetch(API_BASE+'/billing/tokens/summary')
.then(r=>r.ok?r.json():Promise.reject(r.status))