OSVauco/static/billing-dashboard.html

647 lines
35 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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Mono&family=Roboto:wght@400;500&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>
<style>
:root{
--bg:#0d1117;--surface:#161b22;--surface-2:#1c2128;--surface-3:#242c38;
--border:rgba(139,148,158,0.1);--border-h:rgba(139,148,158,0.2);
--text:#e6edf3;--muted:#8b949e;--faint:#30363d;
--blue:#4285f4;--blue-bg:rgba(66,133,244,0.1);--blue-border:rgba(66,133,244,0.25);
--green:#34a853;--green-bg:rgba(52,168,83,0.1);
--yellow:#fbbc04;--yellow-bg:rgba(251,188,4,0.1);
--red:#ea4335;--red-bg:rgba(234,67,53,0.1);
--gem-a:#8ab4f8;--gem-b:#c58af9;--gem-c:#81c995;
--font:'Google Sans','Roboto',sans-serif;--mono:'Google Sans Mono','Roboto Mono',monospace;
--r:8px;--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,#4285f4,#8ab4f8);border-radius:6px;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{color:var(--blue);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:500}
.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)}
.gemini-card{background:linear-gradient(135deg,rgba(138,180,248,0.06),rgba(197,138,249,0.06),rgba(129,201,149,0.04));border:1px solid rgba(138,180,248,0.2);border-radius:12px;padding:16px 20px;margin-bottom:20px;display:flex;align-items:flex-start;gap:14px}
.gem-icon{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#4285f4,#c58af9);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.gem-icon svg{fill:#fff;width:16px;height:16px}
.gem-body{flex:1}
.gem-label{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--gem-a);margin-bottom:6px}
.gem-text{font-size:13px;color:var(--text);line-height:1.6}
.gem-chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.gem-chip{background:rgba(138,180,248,0.1);border:1px solid rgba(138,180,248,0.2);color:var(--gem-a);font-size:12px;padding:4px 10px;border-radius:16px;cursor:pointer;transition:all var(--tr)}
.gem-chip:hover{background:rgba(138,180,248,0.2)}
.page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.page-title{font-size:22px;font-weight:400;letter-spacing:-.01em}
.page-title strong{font-weight:500}
.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}
.chat-fab{position:fixed;bottom:24px;right:24px;width:52px;height:52px;border-radius:50%;background:linear-gradient(135deg,#4285f4,#8ab4f8);box-shadow:0 4px 16px rgba(66,133,244,0.4);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:300;transition:transform var(--tr),box-shadow var(--tr);border:none}
.chat-fab:hover{transform:scale(1.08);box-shadow:0 6px 20px rgba(66,133,244,0.5)}
.chat-fab svg{fill:#fff;width:24px;height:24px}
.chat-panel{position:fixed;bottom:0;right:0;width:380px;height:100vh;background:var(--surface);border-left:1px solid var(--border);display:flex;flex-direction:column;z-index:400;transform:translateX(100%);transition:transform var(--tr)}
.chat-panel.open{transform:translateX(0)}
.chat-head{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
.chat-head-icon{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#4285f4,#c58af9);display:flex;align-items:center;justify-content:center}
.chat-head-icon svg{fill:#fff;width:16px;height:16px}
.chat-head-name{font-size:14px;font-weight:500;flex:1}
.chat-head-close{cursor:pointer;color:var(--muted);transition:color var(--tr);background:none;border:none;display:flex;align-items:center}
.chat-head-close:hover{color:var(--text)}
.chat-head-close .material-symbols-outlined{font-size:20px}
.chat-msgs{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
.chat-msg{display:flex;gap:8px;align-items:flex-start}
.chat-msg.user{flex-direction:row-reverse}
.chat-bubble{max-width:80%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.5}
.chat-msg.ai .chat-bubble{background:var(--surface-2);border-radius:4px 16px 16px 16px;color:var(--text)}
.chat-msg.user .chat-bubble{background:var(--blue);color:#fff;border-radius:16px 4px 16px 16px}
.chat-avatar{width:28px;height:28px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600}
.chat-msg.ai .chat-avatar{background:linear-gradient(135deg,#4285f4,#c58af9)}
.chat-msg.ai .chat-avatar svg{fill:#fff;width:14px;height:14px}
.chat-msg.user .chat-avatar{background:var(--surface-3);color:var(--muted)}
.chat-input-row{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center}
.chat-input{flex:1;background:var(--surface-2);border:1px solid var(--border);border-radius:20px;padding:8px 14px;font-size:13px;font-family:var(--font);color:var(--text);outline:none;resize:none;transition:border-color var(--tr)}
.chat-input:focus{border-color:var(--blue)}
.chat-send{width:36px;height:36px;border-radius:50%;background:var(--blue);border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background var(--tr)}
.chat-send:hover{background:#2d6fd6}
.chat-send .material-symbols-outlined{font-size:18px;color:#fff}
footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
@media(max-width:1024px){.sidebar{display:none}.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}.chat-panel{width:100%}}
</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">
<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 class="tb-icon-btn" onclick="fetchAll()" title="Oppdater">
<span class="material-symbols-outlined">refresh</span>
</button>
<button class="tb-icon-btn" onclick="toggleChat()" title="Gemini AI">
<span class="material-symbols-outlined">auto_awesome</span>
</button>
</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="gemini-card">
<div class="gem-icon">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>
</div>
<div class="gem-body">
<div class="gem-label">Gemini Insight</div>
<div class="gem-text" id="gem-text">Henter GCP-data...</div>
<div class="gem-chips">
<span class="gem-chip" onclick="askGemini('Hva koster mest?')">Hva koster mest?</span>
<span class="gem-chip" onclick="askGemini('Prognose neste måned')">Prognose neste måned</span>
<span class="gem-chip" onclick="askGemini('Er det noen anomalier?')">Anomalier?</span>
<span class="gem-chip" onclick="askGemini('Budsjett-status')">Budsjett-status</span>
</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>
<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>
<div class="bottom-row">
<div class="card">
<div class="card-hdr">
<span class="card-title">Anomali-deteksjon</span>
<span class="tb-badge ok" id="anom-badge" style="font-size:11px">OK</span>
</div>
<div id="anom-list">
<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anomalier</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 class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>
</div>
</div>
<div class="card">
<div class="card-hdr">
<span class="card-title">Budsjett-status</span>
<span class="card-sub" id="budget-month"></span>
</div>
<div class="gauge-wrap">
<div class="gauge-ring">
<svg width="130" height="72" viewBox="0 0 130 72">
<path d="M10,65 A55,55 0 0,1 120,65" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M10,65 A55,55 0 0,1 120,65" stroke="var(--blue)" stroke-width="10" fill="none" stroke-linecap="round"
stroke-dasharray="173" id="gauge-arc" stroke-dashoffset="173" 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:14px;font-weight:500;text-align:center" id="gauge-used">kr 0,00</div>
<div class="gauge-meta">av kr 500 budsjett</div>
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenstår</div>
</div>
</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>
<button class="chat-fab" onclick="toggleChat()" title="Spør Gemini">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
</button>
<div class="chat-panel" id="chat-panel">
<div class="chat-head">
<div class="chat-head-icon">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/></svg>
</div>
<span class="chat-head-name">Gemini · CostGuard AI</span>
<button class="chat-head-close" onclick="toggleChat()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="chat-msgs" id="chat-msgs">
<div class="chat-msg ai">
<div class="chat-avatar"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="white"/></svg></div>
<div class="chat-bubble">Hei! Jeg er din GCP-kostnadsassistent. Spør meg om kostnader, anomalier eller prognoser.</div>
</div>
</div>
<div class="chat-input-row">
<textarea class="chat-input" id="chat-input" placeholder="Spør om kostnader..." rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}"></textarea>
<button class="chat-send" onclick="sendChat()">
<span class="material-symbols-outlined">send</span>
</button>
</div>
</div>
<script>
const API_BASE='';
const BUDGET_NOK=500;
const REFRESH_MS=5*60*1000;
const SVC_COLORS=['#4285f4','#34a853','#fbbc04','#ea4335','#8ab4f8','#81c995','#c58af9','#ff8c42'];
let barChart,donutChart,period=30;
let currentData={summary:[],forecast:{},anomalies:[]};
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 fetchAll(){
const dot=document.getElementById('live-dot');
dot.style.background='var(--yellow)';
try{
const[sr,fr,ar]=await Promise.all([
fetch(API_BASE+'/billing/summary'),
fetch(API_BASE+'/billing/forecast'),
fetch(API_BASE+'/billing/anomalies')
]);
if(!sr.ok||!fr.ok||!ar.ok)throw new Error('HTTP '+sr.status);
const s=await sr.json(),f=await fr.json(),a=await ar.json();
if(s.error||f.error)throw new Error(s.error||f.error);
// Onboarding: vis banner hvis API sier awaiting_data
if(s.onboarding_status&&s.onboarding_status.state==='awaiting_data'){
document.getElementById('onboarding-banner').classList.remove('hidden');
} else {
document.getElementById('onboarding-banner').classList.add('hidden');
}
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[]};
document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--green)';
updateGeminiInsight();
}catch(err){
console.error('API feil:',err.message);
document.getElementById('err-msg').textContent='Kan ikke nå API: '+err.message;
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)';
}
renderAll();
}
function updateGeminiInsight(){
const{summary,forecast,anomalies}=currentData;
const top=summary.find(s=>(s.total_cost||0)>0);
let msg;
if(!top){
msg='Ingen vesentlige GCP-kostnader registrert ennå. BigQuery billing export tar 2448 timer å populere første gang.';
} else {
const parts=[`Høyeste kostnad: ${top.service} (${fmtNOK(top.total_cost)})`];
if(forecast.total_monthly_forecast)parts.push(`Månedsprognose: ${fmtNOK(forecast.total_monthly_forecast)}`);
parts.push(anomalies.length?anomalies.length+' anomali(er) oppdaget — sjekk listen.':'Ingen anomalier detektert.');
msg=parts.join('. ');
}
document.getElementById('gem-text').textContent=msg;
}
function genLabels(n){
return Array.from({length:n},(_,i)=>{const d=new Date();d.setDate(d.getDate()-(n-1-i));return d.toLocaleDateString('no-NO',{day:'numeric',month:'short'});});
}
function renderAll(){
const{summary,forecast,anomalies}=currentData;
buildDonut(summary);
buildSvc(summary);
buildAnom(anomalies);
updateKPIs(forecast,anomalies.length);
// Bar chart: bare vis hvis vi har MTD-data
const mtd=forecast.month_to_date_cost||0;
if(Math.abs(mtd)>0.000001){
buildBar(mtd,period);
} else {
// Vis empty state
document.getElementById('bar-empty').style.display='flex';
document.getElementById('bar-chart').style.display='none';
if(barChart){barChart.destroy();barChart=null;}
}
const now=new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
document.getElementById('footer-txt').textContent='Oppdatert '+now+' · live GCP-data';
document.getElementById('last-updated').textContent='Sist oppdatert: '+now;
const mn=['Januar','Februar','Mars','April','Mai','Juni','Juli','August','September','Oktober','November','Desember'];
document.getElementById('budget-month').textContent=mn[new Date().getMonth()]+' '+new Date().getFullYear();
}
function buildBar(mtd,days){
// Fordel MTD jevnt over dagene — reell fordeling
// Siden BigQuery ikke gir per-dag historikk via dette endepunktet,
// viser vi MTD som én bar på dagens dato og tomme for resten
const costs=Array(days).fill(0);
costs[costs.length-1]=Math.abs(mtd);
const labels=genLabels(days);
document.getElementById('bar-empty').style.display='none';
document.getElementById('bar-chart').style.display='block';
const ctx=document.getElementById('bar-chart').getContext('2d');
if(barChart)barChart.destroy();
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
{label:'Kostnad',data:costs,backgroundColor:'rgba(66,133,244,0.5)',borderRadius:4,borderSkipped:false}
]},options:{responsive:true,maintainAspectRatio:false,
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,4)}}},
scales:{x:{grid:{display:false},ticks:{color:'#30363d',font:{size:10},maxTicksLimit:8}},y:{grid:{color:'rgba(139,148,158,.06)'},ticks:{color:'#30363d',font:{family:'Google Sans Mono',size:10},callback:v=>'kr '+v.toFixed(4)},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:SVC_COLORS,borderColor:'#161b22',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:${SVC_COLORS[i%SVC_COLORS.length]}"></div>
<span class="svc-name">${s.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${SVC_COLORS[i%SVC_COLORS.length]}"></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 abs=Math.abs(mtd);
const pct=Math.min(abs/BUDGET_NOK,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(abs);
const r=BUDGET_NOK-abs;
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||0;
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=>'kr '+v.toFixed(2));
animN(document.getElementById('k-daily'),daily,v=>'kr '+v.toFixed(4));
animN(document.getElementById('k-fc'),fc,v=>'kr '+v.toFixed(2));
document.getElementById('k-anom').textContent=anomCount.toString();
document.getElementById('k-fc-d').textContent=fmtDager(left);
const kd=document.getElementById('k-anom-d');
kd.textContent=anomCount?anomCount+' krever gjennomgang':'siste 24t · ingen avvik';
kd.style.color=anomCount?'var(--red)':'var(--green)';
buildGauge(mtd);
}
function updatePeriod(n){
period=n;
document.getElementById('period-lbl').textContent=n+' dager · netto etter kreditter';
const mtd=currentData.forecast.month_to_date_cost||0;
if(Math.abs(mtd)>0.000001)buildBar(mtd,n);
}
let chatOpen=false;
function toggleChat(){
chatOpen=!chatOpen;
document.getElementById('chat-panel').classList.toggle('open',chatOpen);
if(chatOpen)document.getElementById('chat-input').focus();
}
function askGemini(q){
if(!chatOpen)toggleChat();
document.getElementById('chat-input').value=q;
sendChat();
}
function sendChat(){
const inp=document.getElementById('chat-input');
const msg=inp.value.trim();
if(!msg)return;
inp.value='';
addChatMsg(msg,'user');
setTimeout(()=>{
const{summary,forecast,anomalies}=currentData;
let resp;
const ml=msg.toLowerCase();
if(ml.includes('koster')||ml.includes('kostnad')){
const top=summary.filter(s=>(s.total_cost||0)>0).slice(0,3);
resp=top.length?'Topp kostnadsdrivere: '+top.map(s=>`${s.service} (${fmtNOK(s.total_cost)})`).join(', ')+'.':'Ingen kostnader registrert ennå.';
} else if(ml.includes('prognose')||ml.includes('neste')){
const fc=forecast.total_monthly_forecast||0;
resp=fc?`Månedsprognose: ${fmtNOK(fc)}. Daglig snitt: ${fmtNOK(forecast.daily_average_last_7_days||0,4)}. ${fmtDager(forecast.remaining_days_in_month||0)}.`:'Ikke nok data for prognose ennå.';
} else if(ml.includes('anomali')){
resp=anomalies.length?`${anomalies.length} anomali(er): `+anomalies.map(a=>`${a.service} (${a.ratio.toFixed(1)}x normalt)`).join(', '):'Ingen anomalier i dag.';
} else if(ml.includes('budsjett')){
const mtd=Math.abs(forecast.month_to_date_cost||0);
resp=`Brukt: ${fmtNOK(mtd)} av ${fmtNOK(BUDGET_NOK)} (${((mtd/BUDGET_NOK)*100).toFixed(0)}%). ${mtd<BUDGET_NOK?fmtNOK(BUDGET_NOK-mtd)+' gjenstår.':'Budsjett overskredet!'}`;
} else {
resp='Spør meg om kostnader, prognose, anomalier eller budsjett.';
}
addChatMsg(resp,'ai');
},600);
}
function addChatMsg(text,role){
const el=document.getElementById('chat-msgs');
const isAi=role==='ai';
const div=document.createElement('div');
div.className='chat-msg '+(isAi?'ai':'user');
div.innerHTML=`<div class="chat-avatar">${isAi?'<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="white"/></svg>':'C'}</div><div class="chat-bubble">${text}</div>`;
el.appendChild(div);
el.scrollTop=el.scrollHeight;
}
document.addEventListener('DOMContentLoaded',()=>{
fetchAll();
setInterval(fetchAll,REFRESH_MS);
});
</script>
</body>
</html>