diff --git a/static/billing-dashboard.html b/static/billing-dashboard.html index 1ee38f8..a0b1f1a 100644 --- a/static/billing-dashboard.html +++ b/static/billing-dashboard.html @@ -86,13 +86,19 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14 .card-sub{font-size:12px;color:var(--muted)} .chart-wrap{position:relative;height:200px} .bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px} -.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)} +.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);cursor:pointer;user-select:none} .svc-row:last-child{border-bottom:none} +.svc-row.expanded{background:var(--surface-3);border-radius:8px;padding:8px 6px} .svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} .svc-name{font-size:13px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .svc-bar-w{width:80px;height:2px;background:var(--surface-3);border-radius:2px;flex-shrink:0} .svc-bar{height:100%;border-radius:2px;transition:width .8s cubic-bezier(.16,1,.3,1)} .svc-cost{font-family:var(--mono);font-size:12px;color:var(--muted);width:60px;text-align:right;flex-shrink:0} +.svc-chevron{font-size:14px!important;transition:transform 0.15s;color:var(--muted);flex-shrink:0} +.svc-row.expanded .svc-chevron{transform:rotate(90deg)} +.svc-skus{display:none;padding:4px 0 4px 28px;border-left:2px solid var(--surface-3);margin:0 0 6px 18px} +.svc-skus.open{display:block} +.sku-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);padding:3px 0} .gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 0} .gauge-ring{position:relative;width:130px;height:72px;overflow:hidden} .gauge-ring svg{position:absolute;top:0;left:0} @@ -382,8 +388,8 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
-
Kostnad per tjenesteRangert
-
+
Kostnad per tjenesteKlikk for detaljer
+
@@ -487,7 +493,6 @@ async function apiFetch(url, options = {}) { } function switchProvider(provider) { - // AWS-støtte er parkert; vi holder oss på GCP currentProvider = 'gcp'; localStorage.setItem('costguard_provider', 'gcp'); document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => { @@ -501,11 +506,9 @@ async function checkAuth() { const response = await apiFetch(API_BASE + '/auth/me'); if (!response.ok) throw new Error('Not logged in'); userProfile = await response.json(); - document.getElementById('user-name').textContent = userProfile.name; document.getElementById('user-avatar').src = userProfile.picture; document.getElementById('user-profile').style.display = 'flex'; - switchProvider(currentProvider); await fetchBudget(); setInterval(fetchAll, REFRESH_MS); @@ -532,7 +535,6 @@ async function saveBudget() { const originalText = btn.textContent; btn.textContent = 'Lagrer...'; btn.disabled = true; - const input = document.getElementById('budget-input'); const newBudgetValue = parseFloat(input.value); if (isNaN(newBudgetValue) || newBudgetValue <= 0) { @@ -541,7 +543,6 @@ async function saveBudget() { btn.disabled = false; return; } - try { const response = await apiFetch(API_BASE + '/billing/budget', { method: 'POST', @@ -573,98 +574,87 @@ function closeBudgetModal() { async function fetchAll(){ const dot=document.getElementById('live-dot'); dot.style.background='var(--warn)'; - try { - const gcpSummary = apiFetch(API_BASE+'/billing/summary'); + const gcpSummary = apiFetch(API_BASE+'/billing/summary'); const gcpForecast = apiFetch(API_BASE+'/billing/live'); - const gcpAnomalies = apiFetch(API_BASE+'/billing/anomalies'); - const gcpHistory = apiFetch(API_BASE+'/billing/history'); + const gcpAnomalies= apiFetch(API_BASE+'/billing/anomalies'); + const gcpHistory = apiFetch(API_BASE+'/billing/history'); + const gcpServices = apiFetch(API_BASE+'/billing/by-service'); - const [gcpSR, gcpLR, gcpAR, gcpHR] = await Promise.all([gcpSummary, gcpForecast, gcpAnomalies, gcpHistory]); + const [gcpSR,gcpLR,gcpAR,gcpHR,gcpSvcR] = await Promise.all([gcpSummary,gcpForecast,gcpAnomalies,gcpHistory,gcpServices]); - // GCP summary/forecast/anomalies er kritiske; history er nice-to-have - if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok) { - throw new Error('HTTP error on core billing endpoints'); - } + if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok) throw new Error('HTTP error on core billing endpoints'); - const [gcpS, gcpL, gcpA] = await Promise.all([gcpSR.json(), gcpLR.json(), gcpAR.json()]); + const [gcpS,gcpL,gcpA] = await Promise.all([gcpSR.json(),gcpLR.json(),gcpAR.json()]); let gcpH = { history: [] }; - if (gcpHR.ok) { - try { - gcpH = await gcpHR.json(); - } catch (e) { - console.warn('Could not parse history JSON:', e.message); - } - } else { - console.warn('History endpoint returned', gcpHR.status); - } + if (gcpHR.ok) { try { gcpH = await gcpHR.json(); } catch(e){ console.warn('history parse error',e); } } - currentData.gcp = { summary: gcpS.summary || [], forecast: gcpL, anomalies: gcpA.anomalies || [], history: gcpH.history || [] }; + let gcpSvcData = null; + if (gcpSvcR.ok) { try { gcpSvcData = await gcpSvcR.json(); } catch(e){ console.warn('by-service parse error',e); } } + + currentData.gcp = { + summary: gcpS.summary || [], + forecast: gcpL, + anomalies: gcpA.anomalies || [], + history: gcpH.history || [], + serviceGroups: gcpSvcData || null + }; document.getElementById('err-banner').classList.remove('show'); dot.style.background='var(--ok)'; } catch(err) { console.warn('API feil, demo-data:',err.message); if (err.message.includes("Not logged in")) return; - currentData.gcp = { summary: DEMO_SUMMARY, forecast: DEMO_FORECAST, anomalies: DEMO_ANOMALIES, history: [] }; + currentData.gcp = { summary: DEMO_SUMMARY, forecast: DEMO_FORECAST, anomalies: DEMO_ANOMALIES, history: [], serviceGroups: null }; document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message+' — demo-data vises.'; document.getElementById('err-banner').classList.add('show'); dot.style.background='var(--red)'; } - renderAll(); } function getVisibleData() { - // AWS er parkert; all visning baseres på GCP-data return currentData.gcp; } function calculateDailyCosts(history) { - if (!history || history.length === 0) { - return { labels: [], costs: [] }; - } - - const dailyCosts = []; - const labels = []; - + if (!history || history.length === 0) return { labels: [], costs: [] }; + const dailyCosts = [], labels = []; for (let i = 0; i < history.length; i++) { const entry = history[i]; const currentMtd = entry.mtd || 0; - let dailyCost; - const entryDate = new Date(entry.date); - if (entryDate.getDate() === 1) { - dailyCost = currentMtd; - } else { - const prevDayEntry = history[i-1]; - const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0; - dailyCost = currentMtd - prevMtd; - } - - dailyCosts.push(Math.max(0, dailyCost)); - labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'})); + const prevMtd = (i > 0 && entryDate.getDate() !== 1) ? (history[i-1].mtd || 0) : 0; + dailyCosts.push(Math.max(0, currentMtd - prevMtd)); + labels.push(entryDate.toLocaleDateString('no-NO', {day:'numeric',month:'short'})); } - return { labels, costs: dailyCosts }; } +const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd']; +function serviceColor(name, i) { + if (i !== undefined) return SVC_COLORS[i % SVC_COLORS.length]; + let h = 0; + for (let c of name) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff; + return SVC_COLORS[Math.abs(h) % SVC_COLORS.length]; +} + function renderAll(){ const data = getVisibleData(); if (!data) return; - - const{summary,forecast,anomalies,history}=data; - + const {summary, forecast, anomalies, history, serviceGroups} = data; const { labels, costs } = calculateDailyCosts(history); - buildBar(labels, costs); buildDonut(summary); - buildSvc(summary); - buildAnom(anomalies); - if (forecast) { - updateKPIs(forecast, anomalies.length); + // CG5: bruk serviceGroups fra /billing/by-service hvis tilgjengelig, ellers fallback til summary + if (serviceGroups && serviceGroups.length) { + renderServiceRows(serviceGroups); + } else { + buildSvc(summary); } + buildAnom(anomalies); + if (forecast) updateKPIs(forecast, anomalies.length); const ts = new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'}); document.getElementById('footer-txt').textContent='Sist oppdatert '+ts; document.getElementById('last-updated').textContent='Sist oppdatert '+ts; @@ -675,24 +665,18 @@ function renderAll(){ function buildBar(labels, costs){ const ctx=document.getElementById('bar-chart').getContext('2d'); if(barChart)barChart.destroy(); - if (!costs || costs.length === 0) { - document.getElementById('bar-empty').style.display = 'flex'; - document.getElementById('bar-chart').style.display = 'none'; + document.getElementById('bar-empty').style.display='flex'; + document.getElementById('bar-chart').style.display='none'; return; } document.getElementById('bar-empty').style.display='none'; document.getElementById('bar-chart').style.display='block'; - - barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[ - {label:'Kostnad',data:costs,backgroundColor:'rgba(124,92,255,0.55)',borderRadius:4,borderSkipped:false} - ]},options:{responsive:true,maintainAspectRatio:false, - plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw,2)}}}, - scales:{x:{grid:{display:false},ticks:{color:'#3f3d3a',font:{size:10},maxTicksLimit:15}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(2)},border:{display:false}}}}}); + barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[{label:'Kostnad',data:costs,backgroundColor:'rgba(124,92,255,0.55)',borderRadius:4,borderSkipped:false}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw,2)}}},scales:{x:{grid:{display:false},ticks:{color:'#3f3d3a',font:{size:10},maxTicksLimit:15}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(2)},border:{display:false}}}}}); } function buildDonut(summary){ - const hasData=summary.some(s=>(s.daily_cost ?? s.total_cost ?? s.cost ?? 0)>0); + const hasData=summary.some(s=>(s.daily_cost??s.total_cost??s.cost??0)>0); if(!hasData){ document.getElementById('donut-empty').style.display='flex'; document.getElementById('donut-chart').style.display='none'; @@ -703,31 +687,63 @@ function buildDonut(summary){ document.getElementById('donut-chart').style.display='block'; const ctx=document.getElementById('donut-chart').getContext('2d'); if(donutChart)donutChart.destroy(); - const top=summary.filter(s=>(s.daily_cost ?? s.total_cost ?? s.cost ?? 0)>0).slice(0,7); - donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.daily_cost ?? s.total_cost ?? s.cost ?? 0),backgroundColor:['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'],borderColor:'#13131c',borderWidth:2,hoverBorderWidth:3}]}, - options:{responsive:true,maintainAspectRatio:false,cutout:'70%', - plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}}); + const top=summary.filter(s=>(s.daily_cost??s.total_cost??s.cost??0)>0).slice(0,7); + donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.daily_cost??s.total_cost??s.cost??0),backgroundColor:SVC_COLORS,borderColor:'#13131c',borderWidth:2,hoverBorderWidth:3}]},options:{responsive:true,maintainAspectRatio:false,cutout:'70%',plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}}); } +// CG5: ny grouped render med SKU drill-down +function renderServiceRows(data) { + const container = document.getElementById('services-body'); + if (!data || !data.length) { + container.innerHTML = '
listIngen data
'; + return; + } + const max = Math.max(...data.map(s => s.total_cost || 0), 0.000001); + container.innerHTML = data.slice(0,8).map((svc, i) => { + const hasSkus = svc.skus && svc.skus.length > 1; + const color = serviceColor(svc.service, i); + const pct = ((svc.total_cost || 0) / max * 100).toFixed(1); + const skuRows = (svc.skus || []).map(s => + `
${s.sku}${fmtNOK(s.sku_cost)}
` + ).join(''); + return `
+
+ ${svc.service} +
+ ${fmtNOK(svc.total_cost)} + ${hasSkus ? 'chevron_right' : ''} +
+ ${hasSkus ? `
${skuRows}
` : ''}`; + }).join(''); +} + +function toggleSkus(i) { + const row = document.querySelector(`.svc-row[data-idx="${i}"]`); + const skus = document.getElementById(`skus-${i}`); + if (!row || !skus) return; + row.classList.toggle('expanded'); + skus.classList.toggle('open'); +} + +// Fallback: brukes når /billing/by-service ikke er tilgjengelig function buildSvc(summary){ - const el=document.getElementById('svc-list'); - const top=[...summary].sort((a,b)=>{ - const costA=a.daily_cost ?? a.total_cost ?? a.cost ?? 0; - const costB=b.daily_cost ?? b.total_cost ?? b.cost ?? 0; - return costB-costA; + const el = document.getElementById('services-body'); + const top = [...summary].sort((a,b) => { + return (b.daily_cost??b.total_cost??b.cost??0) - (a.daily_cost??a.total_cost??a.cost??0); }).slice(0,8); - if(!top.length){ + if (!top.length) { el.innerHTML='
listIngen data
'; return; } - const max=Math.max(...top.map(s=>Math.abs(s.daily_cost ?? s.total_cost ?? s.cost ?? 0)),0.000001); - el.innerHTML=top.map((s,i)=>{ - const c=s.daily_cost ?? s.total_cost ?? s.cost ?? 0; - const pct=(Math.abs(c)/max*100).toFixed(1); - return`
-
+ const max = Math.max(...top.map(s=>Math.abs(s.daily_cost??s.total_cost??s.cost??0)),0.000001); + el.innerHTML = top.map((s,i) => { + const c = s.daily_cost??s.total_cost??s.cost??0; + const pct = (Math.abs(c)/max*100).toFixed(1); + const color = serviceColor(s.service, i); + return `
+
${s.service} -
+
${fmtNOK(c)}
`; }).join(''); @@ -736,15 +752,12 @@ function buildSvc(summary){ function buildAnom(anomalies){ const tb=document.getElementById('top-badge'); if(!tb) return; - - if(!anomalies || !anomalies.length){ + if(!anomalies||!anomalies.length){ tb.className='tb-badge ok'; tb.innerHTML='check_circle Ingen anomalier'; return; } - const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0)); - if(maxRatio<1.5){ tb.className='tb-badge ok'; tb.innerHTML='check_circle Mild økning, innenfor normalen'; @@ -764,7 +777,7 @@ function buildGauge(mtd){ setTimeout(()=>arc.style.strokeDashoffset=173*(1-pct),80); document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%'; document.getElementById('gauge-used').textContent=fmtNOK(mtd); - document.getElementById('gauge-budget-total').textContent = `av ${fmtNOK(currentBudget, 0)} budsjett`; + 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)+' gjenstår':'Budsjett overskredet'; @@ -775,13 +788,10 @@ function updateKPIs(forecast,anomCount){ const mtd=forecast.month_to_date_cost||0; const daily=forecast.daily_average_last_7_days; const fc=forecast.total_monthly_forecast||0; - const left=forecast.remaining_days_in_month ?? (31-new Date().getDate()); + const left=forecast.remaining_days_in_month??(31-new Date().getDate()); animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v)); - if (daily !== null && daily > 0) { - animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v, 4)); - } else { - document.getElementById('k-daily').textContent = 'kr —'; - } + if(daily!==null&&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 gjenstår'; buildGauge(mtd); @@ -790,51 +800,42 @@ function updateKPIs(forecast,anomCount){ function updatePeriod(n){ period=n; document.getElementById('period-lbl').textContent=n+' dager'; - const data = getVisibleData(); - if (data && data.history) { - const { labels, costs } = calculateDailyCosts(data.history); - buildBar(labels, costs); + const data=getVisibleData(); + if(data&&data.history){ + const{labels,costs}=calculateDailyCosts(data.history); + buildBar(labels,costs); } } function exportCSV() { - const data = getVisibleData(); - if (!data || !data.summary) return; - - const headers = "Tjeneste,Kostnad (NOK)"; - const rows = data.summary.map(service => { - const cost = (service.daily_cost ?? service.total_cost ?? service.cost ?? 0).toFixed(2); + const data=getVisibleData(); + if(!data||!data.summary) return; + const headers="Tjeneste,Kostnad (NOK)"; + const rows=data.summary.map(service=>{ + const cost=(service.daily_cost??service.total_cost??service.cost??0).toFixed(2); return `"${service.service}",${cost}`; }); - - const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("\n"); - - const encodedUri = encodeURI(csvContent); - const link = document.createElement("a"); - link.setAttribute("href", encodedUri); - const date = new Date().toISOString().split('T')[0]; - link.setAttribute("download", `costguard_export_${date}.csv`); + const csvContent="data:text/csv;charset=utf-8,"+[headers,...rows].join("\n"); + const link=document.createElement("a"); + link.setAttribute("href",encodeURI(csvContent)); + link.setAttribute("download",`costguard_export_${new Date().toISOString().split('T')[0]}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); } -function exportPDF() { - window.print(); -} - +function exportPDF() { window.print(); } document.addEventListener('DOMContentLoaded', checkAuth); if ('serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js').then(registration => { - console.log('ServiceWorker registration successful with scope: ', registration.scope); - }, err => { - console.log('ServiceWorker registration failed: ', err); - }); + navigator.serviceWorker.register('/sw.js').then(r=>{ + console.log('ServiceWorker registered:', r.scope); + }, err=>console.log('ServiceWorker failed:', err)); }); } + // CG4: hent anbefalinger (function(){ const ICONS = {high:'🔴', medium:'🟡', low:'🟢', anomaly:'⚡', waste:'♻️', budget:'💸'}; @@ -854,7 +855,7 @@ if ('serviceWorker' in navigator) { ${ICONS[r.severity]||'💡'} ${r.message} ${r.service} - ${r.estimated_savings_nok>0?`-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr`: ''} + ${r.estimated_savings_nok>0?`-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr`:''}
`).join(''); })