diff --git a/static/opax.html b/static/opax.html
index fa29af6..a8676dc 100644
--- a/static/opax.html
+++ b/static/opax.html
@@ -688,22 +688,16 @@ function renderTierGrid(spend){
function updatePricing(val){
const spend = Number(val)
const tier = activeTier(spend)
-
document.getElementById('spend-display').textContent = fmtUSD(spend)
-
renderTierGrid(spend)
-
const savings = spend * 0.15
const net_monthly = savings - tier.price
const net_annual = net_monthly * 12
const multiplier = tier.price > 0 ? (savings / tier.price) : 0
-
document.getElementById('roi-savings').textContent = fmtUSD(Math.round(savings))
document.getElementById('roi-savings-sub').textContent = '15% av ' + fmtUSD(spend) + ' via optimalisering og varsler'
-
document.getElementById('roi-multiplier').textContent = multiplier.toFixed(2) + '×'
document.getElementById('roi-multiplier-sub').textContent = 'per måned · basert på ' + fmtUSD(tier.price) + '/mnd'
-
const annualEl = document.getElementById('roi-annual')
if(net_annual >= 0){
annualEl.textContent = fmtUSD(Math.round(net_annual))
@@ -762,9 +756,7 @@ function browserRefresh(){
const f = document.getElementById('browser-frame')
if(f.src) f.src = f.src
}
-function browserFrameLoad(){
- document.getElementById('browser-error').classList.remove('show')
-}
+function browserFrameLoad(){ document.getElementById('browser-error').classList.remove('show') }
function browserFrameError(){
document.getElementById('browser-error-msg').textContent = 'Siden kunne ikke lastes — sjekk URL eller /docs/-proxy'
document.getElementById('browser-error').classList.add('show')
@@ -789,24 +781,45 @@ async function pollBuild(){
pollBuild()
setInterval(pollBuild,30000)
-// BILLING
+// ─── Fix 3: CostGuard — fetchWithTimeout helper (AbortController, 10s) ───
+function fetchWithTimeout(url, ms=10000){
+ const ctrl = new AbortController()
+ const timer = setTimeout(()=>ctrl.abort(), ms)
+ return fetch(url, { signal: ctrl.signal })
+ .then(r=>{ clearTimeout(timer); return r })
+ .catch(e=>{ clearTimeout(timer); throw e })
+}
+
+// BILLING — Fix 3: /billing/credits?days=30 + AbortController 10s + fallback
async function loadBilling(){
const el=document.getElementById('billing-body')
el.innerHTML='
'
try{
- const [sum,anm,mod] = await Promise.all([
- fetch('/billing/summary').then(r=>r.json()).catch(()=>null),
- fetch('/billing/anomalies').then(r=>r.json()).catch(()=>null),
- fetch('/billing/tokens/by-module').then(r=>r.json()).catch(()=>null),
+ const [sum, anm, mod, cred] = await Promise.all([
+ fetchWithTimeout('/billing/summary')
+ .then(r=>r.ok?r.json():null).catch(()=>null),
+ fetchWithTimeout('/billing/anomalies')
+ .then(r=>r.ok?r.json():null).catch(()=>null),
+ fetchWithTimeout('/billing/tokens/by-module')
+ .then(r=>r.ok?r.json():null).catch(()=>null),
+ fetchWithTimeout('/billing/credits?days=30')
+ .then(r=>r.ok?r.json():null).catch(()=>null),
])
+
const total = sum?.total_tokens ?? '—'
- const cost = sum?.estimated_cost_usd!=null?'$'+Number(sum.estimated_cost_usd).toFixed(4):'—'
- const budg = sum?.budget_limit!=null?'$'+sum.budget_limit:'—'
+ const cost = sum?.estimated_cost_usd!=null ? '$'+Number(sum.estimated_cost_usd).toFixed(4) : '—'
+ const budg = sum?.budget_limit!=null ? '$'+sum.budget_limit : '—'
+ const credVal = cred?.credits_remaining!=null
+ ? '$'+Number(cred.credits_remaining).toFixed(2)
+ : 'Ingen data tilgjengelig'
+
let h = `
Totale tokens (30d)
${typeof total==='number'?total.toLocaleString('no'):total}
Alle moduler
Estimert kostnad
${cost}
Vertex AI · Gemini 2.5 Pro
Budsjettgrense
${budg}
Månedlig maks
+
Credits igjen (30d)
${esc(credVal)}
/billing/credits?days=30
`
+
const anoms = anm?.anomalies||[]
if(anoms.length){
h+='⚠️ Anomalier
'
@@ -816,6 +829,7 @@ async function loadBilling(){
})
h+='
'
}else{ h+='✅ Ingen anomalier
' }
+
const mods = mod?.modules||mod?.data||[]
const cols = ['#7c5cff','#00d4a8','#ffb547','#ff5c7c','#3ecf8e','#5c8fff','#ff9f7c','#c084fc']
if(mods.length){
@@ -823,15 +837,15 @@ async function loadBilling(){
h+='| Modul | Tokens | Input | Output |
'
mods.forEach((m,i)=>{ h+=`| ${esc(m.module||m.name||'—')} | ${(m.total_tokens||0).toLocaleString('no')} | ${(m.input_tokens||0).toLocaleString('no')} | ${(m.output_tokens||0).toLocaleString('no')} |
` })
h+='
'
- h+=`
-
-
`
- el.innerHTML = h
- renderDonut(mods, cols)
+ h+=``
+ el.innerHTML=h
+ renderDonut(mods,cols)
return
}
el.innerHTML=h
- }catch(e){ el.innerHTML=`` }
+ }catch(e){
+ el.innerHTML=``
+ }
}
function renderDonut(mods, cols) {
@@ -858,15 +872,11 @@ function renderDonut(mods, cols) {
labels: {
color: '#8b909c',
font: { size: 11, family: '-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif' },
- padding: 14,
- boxWidth: 10,
- boxHeight: 10,
+ padding: 14, boxWidth: 10, boxHeight: 10,
}
},
tooltip: {
- callbacks: {
- label: ctx => ` ${ctx.label}: ${ctx.parsed.toLocaleString('no')} tokens`
- }
+ callbacks: { label: ctx => ` ${ctx.label}: ${ctx.parsed.toLocaleString('no')} tokens` }
}
}
}
@@ -966,7 +976,6 @@ async function openMd(path, name){
const res = await fetch('/docs/'+path+'?t='+Date.now())
if(!res.ok) throw new Error('HTTP '+res.status)
const raw = await res.text()
- // Fix 2: bruk marked.js i stedet for hjemmelaget renderMd()
viewer.innerHTML = marked.parse(raw)
}catch(e){
viewer.innerHTML=`❌ Kunne ikke laste: ${esc(e.message)}
`