OSVauco/static/billing-dashboard.html

903 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CostGuard · Billing</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0a0a0f">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-messaging-compat.js"></script>
<style>
:root{
--bg:#0a0a0f;--surface:#13131c;--surface-2:#1a1a26;--surface-3:#242436;
--border:rgba(124,92,255,0.12);--border-h:rgba(124,92,255,0.28);
--text:#ececf5;--muted:#8a8aa3;--faint:#3a3a4d;
--blue:#7c5cff;--blue-bg:rgba(124,92,255,0.12);--blue-border:rgba(124,92,255,0.3);
--primary:#7c5cff;--accent-2:#00d4a8;
--green:#00d4a8;--green-bg:rgba(0,212,168,0.12);
--yellow:#ffb454;--yellow-bg:rgba(255,180,84,0.12);
--red:#ff5c7c;--red-bg:rgba(255,92,124,0.12);
--ok:#00d4a8;--warn:#ffb454;
--gem-a:#7c5cff;--gem-b:#00d4a8;--gem-c:#a78bfa;
--font:'Inter',-apple-system,sans-serif;--mono:'JetBrains Mono',monospace;
--r:10px;--tr:200ms cubic-bezier(0.2,0,0,1);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
.topbar{height:56px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:0;position:sticky;top:0;z-index:200}
.tb-logo{display:flex;align-items:center;gap:10px;padding:0 16px;border-right:1px solid var(--border)}
.tb-logo-icon{width:28px;height:28px;background:linear-gradient(135deg,#7c5cff,#00d4a8);border-radius:8px;display:flex;align-items:center;justify-content:center}
.tb-logo-icon svg{fill:#fff;width:16px;height:16px}
.tb-logo-name{font-size:15px;font-weight:500;letter-spacing:-.01em}
.tb-logo-name span{background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;font-weight:700}
.tb-project{display:flex;align-items:center;gap:6px;padding:6px 12px;margin:0 8px;border-radius:6px;font-size:13px;color:var(--muted)}
.tb-project .material-symbols-outlined{font-size:16px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.tb-badge{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;padding:4px 10px;border-radius:16px;border:1px solid}
.tb-badge.ok{background:var(--green-bg);color:var(--green);border-color:rgba(52,168,83,0.25)}
.tb-badge.warn{background:var(--yellow-bg);color:var(--yellow);border-color:rgba(251,188,4,0.25)}
.tb-badge.err{background:var(--red-bg);color:var(--red);border-color:rgba(234,67,53,0.25)}
.tb-period{background:var(--surface-2);border:1px solid var(--border);color:var(--text);font-size:13px;font-family:var(--font);padding:5px 12px;border-radius:6px;cursor:pointer;outline:none;transition:border-color var(--tr)}
.tb-period:hover{border-color:var(--border-h)}
.pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;position:relative}
.pulse::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:var(--green);opacity:.4;animation:pulse 2s ease-out infinite}
@keyframes pulse{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.5);opacity:0}}
.layout{display:flex;min-height:calc(100vh - 56px)}
.sidebar{width:240px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);padding:12px 0}
.nav-section{padding:8px 16px 4px;font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:12px;padding:8px 16px;font-size:13px;color:var(--muted);cursor:pointer;transition:all var(--tr);border-radius:0 20px 20px 0;margin-right:8px}
.nav-item:hover{background:var(--surface-2);color:var(--text)}
.nav-item.active{background:var(--blue-bg);color:var(--blue);font-weight:600;border-left:2px solid var(--blue)}
.nav-item .material-symbols-outlined{font-size:18px}
.content{flex:1;padding:24px;overflow:hidden}
.onboarding-banner{background:linear-gradient(135deg,rgba(66,133,244,0.08),rgba(138,180,248,0.05));border:1px solid var(--blue-border);border-radius:12px;padding:16px 20px;display:flex;align-items:flex-start;gap:14px;margin-bottom:20px}
.onboarding-banner.hidden{display:none}
.ob-icon{width:36px;height:36px;border-radius:50%;background:var(--blue-bg);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.ob-icon .material-symbols-outlined{font-size:20px;color:var(--blue)}
.ob-body h4{font-size:14px;font-weight:500;color:var(--text);margin-bottom:4px}
.ob-body p{font-size:13px;color:var(--muted);line-height:1.6}
.ob-close{margin-left:auto;cursor:pointer;color:var(--muted);transition:color var(--tr);background:none;border:none;padding:4px}
.ob-close:hover{color:var(--text)}
.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 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(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: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-lbl{font-size:12px;color:var(--muted);font-weight:500}
.kpi-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center}
.kpi-icon .material-symbols-outlined{font-size:18px}
.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)}
.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-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.card-title{font-size:14px;font-weight:500}
.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);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}
.gauge-num{position:absolute;bottom:0;left:50%;transform:translateX(-50%);font-family:var(--mono);font-size:20px;font-weight:500;letter-spacing:-.02em}
.gauge-meta{font-size:12px;color:var(--muted);text-align:center}
.empty-state{color:var(--muted);font-size:13px;padding:24px 0;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
.empty-state .material-symbols-outlined{font-size:32px;opacity:.3}
.err-banner{background:var(--red-bg);border:1px solid rgba(234,67,53,0.2);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--red);display:none;align-items:center;gap:8px;margin-bottom:16px}
.err-banner.show{display:flex}
footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:center}
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}}
@media(max-width:720px){
.topbar{height:auto;flex-wrap:wrap;padding:8px 12px;gap:8px}
.tb-logo{padding:0 10px 0 0;border-right:none}
.tb-project{display:none}
.tb-right{width:100%;margin-left:0;flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:4px;gap:6px}
.tb-right::-webkit-scrollbar{display:none}
.tb-right>*{flex-shrink:0}
.layout{flex-direction:column}
.sidebar{width:100%;display:flex;overflow-x:auto;border-right:none;border-bottom:1px solid var(--border);padding:8px;gap:4px}
.sidebar::-webkit-scrollbar{display:none}
.nav-section{display:none}
.nav-item{flex-shrink:0;border-radius:99px;border-left:none;margin-right:0;padding:8px 14px;white-space:nowrap}
.nav-item.active{border-left:none;border:1px solid var(--blue)}
.content{padding:16px 12px}
.kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.kpi{padding:14px}
.kpi-val{font-size:20px}
.page-title{font-size:20px}
.card{padding:14px}
}
@media(max-width:420px){.kpi-row{grid-template-columns:1fr}}
@media print {
body {
background: #fff;
color: #000;
}
.topbar, #user-profile, #push-btn, .sel, footer, #budget-modal, .dropdown {
display: none !important;
}
.main {
padding: 0;
margin: 0;
}
.kpi, .card {
border: 1px solid #ddd;
box-shadow: none;
}
.kpi-row, .charts-row, .bottom-row {
grid-template-columns: 1fr !important;
}
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: var(--surface-2);
min-width: 130px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
right: 0;
border-radius: var(--r);
border: 1px solid var(--border);
}
.dropdown-content a {
color: var(--text);
padding: 8px 12px;
text-decoration: none;
display: block;
font-size: 12px;
}
.dropdown-content a:hover {background-color: var(--surface-3);}
.dropdown:hover .dropdown-content {display: block;}
.dropdown:hover .sel {
border-color: var(--border-h);
color: var(--text);
}
.sel {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--muted);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
font-family: var(--font);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: background var(--tr), border-color var(--tr), color var(--tr);
}
.sel:hover{
background: var(--surface-3);
border-color: var(--border-h);
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>
</head>
<body>
<header class="topbar">
<div class="tb-logo">
<div class="tb-logo-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<span class="tb-logo-name">Cost<span>Guard</span></span>
</div>
<div class="tb-project">
<span class="material-symbols-outlined">folder_open</span>
propane-will-491900-m5
</div>
<div class="tb-right">
<div id="provider-toggle" class="sel" style="display:flex; gap: 4px; padding: 2px;">
<span class="sel-opt active" data-provider="gcp">GCP</span>
</div>
<span id="push-badge" class="badge" style="display:none; background: #333; color: #aaa;">🔔 Varsler aktive</span>
<span class="pulse" id="live-dot"></span>
<span id="top-badge" class="tb-badge ok">
<span class="material-symbols-outlined" style="font-size:13px">check_circle</span>
Ingen anomalier
</span>
<select class="tb-period" onchange="updatePeriod(+this.value)">
<option value="7">7 dager</option>
<option value="30" selected>30 dager</option>
<option value="90">90 dager</option>
</select>
<button id="push-btn" class="sel" onclick="setupPushNotifications()" title="Aktiver push-varsler">
<span class="material-symbols-outlined" style="font-size:16px">notifications</span>
<span>Push</span>
</button>
<button class="sel" onclick="fetchAll()" title="Oppdater">
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
</button>
<div class="dropdown">
<button class="sel">
<span class="material-symbols-outlined" style="font-size:16px">download</span>
<span>Eksport</span>
</button>
<div class="dropdown-content">
<a href="#" onclick="exportCSV()">Eksporter som CSV</a>
<a href="#" onclick="exportPDF()">Eksporter til PDF</a>
</div>
</div>
<div class="sep"></div>
<div id="user-profile" style="display: none; align-items: center; gap: 8px;">
<img id="user-avatar" src="" alt="avatar" style="width: 28px; height: 28px; border-radius: 50%;">
<div style="display: flex; flex-direction: column; line-height: 1.2;">
<span id="user-name" style="font-size: 12px; font-weight: 600;"></span>
<a href="/auth/logout" style="font-size: 10px; color: var(--muted); text-decoration: none;">Logg ut</a>
</div>
</div>
</div>
</header>
<div class="layout">
<nav class="sidebar">
<div class="nav-section">Oversikt</div>
<div class="nav-item active" onclick="window.scrollTo({top:0,behavior:'smooth'})">
<span class="material-symbols-outlined">bar_chart</span>Billing
</div>
<div class="nav-item">
<span class="material-symbols-outlined">notifications</span>Varsler
</div>
<div class="nav-item">
<span class="material-symbols-outlined">trending_up</span>Prognoser
</div>
<div class="nav-section" style="margin-top:12px">Innstillinger</div>
<div class="nav-item" onclick="openBudgetModal()">
<span class="material-symbols-outlined">account_balance_wallet</span>Budsjett
</div>
<div class="nav-item">
<span class="material-symbols-outlined">settings</span>Konfigurasjon
</div>
</nav>
<main class="content">
<div class="onboarding-banner hidden" id="onboarding-banner">
<div class="ob-icon"><span class="material-symbols-outlined">schedule</span></div>
<div class="ob-body">
<h4>BigQuery billing export synkroniserer</h4>
<p>GCP eksporterer fakturadata med 2448 timers forsinkelse første gang. Kom tilbake i morgen for å se ditt faktiske forbruk.</p>
</div>
<button class="ob-close" onclick="document.getElementById('onboarding-banner').classList.add('hidden')">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="err-banner" id="err-banner">
<span class="material-symbols-outlined" style="font-size:14px">warning</span>
<span id="err-msg">Kan ikke nå API.</span>
</div>
<div class="page-hdr">
<h1 class="page-title">Billing <strong>Oversikt</strong></h1>
<span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
</div>
<section class="cg-section" style="margin-bottom:20px">
<h2 style="font-size:14px;margin-bottom:4px">Kostnader siste 30 dager</h2>
<p style="font-size:13px;color:var(--muted);max-width:720px">
Denne oversikten viser dine faktiske GCP-kostnader de siste 30 dagene, 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>
</section>
<div class="kpi-row">
<div class="kpi">
<div class="kpi-top">
<span class="kpi-lbl">Totalkostnad MTD</span>
<div class="kpi-icon ki-blue"><span class="material-symbols-outlined">payments</span></div>
</div>
<div class="kpi-val" id="k-mtd"></div>
<div class="kpi-delta"><span id="k-mtd-d">hittil denne måneden</span></div>
</div>
<div class="kpi">
<div class="kpi-top">
<span class="kpi-lbl">Daglig snitt (7d)</span>
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">today</span></div>
</div>
<div class="kpi-val" id="k-daily"></div>
<div class="kpi-delta"><span>snitt siste 7 dager</span></div>
</div>
<div class="kpi">
<div class="kpi-top">
<span class="kpi-lbl">Prognose måneden</span>
<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 gjenstår</span></div>
</div>
</div>
<div class="charts-row">
<div class="card">
<div class="card-hdr">
<span class="card-title">Daglig kostnad</span>
<span class="card-sub" id="period-lbl">30 dager · netto etter kreditter</span>
</div>
<div class="chart-wrap" id="bar-wrap">
<div class="empty-state" id="bar-empty">
<span class="material-symbols-outlined">bar_chart</span>
Ingen daglig data tilgjengelig ennå
</div>
<canvas id="bar-chart" style="display:none"></canvas>
</div>
</div>
<div class="card">
<div class="card-hdr">
<span class="card-title">Fordeling per tjeneste</span>
<span class="card-sub">Andel av total</span>
</div>
<div class="chart-wrap" id="donut-wrap">
<div class="empty-state" id="donut-empty">
<span class="material-symbols-outlined">donut_large</span>
Ingen tjenestedata tilgjengelig ennå
</div>
<canvas id="donut-chart" style="display:none"></canvas>
</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">Klikk for detaljer</span></div>
<div id="services-body"></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>
<!-- CG3e: Tokenbruk per agent -->
<div class="card" style="margin-top:12px">
<div class="card-hdr">
<span class="card-title">⚡ Tokenbruk per agent</span>
<span class="card-sub">Siste 30 dager</span>
</div>
<div id="token-usage-body">
<div class="empty-state">
<span class="material-symbols-outlined">bolt</span>
Laster tokendata...
</div>
</div>
</div>
</main>
</div>
<footer>
<span id="footer-txt">Henter data...</span>
<span style="color:var(--faint)">CostGuard · Vauco · nettokostnad etter kreditter</span>
</footer>
<div id="budget-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(4px);">
<div style="background: var(--surface-2); padding: 24px; border-radius: var(--r); border: 1px solid var(--border); width: 320px; display: flex; flex-direction: column; gap: 16px;">
<h3 style="font-size: 16px; font-weight: 600;">Sett månedsbudsjett</h3>
<div>
<label for="budget-input" style="font-size: 11px; color: var(--muted); margin-bottom: 4px; display: block;">Budsjett (NOK)</label>
<input id="budget-input" type="number" style="width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; color: var(--text); font-family: var(--mono); font-size: 14px;">
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button onclick="closeBudgetModal()" style="background: var(--surface-3); border: 1px solid var(--border); color: var(--text); padding: 6px 14px; border-radius: 6px; cursor: pointer;">Avbryt</button>
<button id="budget-save-btn" onclick="saveBudget()" style="background: var(--primary); border: none; color: var(--bg); font-weight: 600; padding: 7px 16px; border-radius: 6px; cursor: pointer;">Lagre</button>
</div>
</div>
</div>
<script>
const API_BASE = '';
let currentBudget = 500;
const REFRESH_MS = 5 * 60 * 1000;
let currentProvider = 'gcp';
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},{service:'Pub/Sub',total_cost:0.04},
{service:'Cloud Storage',total_cost:0.03}
];
const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,total_monthly_forecast:3.52};
const DEMO_ANOMALIES = [];
let barChart,donutChart,period=30;
let currentData={gcp: null};
let userProfile = null;
function fmtNOK(v,d=2){
const n=Math.abs(Number(v)||0);
return 'kr '+n.toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d});
}
function fmtDager(n){return n===1?'1 dag gjenstår':n+' dager gjenstår';}
function animN(el,end,fmt,dur=700){
const abs=Math.abs(end);
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*abs);if(p<1)requestAnimationFrame(go);};
requestAnimationFrame(go);
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
if (response.status === 401) {
window.location.href = '/auth/login';
return new Promise(() => {});
}
return response;
}
function switchProvider(provider) {
currentProvider = 'gcp';
localStorage.setItem('costguard_provider', 'gcp');
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
btn.classList.toggle('active', btn.dataset.provider === 'gcp');
});
fetchAll();
}
async function checkAuth() {
try {
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);
} catch (err) {
console.error("Authentication check failed", err);
}
}
async function fetchBudget() {
try {
const response = await apiFetch(API_BASE + '/billing/budget');
if (!response.ok) throw new Error('Failed to fetch budget');
const data = await response.json();
currentBudget = data.budget;
document.getElementById('budget-input').value = currentBudget;
renderAll();
} catch (err) {
console.warn('Could not fetch budget, using default:', err.message);
}
}
async function saveBudget() {
const btn = document.getElementById('budget-save-btn');
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) {
alert('Vennligst oppgi et gyldig, positivt tall for budsjettet.');
btn.textContent = originalText;
btn.disabled = false;
return;
}
try {
const response = await apiFetch(API_BASE + '/billing/budget', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ budget: newBudgetValue })
});
if (!response.ok) throw new Error('Failed to save budget');
const data = await response.json();
currentBudget = data.budget;
closeBudgetModal();
renderAll();
} catch (err) {
alert('Kunne ikke lagre budsjett: ' + err.message);
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
}
function openBudgetModal() {
document.getElementById('budget-input').value = currentBudget;
document.getElementById('budget-modal').style.display = 'flex';
}
function closeBudgetModal() {
document.getElementById('budget-modal').style.display = 'none';
}
async function fetchAll(){
const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)';
try {
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 gcpServices = apiFetch(API_BASE+'/billing/by-service');
const [gcpSR,gcpLR,gcpAR,gcpHR,gcpSvcR] = await Promise.all([gcpSummary,gcpForecast,gcpAnomalies,gcpHistory,gcpServices]);
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()]);
let gcpH = { history: [] };
if (gcpHR.ok) { try { gcpH = await gcpHR.json(); } catch(e){ console.warn('history parse error',e); } }
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: [], 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() {
return currentData.gcp;
}
function calculateDailyCosts(history) {
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;
const entryDate = new Date(entry.date);
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, serviceGroups} = data;
const { labels, costs } = calculateDailyCosts(history);
buildBar(labels, costs);
buildDonut(summary);
// 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;
const monthName = new Date().toLocaleString('no-NO', { month: 'long' });
document.getElementById('budget-month').textContent = monthName.charAt(0).toUpperCase() + monthName.slice(1) + " " + new Date().getFullYear();
}
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';
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}}}}});
}
function buildDonut(summary){
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';
if(donutChart){donutChart.destroy();donutChart=null;}
return;
}
document.getElementById('donut-empty').style.display='none';
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: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 = '<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';
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 =>
`<div class="sku-row"><span>${s.sku}</span><span>${fmtNOK(s.sku_cost)}</span></div>`
).join('');
return `<div class="svc-row" data-idx="${i}" ${hasSkus ? `onclick="toggleSkus(${i})"` : ''}>
<div class="svc-dot" style="background:${color}"></div>
<span class="svc-name">${svc.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${color}"></div></div>
<span class="svc-cost">${fmtNOK(svc.total_cost)}</span>
${hasSkus ? '<span class="material-symbols-outlined svc-chevron">chevron_right</span>' : ''}
</div>
${hasSkus ? `<div class="svc-skus" id="skus-${i}">${skuRows}</div>` : ''}`;
}).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('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) {
el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';
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 `<div class="svc-row" data-idx="${i}">
<div class="svc-dot" style="background:${color}"></div>
<span class="svc-name">${s.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${color}"></div></div>
<span class="svc-cost">${fmtNOK(c)}</span>
</div>`;
}).join('');
}
function buildAnom(anomalies){
const tb=document.getElementById('top-badge');
if(!tb) return;
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';
return;
}
const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
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){
const pct=Math.min(mtd/currentBudget,1);
const arc=document.getElementById('gauge-arc');
arc.style.stroke=pct>.8?'var(--red)':pct>.6?'var(--yellow)':'var(--blue)';
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`;
const r=currentBudget-mtd;
const rel=document.getElementById('gauge-remain');
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
rel.style.color=r<0?'var(--red)':'var(--muted)';
}
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());
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 —';}
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
buildGauge(mtd);
}
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);
}
}
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);
return `"${service.service}",${cost}`;
});
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(); }
document.addEventListener('DOMContentLoaded', checkAuth);
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
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:'💸'};
fetch('/billing/recommendations')
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(data=>{
const body=document.getElementById('recommendations-body');
const sub=document.getElementById('rec-savings');
if(!data.recommendations||!data.recommendations.length){
body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger — forbruket ser normalt ut</div>';
sub.textContent='Alt OK';
return;
}
sub.textContent=`Estimert besparelse: ${(data.total_estimated_savings_nok||0).toLocaleString('no-NO',{minimumFractionDigits:2})} kr/mnd`;
body.innerHTML=data.recommendations.map(r=>`
<div class="svc-row">
<span style="font-size:16px">${ICONS[r.severity]||'💡'}</span>
<span class="svc-name">${r.message}</span>
<span style="font-size:11px;color:var(--muted);background:var(--surface-3);padding:2px 8px;border-radius:99px;flex-shrink:0">${r.service}</span>
${r.estimated_savings_nok>0?`<span class="svc-cost">-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr</span>`:''}
</div>
`).join('');
})
.catch(()=>{
document.getElementById('recommendations-body').innerHTML=
'<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger tilgjengelig</div>';
document.getElementById('rec-savings').textContent='';
});
})();
// CG3e: hent tokenbruk
(function(){
fetch('/billing/tokens/summary')
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(data=>{
const body=document.getElementById('token-usage-body');
if(!data.rows||!data.rows.length){
body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Ingen tokendata ennå</div>';
return;
}
body.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="color:var(--muted);font-size:11px;text-align:left">
<th style="padding:6px 0">Agent</th>
<th>Modell</th>
<th style="text-align:right">Tokens</th>
<th style="text-align:right">Kost (USD)</th>
</tr></thead>
<tbody>${data.rows.map(r=>`<tr style="border-top:1px solid var(--border)">
<td style="padding:7px 0">${r.agent_name}</td>
<td style="color:var(--muted)">${r.model_name}</td>
<td style="text-align:right;font-family:var(--mono)">${r.total_tokens.toLocaleString()}</td>
<td style="text-align:right;font-family:var(--mono)">$${r.total_cost_usd.toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
})
.catch(()=>{
document.getElementById('token-usage-body').innerHTML=
'<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Ingen tokendata ennå</div>';
});
})();
</script>
</body>
</html>