903 lines
42 KiB
HTML
903 lines
42 KiB
HTML
<!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 24–48 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>
|