feat(CG4-credits): credits-panel i billing-dashboard + auto-poll hvert 5 min
This commit is contained in:
parent
af1d546c9b
commit
f58ec09d4c
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user