OSVauco/static/billing-dashboard.html

664 lines
36 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 — Google Cloud style */
.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;cursor:pointer;transition:background var(--tr);font-size:13px;color:var(--muted)}
.tb-project:hover{background:var(--surface-2)}
.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 */
.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;max-width:1200px;overflow:hidden}
/* ONBOARDING BANNER */
.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 INSIGHT CARD */
.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 HEADER */
.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 CARDS */
.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}
.kpi-delta .material-symbols-outlined{font-size:14px}
.delta-up{color:var(--yellow)}.delta-dn{color:var(--green)}
/* CHARTS */
.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 */
.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}
/* BUDGET GAUGE */
.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 */
.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}
/* ERROR BANNER */
.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 PANEL */
.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>
<!-- TOPBAR -->
<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
<span class="material-symbols-outlined">expand_more</span>
</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">
<!-- SIDEBAR -->
<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 CONTENT -->
<main class="content">
<!-- ONBOARDING BANNER -->
<div class="onboarding-banner" 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. Dashboard viser demo-data inntil eksporten er populert. 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>
<!-- GEMINI INSIGHT -->
<div class="gemini-card" id="gemini-insight">
<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">Analyserer dine GCP-kostnader...</div>
<div class="gem-chips">
<span class="gem-chip" onclick="askGemini('Hva koster mest?')">Hva koster mest?</span>
<span class="gem-chip" onclick="askGemini('Vis prognose for neste måned')">Prognose neste måned</span>
<span class="gem-chip" onclick="askGemini('Er det noen anomalier?')">Anomalier?</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">API utilgjengelig — viser demo-data.</span>
</div>
<!-- PAGE HEADER -->
<div class="page-hdr">
<h1 class="page-title">Billing <strong>Oversikt</strong></h1>
<span style="font-size:12px;color:var(--muted)" id="last-updated">Laster...</span>
</div>
<!-- KPI CARDS -->
<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">kr —</div>
<div class="kpi-delta"><span class="material-symbols-outlined delta-up">arrow_upward</span><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">kr —</div>
<div class="kpi-delta"><span style="color:var(--muted)">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">kr —</div>
<div class="kpi-delta"><span class="material-symbols-outlined" style="font-size:14px;color:var(--muted)">calendar_month</span><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" style="color:var(--green)">siste 24t · ingen avvik</span></div>
</div>
</div>
<!-- CHARTS -->
<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"><canvas id="bar-chart"></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"><canvas id="donut-chart"></canvas></div>
</div>
</div>
<!-- BOTTOM ROW -->
<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>
</div>
<div class="card">
<div class="card-hdr">
<span class="card-title">Budsjett-status</span>
<span class="card-sub" id="budget-month">Mai 2026</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" id="gauge-budget-lbl">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 -->
<footer>
<span id="footer-txt">Laster data...</span>
<span style="color:var(--faint)">CostGuard · Vauco OS · nettokostnad etter kreditter</span>
</footer>
<!-- CHAT FAB -->
<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>
<!-- CHAT PANEL -->
<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"/></svg>
</div>
<div class="chat-bubble">Hei! Jeg er din GCP-kostnadsassistent. Spør meg om kostnader, anomalier eller prognoser for prosjektet ditt.</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'];
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,remaining_days_in_month:1};
const DEMO_ANOMALIES=[];
let barChart,donutChart,period=30;
let currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
let isLiveData=false;
function fmtNOK(v,d=2){return 'kr '+Number(v).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d})}
function animN(el,end,fmt,dur=700){
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*end);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);
const summary=s.summary||[];
const hasLiveData=summary.some(x=>(x.total_cost||0)!==0);
// Onboarding banner
if(s.onboarding_status||!hasLiveData){
document.getElementById('onboarding-banner').classList.remove('hidden');
} else {
document.getElementById('onboarding-banner').classList.add('hidden');
}
isLiveData=hasLiveData;
currentData={summary:hasLiveData?summary:DEMO_SUMMARY,forecast:f,anomalies:a.anomalies||[]};
document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--green)';
updateGeminiInsight();
}catch(err){
console.warn('API feil:',err.message);
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
document.getElementById('err-msg').textContent='Tilkobling feilet: '+err.message+' — viser demo-data.';
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)';
setTimeout(()=>dot.style.background='var(--green)',3000);
}
renderAll();
}
function updateGeminiInsight(){
const{summary,forecast,anomalies}=currentData;
const top=summary[0];
let msg='Ditt GCP-prosjekt har ingen vesentlige kostnader registrert ennå. BigQuery billing export tar 2448 timer å populere første gang.';
if(top&&top.total_cost>0){
msg=`Høyeste kostnad: ${top.service} (${fmtNOK(top.total_cost)}). Månedsprognose: ${fmtNOK(forecast.total_monthly_forecast||0)}. ${anomalies.length?anomalies.length+' anomali(er) oppdaget — sjekk listen.':'Ingen anomalier detektert.'}`;
}
document.getElementById('gem-text').textContent=msg;
}
function genDailyFromMTD(mtd,days){
const base=Math.max(mtd/days,0.001);
const arr=Array.from({length:days},()=>Math.max(0,parseFloat((base*(0.7+Math.random()*0.6)).toFixed(4))));
arr[arr.length-4]*=2.1;
const sum=arr.reduce((a,b)=>a+b,0);
return arr.map(v=>v*(mtd||0.1)/sum);
}
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;
const costs=genDailyFromMTD(forecast.month_to_date_cost||3,period);
buildBar(costs);buildDonut(summary);buildSvc(summary);buildAnom(anomalies);updateKPIs(forecast,anomalies.length);
const now=new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
document.getElementById('footer-txt').textContent='Oppdatert '+now+(isLiveData?' · live GCP-data':' · demo-data');
document.getElementById('last-updated').textContent='Sist oppdatert: '+now;
// Budget month
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(costs){
const labels=genLabels(costs.length);
const avg7=costs.map((_,i)=>{const s=costs.slice(Math.max(0,i-6),i+1);return s.reduce((a,b)=>a+b,0)/s.length;});
const avgC=costs.reduce((a,b)=>a+b,0)/costs.length;
const 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:costs.map(v=>v>avgC*1.8?'rgba(234,67,53,0.6)':v>avgC*1.3?'rgba(251,188,4,0.55)':'rgba(66,133,244,0.45)'),borderRadius:4,borderSkipped:false,order:2},
{label:'7d snitt',data:avg7,type:'line',borderColor:'rgba(138,180,248,0.5)',borderWidth:1.5,pointRadius:0,tension:.4,fill:false,order:1}
]},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',titleFont:{family:'Google Sans',size:11},bodyFont:{family:'Google Sans Mono',size:12,weight:'600'},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(3)},border:{display:false}}}}});
}
function buildDonut(summary){
const ctx=document.getElementById('donut-chart').getContext('2d');
if(donutChart)donutChart.destroy();
const top=summary.slice(0,7);
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>Math.max(s.total_cost||0,0.001)),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);
const max=Math.max(...top.map(s=>s.total_cost||0),0.001);
el.innerHTML=top.map((s,i)=>{
const c=Math.max(s.total_cost||0,0);
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:${(c/max*100).toFixed(1)}%;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 pct=Math.min(Math.max(mtd/BUDGET_NOK,0),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);
const r=BUDGET_NOK-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||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));
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
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';
buildBar(genDailyFromMTD(currentData.forecast.month_to_date_cost||3,n));
}
// CHAT
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');
// Simulate AI response based on current data
setTimeout(()=>{
const{summary,forecast,anomalies}=currentData;
let resp='Jeg analyserer dataene dine...';
const ml=msg.toLowerCase();
if(ml.includes('koster')&&summary.length){
const top=summary.slice(0,3);
resp='Topp 3 kostnadsdrivere: '+top.map(s=>`${s.service} (${fmtNOK(s.total_cost)})`).join(', ')+'.';
} else if(ml.includes('prognose')||ml.includes('neste')){
resp=`Månedsprognose: ${fmtNOK(forecast.total_monthly_forecast||0)}. Daglig snitt: ${fmtNOK(forecast.daily_average_last_7_days||0,4)}. ${forecast.remaining_days_in_month||0} dager gjenstår.`;
} else if(ml.includes('anomali')){
resp=anomalies.length?`${anomalies.length} anomali(er) oppdaget: `+anomalies.map(a=>`${a.service} (${a.ratio.toFixed(1)}x normalt)`).join(', '):"Ingen anomalier detektert i dag. Alle tjenester innenfor forventet forbruk.";
} else if(ml.includes('budsjett')){
const mtd=forecast.month_to_date_cost||0;
const pct=((mtd/BUDGET_NOK)*100).toFixed(0);
resp=`Du har brukt ${fmtNOK(mtd)} av ${fmtNOK(BUDGET_NOK)} budsjett (${pct}%). ${mtd<BUDGET_NOK?fmtNOK(BUDGET_NOK-mtd)+' gjenstår.':'Budsjett overskredet!'}`;
} else {
resp='Spør meg om kostnader, prognose, anomalier eller budsjett-status for GCP-prosjektet ditt.';
}
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>