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
+
+
+
@@ -347,21 +373,21 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
-
Kreditter brukt
+
Totalt brukt
credit_card
—
-
av total
+
siste 90 dager
-
Gjenstår
-
savings
+
Infra gjenstår
+
timer
-
—
-
estimert saldo
+
—
+
Free Trial
@@ -371,40 +397,29 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
local_fire_department
—
-
USD/dag (snitt 7d)
+
NOK/dag (snitt 7d)
-
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 ``;
+ }).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');
}
}