OSVauco/static/billing-dashboard.html

857 lines
41 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-icon-btn{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background var(--tr);border:none;background:transparent;color:var(--muted)}
.tb-icon-btn:hover{background:var(--surface-2);color:var(--text)}
.tb-icon-btn .material-symbols-outlined{font-size:20px}
.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(4,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)}
.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}
.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:1fr 1fr 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:last-child{border-bottom:none}
.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}
.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: 100px;
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);
}
</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;">
<button class="sel-opt active" data-provider="gcp" onclick="switchProvider('gcp')">GCP</button>
<button class="sel-opt" data-provider="aws" onclick="switchProvider('aws')">AWS</button>
<button class="sel-opt" data-provider="all" onclick="switchProvider('all')">Alle</button>
</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()" style="cursor:pointer" title="Aktiver push-varsler">Aktiver push-varsler</button>
<button class="sel" onclick="fetchAll()" style="cursor:pointer" title="Oppdater">&#8635;</button>
<div class="dropdown">
<button class="sel">Eksporter</button>
<div class="dropdown-content">
<a href="#" onclick="exportCSV()">CSV</a>
<a href="#" onclick="exportPDF()">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">
<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">
<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="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">
<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">
<h2>Kostnader siste 30 dager</h2>
<p>
Denne oversikten viser dine faktiske GCP-kostnader de siste 30 dagene,
hentet direkte fra BigQuery billing export for din billing-konto.
</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>
</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 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 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>
</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 = localStorage.getItem('costguard_provider') || '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, aws: 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 = provider;
localStorage.setItem('costguard_provider', provider);
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
btn.classList.toggle('active', btn.dataset.provider === provider);
});
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); // Set initial state
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 awsSummary = apiFetch(API_BASE+'/billing/aws/summary');
const awsForecast = apiFetch(API_BASE+'/billing/aws/forecast');
const [gcpSR, gcpLR, gcpAR, gcpHR, awsSR, awsLR] = await Promise.all([gcpSummary, gcpForecast, gcpAnomalies, gcpHistory, awsSummary, awsForecast]);
if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok || !gcpHR.ok || !awsSR.ok || !awsLR.ok) {
throw new Error('HTTP error on one or more endpoints');
}
const [gcpS, gcpL, gcpA, gcpH, awsS, awsL] = await Promise.all([gcpSR.json(), gcpLR.json(), gcpAR.json(), gcpHR.json(), awsSR.json(), awsLR.json()]);
currentData.gcp = { summary: gcpS.summary || [], forecast: gcpL, anomalies: gcpA.anomalies || [], history: gcpH.history || [] };
currentData.aws = { summary: awsS.summary || [], forecast: awsL, anomalies: [], history: [] }; // AWS history/anomalies not implemented yet
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.aws = { summary: [], forecast: {}, anomalies: [], history: [] };
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() {
if (currentProvider === 'gcp') {
return currentData.gcp;
}
if (currentProvider === 'aws') {
return currentData.aws;
}
if (currentProvider === 'all') {
if (!currentData.gcp || !currentData.aws) return null;
const combined = {
summary: [...(currentData.gcp.summary || []), ...(currentData.aws.summary || [])]
.reduce((acc, service) => {
const existing = acc.find(s => s.service === service.service);
if (existing) {
existing.total_cost += service.total_cost || service.cost || 0;
} else {
acc.push({ ...service });
}
return acc;
}, [])
.sort((a, b) => (b.total_cost || b.cost) - (a.total_cost || a.cost)),
forecast: {
month_to_date_cost: (currentData.gcp.forecast?.month_to_date_cost || 0) + (currentData.aws.forecast?.month_to_date_cost || 0),
daily_average_last_7_days: (currentData.gcp.forecast?.daily_average_last_7_days || 0) + (currentData.aws.forecast?.daily_average_last_7_days || 0),
total_monthly_forecast: (currentData.gcp.forecast?.total_monthly_forecast || 0) + (currentData.aws.forecast?.total_monthly_forecast || 0),
remaining_days_in_month: currentData.gcp.forecast?.remaining_days_in_month
},
anomalies: [...(currentData.gcp.anomalies || []), ...(currentData.aws.anomalies || [])],
history: currentData.gcp.history // Only show GCP history for now
};
return combined;
}
return null;
}
function calculateDailyCosts(history) {
if (!history || history.length === 0) {
return { labels: [], costs: [] };
}
const dailyCosts = [];
const labels = [];
let lastMtd = 0;
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 it's the first day of the month, the daily cost is just the MTD
if (entryDate.getDate() === 1) {
dailyCost = currentMtd;
} else {
// Find the previous day's MTD
const prevDayEntry = history[i-1];
const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0;
dailyCost = currentMtd - prevMtd;
}
// Ensure we don't have negative costs due to billing adjustments
dailyCosts.push(Math.max(0, dailyCost));
labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'}));
}
return { labels, costs: dailyCosts };
}
function renderAll(){
const data = getVisibleData();
if (!data) return;
const{summary,forecast,anomalies,history}=data;
const { labels, costs } = calculateDailyCosts(history);
buildBar(labels, costs);
buildDonut(summary);
buildSvc(summary);
buildAnom(anomalies);
if (forecast) {
updateKPIs(forecast, anomalies.length);
}
document.getElementById('footer-txt').textContent='Sist oppdatert '+new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
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';
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;
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.total_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.total_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.total_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)}}}}});
}
function buildSvc(summary){
const el=document.getElementById('svc-list');
const top=summary.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.total_cost||0)),0.000001);
el.innerHTML=top.map((s,i)=>{
const c=s.total_cost||0;
const pct=(Math.abs(c)/max*100).toFixed(1);
return`<div class="svc-row">
<div class="svc-dot" style="background:${['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'][i%7]}"></div>
<span class="svc-name">${s.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'][i%7]}"></div></div>
<span class="svc-cost">${fmtNOK(c)}</span>
</div>`;
}).join('');
}
function buildAnom(anomalies){
const el=document.getElementById('anom-list');
const tb=document.getElementById('top-badge'),ab=document.getElementById('anom-badge');
if(!anomalies.length){
el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anomalier detektert</div>';
tb.className='tb-badge ok';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
ab.className='tb-badge ok';ab.textContent='OK';
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';
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('');
}
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));
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
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.total_cost || service.cost || 0).toFixed(2);
return `"${service.service}",${cost}`;
});
const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("
");
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`);
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(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, err => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
</script>
<style>
.sel-opt {
background: transparent;
border: none;
color: var(--muted);
padding: 4px 10px;
cursor: pointer;
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>
</html>