@@ -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('');
})