From c114f6c32800c5de63430775e6d8fc7f23fd07d4 Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Sat, 13 Jun 2026 07:35:37 +0200 Subject: [PATCH] feat(dashboard): three separate credit pool bars, NOK display, Free Trial expiry banner --- static/billing-dashboard.html | 280 ++++++++++++++++++++-------------- 1 file changed, 166 insertions(+), 114 deletions(-) diff --git a/static/billing-dashboard.html b/static/billing-dashboard.html index 0bc4615..b8cff75 100644 --- a/static/billing-dashboard.html +++ b/static/billing-dashboard.html @@ -78,6 +78,24 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14 /* ── credits kpi row ── */ .credits-kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px} +/* ── credit pool bars ── */ +.credit-pools{display:flex;flex-direction:column;gap:14px;margin-top:16px} +.pool-item{display:flex;flex-direction:column;gap:6px} +.pool-header{display:flex;align-items:center;justify-content:space-between;font-size:12px} +.pool-label{font-weight:500;color:var(--text);display:flex;align-items:center;gap:6px} +.pool-badge{font-size:10px;font-weight:600;padding:2px 7px;border-radius:99px} +.pool-badge.crit{background:var(--red-bg);color:var(--red)} +.pool-badge.warn{background:var(--yellow-bg);color:var(--yellow)} +.pool-badge.ok{background:var(--green-bg);color:var(--green)} +.pool-badge.inactive{background:var(--surface-3);color:var(--muted)} +.pool-meta{font-size:11px;color:var(--muted);font-family:var(--mono)} +.pool-bar-bg{height:6px;background:var(--surface-3);border-radius:3px;overflow:hidden} +.pool-bar-fill{height:100%;border-radius:3px;transition:width 1.2s cubic-bezier(.16,1,.3,1)} +.pool-warning{font-size:11px;padding:6px 10px;border-radius:6px;margin-top:2px;display:none} +.pool-warning.show{display:flex;align-items:center;gap:6px} +.pool-warning.crit{background:var(--red-bg);color:var(--red)} +.pool-warning.warn{background:var(--yellow-bg);color:var(--yellow)} + /* ── draggable module ── */ .module{ position:relative; @@ -125,11 +143,12 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14 .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)} +/* ── free trial expiry banner (topbar) ── */ +.freetrial-banner{display:none;width:100%;background:var(--red-bg);border-bottom:1px solid rgba(255,92,124,.25);padding:7px 24px;font-size:12px;color:var(--red);align-items:center;gap:8px;position:sticky;top:56px;z-index:190} +.freetrial-banner.show{display:flex} +.freetrial-banner .material-symbols-outlined{font-size:15px;animation:blink 1.2s ease-in-out infinite} + +/* ── credits warning ── */ .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)} @@ -194,6 +213,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente .kpi-row{grid-template-columns:1fr 1fr;gap:8px} .credits-kpi-row{grid-template-columns:1fr 1fr;gap:8px} .page-title{font-size:18px} + .freetrial-banner{top:auto;position:relative} } @media(max-width:420px){.kpi-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:1fr}} @media print{ @@ -205,6 +225,12 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente + +
+ warning + +
+
- Runway -
schedule
+ Vertex gjenstår +
bolt
-
-
dager igjen
+
+
Gen App Builder
- -
-
- Kredittforbruk - $0 / $— -
-
-
-
-
+ +
warning
- - -
@@ -592,12 +607,22 @@ const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,to const DEMO_ANOMALIES = []; const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd']; +const POOL_COLORS = ['#ff5c7c','#7c5cff','#00d4a8']; + 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];} -async function apiFetch(url,opts={}){const r=await fetch(url,opts);if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});}return r;} +async function apiFetch(url,opts={}){ + try{ + const r=await fetch(url,opts); + if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});} + return r; + } catch(e) { + console.warn('apiFetch error:',url,e.message); + throw e; + } +} // ── auth ────────────────────────────────────────────────────────────────── async function checkAuth(){ @@ -665,37 +690,38 @@ function toggleModule(id){ localStorage.setItem(LS_COLLAPSED,JSON.stringify(collapsed)); } -// ── fetch ───────────────────────────────────────────────────────────────── +// ── fetch (med retry-fallback) ──────────────────────────────────────────── async function fetchAll(){ const dot=document.getElementById('live-dot'); dot.style.background='var(--warn)'; - try{ - const [sr,fr,ar,hr,svcr] = await Promise.all([ - apiFetch(API_BASE+'/billing/summary'), - apiFetch(API_BASE+'/billing/live'), - apiFetch(API_BASE+'/billing/anomalies'), - apiFetch(API_BASE+'/billing/history'), - apiFetch(API_BASE+'/billing/by-service'), - ]); - if(!sr.ok||!fr.ok) throw new Error('Core endpoints failed'); - const [s,f] = await Promise.all([sr.json(),fr.json()]); - const a = ar.ok ? await ar.json() : {anomalies:[]}; - const h = hr.ok ? await hr.json() : {history:[]}; - const svc = svcr.ok ? await svcr.json() : []; + const results = await Promise.allSettled([ + apiFetch(API_BASE+'/billing/summary'), + apiFetch(API_BASE+'/billing/live'), + apiFetch(API_BASE+'/billing/anomalies'), + apiFetch(API_BASE+'/billing/history'), + apiFetch(API_BASE+'/billing/by-service'), + ]); + const [sr,fr,ar,hr,svcr] = results; + const coreOk = sr.status==='fulfilled'&&sr.value.ok && fr.status==='fulfilled'&&fr.value.ok; + if(!coreOk){ + console.warn('Core endpoints feilet, bruker demo-data'); + currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES,history:[],serviceGroups:null}; + document.getElementById('err-msg').textContent='API utilgjengelig — viser demo-data'; + document.getElementById('err-banner').classList.add('show'); + dot.style.background='var(--red)'; + } else { + const [s,f] = await Promise.all([sr.value.json(),fr.value.json()]); + const a = ar.status==='fulfilled'&&ar.value.ok ? await ar.value.json() : {anomalies:[]}; + const h = hr.status==='fulfilled'&&hr.value.ok ? await hr.value.json() : {history:[]}; + const svc = svcr.status==='fulfilled'&&svcr.value.ok ? await svcr.value.json() : []; currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[],history:h.history||[],serviceGroups:svc}; 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,history:[],serviceGroups:null}; - document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message; - document.getElementById('err-banner').classList.add('show'); - dot.style.background='var(--red)'; } renderAll(); fetchTokens(); fetchRecommendations(); - fetchCredits(); // CG4-credits: hentes parallelt med alt annet + fetchCredits(); } // ── CG4-credits: fetchCredits ───────────────────────────────────────────── @@ -709,96 +735,122 @@ async function fetchCredits(){ } catch(e){ document.getElementById('credits-sub').textContent='Ikke tilgjengelig'; document.getElementById('cr-used').textContent='—'; - document.getElementById('cr-remaining').textContent='—'; + document.getElementById('cr-infra-remaining').textContent='—'; + document.getElementById('cr-vertex-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; + const pools = d.credit_pools ?? []; + const usedTotal = d.credits_used_total_nok ?? 0; + const dailyBurn = d.daily_credit_burn_nok ?? 0; + const topWarning = d.warning ?? ''; - // subtitle + // Subtitle + const anyRunway = pools.find(p=>p.runway_days!=null); document.getElementById('credits-sub').textContent = - runway != null ? `Runway: ${runway} dager` : 'Sett GOOGLE_CREDIT_TOTAL_USD for runway'; + anyRunway ? `Infra runway: ${anyRunway.runway_days} dager` : 'Kreditter lastet'; - // 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: totalt brukt + animN(document.getElementById('cr-used'), usedTotal, v => fmtNOK(v)); - // KPI: gjenstår - if(remaining!=null){ - animN(document.getElementById('cr-remaining'), remaining, v => fmtUSD(v)); + // KPI: Infra gjenstår (Free Trial) + const infraPool = pools.find(p=>p.env_key==='GOOGLE_CREDIT_INFRA_NOK'); + if(infraPool && infraPool.remaining_nok!=null){ + animN(document.getElementById('cr-infra-remaining'), infraPool.remaining_nok, v=>fmtNOK(v)); + document.getElementById('cr-infra-days').textContent = + infraPool.expires_days!=null ? `⚠️ Utløper om ${infraPool.expires_days}d` : 'Free Trial'; } else { - document.getElementById('cr-remaining').textContent = '—'; + document.getElementById('cr-infra-remaining').textContent='—'; } // KPI: daglig burn - animN(document.getElementById('cr-burn'), burn, v => fmtUSD(v,4)); + animN(document.getElementById('cr-burn'), dailyBurn, v=>fmtNOK(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'); + // KPI: Vertex gjenstår + const vertexPool = pools.find(p=>p.env_key==='GOOGLE_CREDIT_VERTEX_NOK'); + if(vertexPool && vertexPool.remaining_nok!=null){ + animN(document.getElementById('cr-vertex-remaining'), vertexPool.remaining_nok, v=>fmtNOK(v)); } else { - runwayEl.textContent = '—'; - exhaustEl.textContent = 'sett GOOGLE_CREDIT_TOTAL_USD'; - iconEl.className = 'kpi-icon ki-blue'; + document.getElementById('cr-vertex-remaining').textContent='—'; } - // --- 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); + // Tre pool-bars + const poolsEl = document.getElementById('cr-pools'); + if(pools.length){ + poolsEl.innerHTML = pools.map((pool, i) => { + const color = POOL_COLORS[i] || '#7c5cff'; + const pct = (pool.total_nok && pool.used_nok) + ? Math.min(pool.used_nok / pool.total_nok * 100, 100).toFixed(1) + : 0; + const barColor = pool.expires_days!=null&&pool.expires_days<=30 + ? 'var(--red)' + : pct>80 ? 'var(--red)' : pct>60 ? 'var(--yellow)' : color; + + let badgeClass='ok', badgeTxt='OK'; + if(!pool.configured){badgeClass='inactive';badgeTxt='Ikke satt';} + else if(pool.expires_days!=null&&pool.expires_days<=30){badgeClass='crit';badgeTxt=`${pool.expires_days}d`;} + else if(pool.runway_days!=null&&pool.runway_days<30){badgeClass='crit';badgeTxt=`${pool.runway_days}d`;} + else if(pool.runway_days!=null&&pool.runway_days<60){badgeClass='warn';badgeTxt=`${pool.runway_days}d`;} + + const metaTxt = pool.total_nok + ? `${fmtNOK(pool.used_nok)} brukt av ${fmtNOK(pool.total_nok)} — ${fmtNOK(pool.remaining_nok??0)} gjenstår` + : 'Ikke konfigurert'; + + const warnHtml = pool.warning + ? `
+ warning + ${pool.warning} +
` + : ''; + + return `
+
+ + + ${pool.label} + ${badgeTxt} + + ${metaTxt} +
+
+
+
+ ${warnHtml} +
`; + }).join(''); + // Animate bars + setTimeout(()=>{ + poolsEl.querySelectorAll('.pool-bar-fill').forEach(el=>{ + const target = el.dataset.pct; + el.style.width = '0%'; + requestAnimationFrame(()=>{ el.style.width = target+'%'; }); + }); + }, 80); } else { - fill.style.width='0%'; - barLbl.textContent = fmtUSD(used)+' brukt'; + poolsEl.innerHTML = '
credit_cardIngen kredittdata
'; } - // --- Advarsel-banner --- + // Global 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'); + if(topWarning){ + warnMsg.textContent = topWarning; + warn.className = 'credits-warning show crit'; } 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(''); + // Free Trial topbar-banner + const ftBanner = document.getElementById('freetrial-banner'); + const ftMsg = document.getElementById('freetrial-msg'); + if(infraPool && infraPool.expires_days!=null && infraPool.expires_days<=30){ + ftMsg.textContent = `⚠️ Free Trial utløper om ${infraPool.expires_days} dager (${fmtNOK(infraPool.remaining_nok??0)} gjenstår) — aktiver fakturering for å unngå driftsstopp!`; + ftBanner.classList.add('show'); } else { - typesEl.innerHTML = ''; + ftBanner.classList.remove('show'); } }