CG3: simplify layout, center around service costs + donut, and move anomalies to top-line status only

This commit is contained in:
chrischristiansen-glitch 2026-06-09 03:33:23 +02:00
parent 868a0e496d
commit f48fcd7422

View File

@ -72,7 +72,7 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
.page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px} .page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.page-title{font-size:24px;font-weight:600;letter-spacing:-.02em} .page-title{font-size:24px;font-weight:600;letter-spacing:-.02em}
.page-title strong{font-weight:700;background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent} .page-title strong{font-weight:700;background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px} .kpi-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px;transition:box-shadow var(--tr),border-color var(--tr)} .kpi{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px;transition:box-shadow var(--tr),border-color var(--tr)}
.kpi:hover{border-color:var(--border-h);box-shadow:0 1px 8px rgba(0,0,0,.3)} .kpi:hover{border-color:var(--border-h);box-shadow:0 1px 8px rgba(0,0,0,.3)}
.kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px} .kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
@ -82,24 +82,13 @@ 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-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-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-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-family:var(--mono);font-size:24px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px}
.kpi-delta{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px}
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-bottom:20px} .charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-bottom:20px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px} .card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px}
.card-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px} .card-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.card-title{font-size:14px;font-weight:500} .card-title{font-size:14px;font-weight:500}
.card-sub{font-size:12px;color:var(--muted)} .card-sub{font-size:12px;color:var(--muted)}
.chart-wrap{position:relative;height:200px} .chart-wrap{position:relative;height:200px}
.bottom-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px} .bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.anomaly-row{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px}
.anomaly-row:last-child{border-bottom:none}
.a-svc{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.a-cost{font-family:var(--mono);font-size:12px;color:var(--muted)}
.a-ratio{font-family:var(--mono);font-size:11px;padding:2px 8px;border-radius:12px;font-weight:600}
.r-h{background:var(--red-bg);color:var(--red)}
.r-m{background:var(--yellow-bg);color:var(--yellow)}
.r-l{background:var(--green-bg);color:var(--green)}
.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)}
.svc-row:last-child{border-bottom:none} .svc-row:last-child{border-bottom:none}
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} .svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
@ -187,6 +176,28 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
border-color: var(--border-h); border-color: var(--border-h);
color: var(--text); color: var(--text);
} }
.sel-opt {
background: transparent;
border: none;
color: var(--muted);
padding: 4px 10px;
cursor: default;
border-radius: 4px;
font-size: 12px;
font-family: var(--font);
}
.sel-opt.active {
background: var(--surface-3);
color: var(--text);
font-weight: 600;
}
.blink {
animation: blink 1.2s ease-in-out infinite;
}
@keyframes blink {
0%,100% { opacity: 1; }
50% { opacity: .3; }
}
</style> </style>
</head> </head>
<body> <body>
@ -271,40 +282,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
</button> </button>
</div> </div>
<div class="bottom-row">
<div class="card">
<div class="card-hdr"><span class="card-title">Anomali-deteksjon</span><span class="badge b-ok" id="anom-badge" style="font-size:10px">OK</span></div>
<div id="anom-list"><div class="empty-state">Laster...</div></div>
</div>
<div class="card">
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
<div id="svc-list"></div>
</div>
<div class="card">
<div class="card-hdr">
<span class="card-title">Budsjett-status</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="card-sub" id="budget-month"></span>
<svg onclick="openBudgetModal()" style="cursor: pointer; color: var(--muted);" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" title="Endre budsjett"><path d="M12.22 2h-4.44a2 2 0 0 0-2 2v.78a2 2 0 0 1-1.11 1.79l-.65.44a2 2 0 0 1-1.79 1.11H2v4.44a2 2 0 0 0 2 2h.78a2 2 0 0 1 1.79 1.11l.44.65a2 2 0 0 1 1.11 1.79V22h4.44a2 2 0 0 0 2-2v-.78a2 2 0 0 1 1.11-1.79l.65-.44a2 2 0 0 1 1.79-1.11H22v-4.44a2 2 0 0 0-2-2h-.78a2 2 0 0 1-1.79-1.11l-.44-.65a2 2 0 0 1-1.11-1.79V2z"/><circle cx="12" cy="12" r="3"/></svg>
</div>
</div>
<div class="gauge-wrap">
<div class="gauge-ring">
<svg width="120" height="66" viewBox="0 0 120 66">
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
</svg>
<span class="gauge-num" id="gauge-pct">0%</span>
</div>
<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">&#8212; gjennst&#229;r</div>
</div>
</div>
</div>
<div class="err-banner" id="err-banner"> <div class="err-banner" id="err-banner">
<span class="material-symbols-outlined" style="font-size:14px">warning</span> <span class="material-symbols-outlined" style="font-size:14px">warning</span>
<span id="err-msg">Kan ikke nå API.</span> <span id="err-msg">Kan ikke nå API.</span>
@ -315,28 +292,11 @@ 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> <span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
</div> </div>
<section class="cg-section"> <section class="cg-section" style="margin-bottom:20px">
<h2>Kostnader siste 30 dager</h2> <h2 style="font-size:14px;margin-bottom:4px">Kostnader siste 30 dager</h2>
<p> <p style="font-size:13px;color:var(--muted);max-width:720px">
Denne oversikten viser dine faktiske GCP-kostnader de siste 30 dagene, Denne oversikten viser dine faktiske GCP-kostnader de siste 30 dagene, hentet direkte fra BigQuery billing export for din billing-konto.
hentet direkte fra BigQuery billing export for din billing-konto. Dataene er aggregert per <strong>dato</strong>, <strong>prosjekt</strong> og <strong>tjeneste</strong>, slik at du raskt ser hvor pengene går.
</p>
<p>
Dataene er aggregert per <strong>dato</strong>, <strong>prosjekt</strong>
og <strong>tjeneste</strong>, slik at du raskt ser hvor pengene går.
</p>
<ul>
<li><strong>Dato</strong>: Basert på <code>usage_start_time</code> i billing-eksporten.</li>
<li><strong>Prosjekt</strong>: <code>project.id</code> for GCP-prosjektet kostnaden tilhører.</li>
<li><strong>Tjeneste</strong>: Tjenesten som har generert kostnaden (for eksempel Compute Engine, BigQuery, Cloud Run).</li>
<li><strong>Daglig kostnad</strong>: Summen av <code>cost</code> for den datoen, prosjektet og tjenesten.</li>
</ul>
<p class="cg-note">
Viser inntil 100 linjer med de høyeste kostnadene i perioden. Tallene kommer fra tabellen
<code>billing_export.gcp_billing_export_v1_0171F6_057E6B_A260BA</code> i prosjektet
<code>propane-will-491900-m5</code>.
</p> </p>
</section> </section>
@ -365,14 +325,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
<div class="kpi-val" id="k-fc"></div> <div class="kpi-val" id="k-fc"></div>
<div class="kpi-delta"><span id="k-fc-d">— dager gjenstår</span></div> <div class="kpi-delta"><span id="k-fc-d">— dager gjenstår</span></div>
</div> </div>
<div class="kpi" id="k-anom-card">
<div class="kpi-top">
<span class="kpi-lbl">Anomalier</span>
<div class="kpi-icon ki-red"><span class="material-symbols-outlined">warning</span></div>
</div>
<div class="kpi-val" id="k-anom"></div>
<div class="kpi-delta"><span id="k-anom-d">siste 24t</span></div>
</div>
</div> </div>
<div class="charts-row"> <div class="charts-row">
@ -404,6 +356,37 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
</div> </div>
</div> </div>
<div class="bottom-row">
<div class="card">
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
<div id="svc-list"></div>
</div>
<div class="card">
<div class="card-hdr">
<span class="card-title">Budsjett-status</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="card-sub" id="budget-month"></span>
<svg onclick="openBudgetModal()" style="cursor: pointer; color: var(--muted);" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" title="Endre budsjett"><path d="M12.22 2h-4.44a2 2 0 0 0-2 2v.78a2 2 0 0 1-1.11 1.79l-.65.44a2 2 0 0 1-1.79 1.11H2v4.44a2 2 0 0 0 2 2h.78a2 2 0 0 1 1.79 1.11l.44.65a2 2 0 0 1 1.11 1.79V22h4.44a2 2 0 0 0 2-2v-.78a2 2 0 0 1 1.11-1.79l.65-.44a2 2 0 0 1 1.79-1.11H22v-4.44a2 2 0 0 0-2-2h-.78a2 2 0 0 1-1.79-1.11l-.44-.65a2 2 0 0 1-1.11-1.79V2z"/><circle cx="12" cy="12" r="3"/></svg>
</div>
</div>
<div class="gauge-wrap">
<div class="gauge-ring">
<svg width="120" height="66" viewBox="0 0 120 66">
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
</svg>
<span class="gauge-num" id="gauge-pct">0%</span>
</div>
<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">— gjenstår</div>
</div>
</div>
</div>
</div>
</main> </main>
</div> </div>
@ -489,7 +472,6 @@ async function checkAuth() {
switchProvider(currentProvider); switchProvider(currentProvider);
await fetchBudget(); await fetchBudget();
setInterval(fetchAll, REFRESH_MS); setInterval(fetchAll, REFRESH_MS);
} catch (err) { } catch (err) {
console.error("Authentication check failed", err); console.error("Authentication check failed", err);
} }
@ -551,7 +533,6 @@ function closeBudgetModal() {
document.getElementById('budget-modal').style.display = 'none'; document.getElementById('budget-modal').style.display = 'none';
} }
async function fetchAll(){ async function fetchAll(){
const dot=document.getElementById('live-dot'); const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)'; dot.style.background='var(--warn)';
@ -603,7 +584,6 @@ function getVisibleData() {
return currentData.gcp; return currentData.gcp;
} }
function calculateDailyCosts(history) { function calculateDailyCosts(history) {
if (!history || history.length === 0) { if (!history || history.length === 0) {
return { labels: [], costs: [] }; return { labels: [], costs: [] };
@ -665,7 +645,6 @@ function buildBar(labels, costs){
document.getElementById('bar-empty').style.display='none'; document.getElementById('bar-empty').style.display='none';
document.getElementById('bar-chart').style.display='block'; document.getElementById('bar-chart').style.display='block';
const avg7=costs.map((_,i)=>{const s=costs.slice(Math.max(0,i-6),i+1);return s.reduce((a,b)=>a+b,0)/s.length;});
const avgC=costs.reduce((a,b)=>a+b,0)/costs.length; const avgC=costs.reduce((a,b)=>a+b,0)/costs.length;
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[ barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
@ -718,17 +697,27 @@ function buildSvc(summary){
} }
function buildAnom(anomalies){ function buildAnom(anomalies){
const el=document.getElementById('anom-list'); const tb=document.getElementById('top-badge');
const tb=document.getElementById('top-badge'),ab=document.getElementById('anom-badge'); if(!tb) return;
if(!anomalies.length){
el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anomalier detektert</div>'; if(!anomalies || !anomalies.length){
tb.className='tb-badge ok';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier'; tb.className='tb-badge ok';
ab.className='tb-badge ok';ab.textContent='OK'; tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
return; return;
} }
tb.className='tb-badge err';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">warning</span> '+anomalies.length+' anomali'+(anomalies.length>1?'er':'');
ab.className='tb-badge err';ab.textContent='AKTIV'; const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
el.innerHTML=anomalies.map(a=>{const r=a.ratio||0;return`<div class="anomaly-row"><span class="a-svc">${a.service}</span><span class="a-cost">${fmtNOK(a.today_cost||0)}</span><span class="a-ratio ${r>3?'r-h':r>1.8?'r-m':'r-l'}">${r.toFixed(1)}x</span></div>`;}).join('');
if(maxRatio<1.5){
tb.className='tb-badge ok';
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Mild økning, innenfor normalen';
}else if(maxRatio<2.5){
tb.className='tb-badge warn';
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">error</span> Økt bruk på flere tjenester';
}else{
tb.className='tb-badge err';
tb.innerHTML='<span class="material-symbols-outlined blink" style="font-size:13px">warning</span> Kraftig avvik — sjekk kostnad per tjeneste';
}
} }
function buildGauge(mtd){ function buildGauge(mtd){
@ -757,7 +746,6 @@ function updateKPIs(forecast,anomCount){
document.getElementById('k-daily').textContent = 'kr —'; document.getElementById('k-daily').textContent = 'kr —';
} }
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v)); animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
document.getElementById('k-fc-d').textContent=left+' dager gjenstår'; document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
buildGauge(mtd); buildGauge(mtd);
} }
@ -811,22 +799,6 @@ if ('serviceWorker' in navigator) {
}); });
} }
</script> </script>
<style>
.sel-opt {
background: transparent;
border: none;
color: var(--muted);
padding: 4px 10px;
cursor: default;
border-radius: 4px;
font-size: 12px;
font-family: var(--font);
}
.sel-opt.active {
background: var(--surface-3);
color: var(--text);
font-weight: 600;
}
</style>
</body> </body>
</html> </html>