redesign: Google Cloud Console / Gemini-stil dashboard + onboarding banner
This commit is contained in:
parent
6c10e87329
commit
46706faaa1
|
|
@ -1,183 +1,442 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="no" data-theme="dark">
|
<html lang="no">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>OSVauco · Billing</title>
|
<title>CostGuard · Billing</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
<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>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root,[data-theme="dark"]{
|
:root{
|
||||||
--bg:#0f0e0d;--surface:#161513;--surface-2:#1c1a18;--surface-3:#232120;
|
--bg:#0d1117;--surface:#161b22;--surface-2:#1c2128;--surface-3:#242c38;
|
||||||
--border:rgba(255,255,255,0.07);--border-h:rgba(255,255,255,0.14);
|
--border:rgba(139,148,158,0.1);--border-h:rgba(139,148,158,0.2);
|
||||||
--text:#e8e6e2;--muted:#7a7874;--faint:#3f3d3a;
|
--text:#e6edf3;--muted:#8b949e;--faint:#30363d;
|
||||||
--primary:#4f98a3;--warn:#fdab43;--warn-bg:rgba(253,171,67,0.1);
|
--blue:#4285f4;--blue-bg:rgba(66,133,244,0.1);--blue-border:rgba(66,133,244,0.25);
|
||||||
--err:#dd6974;--err-bg:rgba(221,105,116,0.1);
|
--green:#34a853;--green-bg:rgba(52,168,83,0.1);
|
||||||
--ok:#6daa45;--ok-bg:rgba(109,170,69,0.1);
|
--yellow:#fbbc04;--yellow-bg:rgba(251,188,4,0.1);
|
||||||
--font:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;
|
--red:#ea4335;--red-bg:rgba(234,67,53,0.1);
|
||||||
--r:8px;--tr:160ms cubic-bezier(0.16,1,0.3,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}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
html{-webkit-font-smoothing:antialiased}
|
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}
|
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
|
||||||
.topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:12px;position:sticky;top:0;z-index:100}
|
|
||||||
.logo{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;letter-spacing:-.01em}
|
/* TOPBAR — Google Cloud style */
|
||||||
.logo svg{color:var(--primary)}
|
.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}
|
||||||
.sep{width:1px;height:20px;background:var(--border);margin:0 4px}
|
.tb-logo{display:flex;align-items:center;gap:10px;padding:0 16px;border-right:1px solid var(--border)}
|
||||||
.tbar-title{font-size:13px;color:var(--muted)}
|
.tb-logo-icon{width:28px;height:28px;background:linear-gradient(135deg,#4285f4,#8ab4f8);border-radius:6px;display:flex;align-items:center;justify-content:center}
|
||||||
.tbar-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
.tb-logo-icon svg{fill:#fff;width:16px;height:16px}
|
||||||
.badge{font-size:11px;padding:2px 8px;border-radius:100px;font-weight:500}
|
.tb-logo-name{font-size:15px;font-weight:500;letter-spacing:-.01em}
|
||||||
.b-ok{background:var(--ok-bg);color:var(--ok)}.b-warn{background:var(--warn-bg);color:var(--warn)}.b-err{background:var(--err-bg);color:var(--err)}
|
.tb-logo-name span{color:var(--blue);font-weight:700}
|
||||||
.sel{background:var(--surface-2);border:1px solid var(--border);color:var(--muted);font-size:12px;padding:4px 10px;border-radius:6px;cursor:pointer;outline:none;font-family:var(--font)}
|
.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)}
|
||||||
.sel:hover{border-color:var(--border-h);color:var(--text)}
|
.tb-project:hover{background:var(--surface-2)}
|
||||||
.main{padding:20px;max-width:1280px;margin:0 auto;display:flex;flex-direction:column;gap:16px}
|
.tb-project .material-symbols-outlined{font-size:16px}
|
||||||
.sec-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);padding:0 2px;margin-bottom:6px}
|
.tb-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||||
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
.tb-badge{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;padding:4px 10px;border-radius:16px;border:1px solid}
|
||||||
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px 18px;display:flex;flex-direction:column;gap:4px;transition:border-color var(--tr)}
|
.tb-badge.ok{background:var(--green-bg);color:var(--green);border-color:rgba(52,168,83,0.25)}
|
||||||
.kpi:hover{border-color:var(--border-h)}
|
.tb-badge.warn{background:var(--yellow-bg);color:var(--yellow);border-color:rgba(251,188,4,0.25)}
|
||||||
.kpi-lbl{font-size:11px;color:var(--muted);font-weight:500}
|
.tb-badge.err{background:var(--red-bg);color:var(--red);border-color:rgba(234,67,53,0.25)}
|
||||||
.kpi-val{font-family:var(--mono);font-size:22px;font-weight:600;letter-spacing:-.03em}
|
.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)}
|
||||||
.kpi-delta{font-size:11px;color:var(--muted);margin-top:2px}
|
.tb-icon-btn:hover{background:var(--surface-2);color:var(--text)}
|
||||||
.kpi-delta.up{color:var(--warn)}.kpi-delta.dn{color:var(--ok)}
|
.tb-icon-btn .material-symbols-outlined{font-size:20px}
|
||||||
.k-p{border-top:2px solid var(--primary)}.k-w{border-top:2px solid var(--warn)}.k-e{border-top:2px solid var(--err)}
|
.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)}
|
||||||
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
|
.tb-period:hover{border-color:var(--border-h)}
|
||||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px 18px}
|
.pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;position:relative}
|
||||||
.card-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
.pulse::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:var(--green);opacity:.4;animation:pulse 2s ease-out infinite}
|
||||||
.card-title{font-size:13px;font-weight:600;letter-spacing:-.01em}
|
|
||||||
.card-sub{font-size:11px;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:9px 0;border-bottom:1px solid var(--border);font-size:12px}
|
|
||||||
.anomaly-row:last-child{border-bottom:none}
|
|
||||||
.a-svc{font-weight:500}.a-cost{font-family:var(--mono);color:var(--muted)}
|
|
||||||
.a-ratio{font-family:var(--mono);font-size:11px;padding:2px 7px;border-radius:4px;font-weight:600}
|
|
||||||
.r-h{background:var(--err-bg);color:var(--err)}.r-m{background:var(--warn-bg);color:var(--warn)}.r-l{background:var(--ok-bg);color:var(--ok)}
|
|
||||||
.svc-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border)}
|
|
||||||
.svc-row:last-child{border-bottom:none}
|
|
||||||
.svc-name{font-size:12px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.svc-bar-w{width:100px;height:3px;background:var(--surface-3);border-radius:2px;flex-shrink:0}
|
|
||||||
.svc-bar{height:100%;border-radius:2px;background:var(--primary);transition:width .8s cubic-bezier(.16,1,.3,1)}
|
|
||||||
.svc-cost{font-family:var(--mono);font-size:11px;color:var(--muted);width:56px;text-align:right;flex-shrink:0}
|
|
||||||
.gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:10px;padding:6px 0}
|
|
||||||
.gauge-ring{position:relative;width:120px;height:66px;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:18px;font-weight:700}
|
|
||||||
.gauge-meta{font-size:11px;color:var(--muted);text-align:center}
|
|
||||||
.pulse{width:6px;height:6px;border-radius:50%;background:var(--ok);display:inline-block;position:relative}
|
|
||||||
.pulse::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:var(--ok);opacity:.4;animation:pulse 2s ease-out infinite}
|
|
||||||
@keyframes pulse{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.5);opacity:0}}
|
@keyframes pulse{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.5);opacity:0}}
|
||||||
.empty-state{color:var(--muted);font-size:12px;padding:20px 0;text-align:center}
|
|
||||||
.empty-icon{margin:0 auto 8px;display:block;opacity:.3}
|
/* LAYOUT */
|
||||||
.err-banner{background:var(--err-bg);border:1px solid rgba(221,105,116,0.2);border-radius:var(--r);padding:10px 14px;font-size:12px;color:var(--err);display:none;align-items:center;gap:8px}
|
.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}
|
.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}}
|
/* 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- TOPBAR -->
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="logo">
|
<div class="tb-logo">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-label="OSVauco">
|
<div class="tb-logo-icon">
|
||||||
<path d="M10 2L17 6V14L10 18L3 14V6L10 2Z" stroke="currentColor" stroke-width="1.5"/>
|
<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>
|
||||||
<path d="M10 6L14 8.5V13.5L10 16L6 13.5V8.5L10 6Z" fill="currentColor" opacity="0.25"/>
|
</div>
|
||||||
<circle cx="10" cy="10" r="2" fill="currentColor"/>
|
<span class="tb-logo-name">Cost<span>Guard</span></span>
|
||||||
</svg>
|
|
||||||
OSVauco
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sep"></div>
|
<div class="tb-project">
|
||||||
<span class="tbar-title">Billing Overview</span>
|
<span class="material-symbols-outlined">folder_open</span>
|
||||||
<div class="tbar-right">
|
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 class="pulse" id="live-dot"></span>
|
||||||
<span id="top-badge" class="badge b-ok">Ingen anomalier</span>
|
<span id="top-badge" class="tb-badge ok">
|
||||||
<select class="sel" onchange="updatePeriod(+this.value)">
|
<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="7">7 dager</option>
|
||||||
<option value="30" selected>30 dager</option>
|
<option value="30" selected>30 dager</option>
|
||||||
<option value="90">90 dager</option>
|
<option value="90">90 dager</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="sel" onclick="fetchAll()" style="cursor:pointer" title="Oppdater">↻</button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main">
|
<div class="layout">
|
||||||
<div class="err-banner" id="err-banner">
|
<!-- SIDEBAR -->
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
<nav class="sidebar">
|
||||||
<span id="err-msg">API utilgjengelig — viser demo-data.</span>
|
<div class="nav-section">Oversikt</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div>
|
<!-- MAIN CONTENT -->
|
||||||
<div class="sec-label">Nøkkeltall</div>
|
<main class="content">
|
||||||
<div class="kpi-row">
|
|
||||||
<div class="kpi k-p"><span class="kpi-lbl">Totalkostnad MTD</span><span class="kpi-val" id="k-mtd">kr —</span><span class="kpi-delta up">↑ 12% vs april</span></div>
|
|
||||||
<div class="kpi"><span class="kpi-lbl">Daglig snitt (7d)</span><span class="kpi-val" id="k-daily">kr —</span><span class="kpi-delta">snitt siste 7 dager</span></div>
|
|
||||||
<div class="kpi k-w"><span class="kpi-lbl">Prognose (måneden)</span><span class="kpi-val" id="k-fc">kr —</span><span class="kpi-delta" id="k-fc-d">— dager gjennstår</span></div>
|
|
||||||
<div class="kpi" id="k-anom-card"><span class="kpi-lbl">Anomalier</span><span class="kpi-val" id="k-anom">—</span><span class="kpi-delta dn" id="k-anom-d">siste 24t</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="charts-row">
|
<!-- ONBOARDING BANNER -->
|
||||||
<div class="card">
|
<div class="onboarding-banner" id="onboarding-banner">
|
||||||
<div class="card-hdr"><span class="card-title">Daglig kostnad</span><span class="card-sub" id="period-lbl">30 dager</span></div>
|
<div class="ob-icon"><span class="material-symbols-outlined">schedule</span></div>
|
||||||
<div class="chart-wrap"><canvas id="bar-chart"></canvas></div>
|
<div class="ob-body">
|
||||||
|
<h4>BigQuery billing export synkroniserer</h4>
|
||||||
|
<p>GCP eksporterer fakturadata med 24–48 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>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div class="card-hdr"><span class="card-title">Fordeling per tjeneste</span><span class="card-sub">Andel</span></div>
|
|
||||||
<div class="chart-wrap"><canvas id="donut-chart"></canvas></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-row">
|
<!-- GEMINI INSIGHT -->
|
||||||
<div class="card">
|
<div class="gemini-card" id="gemini-insight">
|
||||||
<div class="card-hdr"><span class="card-title">Anomali-deteksjon</span><span class="badge b-ok" id="anom-badge" style="font-size:10px">OK</span></div>
|
<div class="gem-icon">
|
||||||
<div id="anom-list"><div class="empty-state">Laster...</div></div>
|
<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>
|
||||||
<div class="card">
|
<div class="gem-body">
|
||||||
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
|
<div class="gem-label">Gemini Insight</div>
|
||||||
<div id="svc-list"></div>
|
<div class="gem-text" id="gem-text">Analyserer dine GCP-kostnader...</div>
|
||||||
</div>
|
<div class="gem-chips">
|
||||||
<div class="card">
|
<span class="gem-chip" onclick="askGemini('Hva koster mest?')">Hva koster mest?</span>
|
||||||
<div class="card-hdr"><span class="card-title">Budsjett-status</span><span class="card-sub">Mai 2026</span></div>
|
<span class="gem-chip" onclick="askGemini('Vis prognose for neste måned')">Prognose neste måned</span>
|
||||||
<div class="gauge-wrap">
|
<span class="gem-chip" onclick="askGemini('Er det noen anomalier?')">Anomalier?</span>
|
||||||
<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">av kr 500,00 budsjett</div>
|
|
||||||
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjennstår</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer id="footer">Laster data...</footer>
|
<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>
|
<script>
|
||||||
const API_BASE = '';
|
const API_BASE='';
|
||||||
const BUDGET_NOK = 500;
|
const BUDGET_NOK=500;
|
||||||
const REFRESH_MS = 5 * 60 * 1000;
|
const REFRESH_MS=5*60*1000;
|
||||||
|
const SVC_COLORS=['#4285f4','#34a853','#fbbc04','#ea4335','#8ab4f8','#81c995','#c58af9','#ff8c42'];
|
||||||
|
|
||||||
const DEMO_SUMMARY = [
|
const DEMO_SUMMARY=[
|
||||||
{service:'Cloud Run',total_cost:1.82},{service:'Cloud Build',total_cost:0.61},
|
{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:'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:'Secret Manager',total_cost:0.09},{service:'Pub/Sub',total_cost:0.04},
|
||||||
{service:'Cloud Storage',total_cost:0.03}
|
{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_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 = [];
|
const DEMO_ANOMALIES=[];
|
||||||
|
|
||||||
let barChart,donutChart,period=30;
|
let barChart,donutChart,period=30;
|
||||||
let currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
|
let currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
|
||||||
|
let isLiveData=false;
|
||||||
|
|
||||||
function fmtNOK(v,d=2){return 'kr '+v.toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d})}
|
function fmtNOK(v,d=2){return 'kr '+Number(v).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d})}
|
||||||
function animN(el,end,fmt,dur=700){
|
function animN(el,end,fmt,dur=700){
|
||||||
const t0=performance.now();
|
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);};
|
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);};
|
||||||
|
|
@ -186,9 +445,9 @@ function animN(el,end,fmt,dur=700){
|
||||||
|
|
||||||
async function fetchAll(){
|
async function fetchAll(){
|
||||||
const dot=document.getElementById('live-dot');
|
const dot=document.getElementById('live-dot');
|
||||||
dot.style.background='var(--warn)';
|
dot.style.background='var(--yellow)';
|
||||||
try{
|
try{
|
||||||
const [sr,fr,ar]=await Promise.all([
|
const[sr,fr,ar]=await Promise.all([
|
||||||
fetch(API_BASE+'/billing/summary'),
|
fetch(API_BASE+'/billing/summary'),
|
||||||
fetch(API_BASE+'/billing/forecast'),
|
fetch(API_BASE+'/billing/forecast'),
|
||||||
fetch(API_BASE+'/billing/anomalies')
|
fetch(API_BASE+'/billing/anomalies')
|
||||||
|
|
@ -196,26 +455,46 @@ async function fetchAll(){
|
||||||
if(!sr.ok||!fr.ok||!ar.ok)throw new Error('HTTP '+sr.status);
|
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();
|
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);
|
if(s.error||f.error)throw new Error(s.error||f.error);
|
||||||
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[]};
|
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');
|
document.getElementById('err-banner').classList.remove('show');
|
||||||
dot.style.background='var(--ok)';
|
dot.style.background='var(--green)';
|
||||||
|
updateGeminiInsight();
|
||||||
}catch(err){
|
}catch(err){
|
||||||
console.warn('API feil, demo-data:',err.message);
|
console.warn('API feil:',err.message);
|
||||||
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
|
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES};
|
||||||
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message+' — demo-data vises.';
|
document.getElementById('err-msg').textContent='Tilkobling feilet: '+err.message+' — viser demo-data.';
|
||||||
document.getElementById('err-banner').classList.add('show');
|
document.getElementById('err-banner').classList.add('show');
|
||||||
dot.style.background='var(--err)';
|
dot.style.background='var(--red)';
|
||||||
setTimeout(()=>dot.style.background='var(--ok)',3000);
|
setTimeout(()=>dot.style.background='var(--green)',3000);
|
||||||
}
|
}
|
||||||
renderAll();
|
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 24–48 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){
|
function genDailyFromMTD(mtd,days){
|
||||||
const base=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))));
|
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;
|
arr[arr.length-4]*=2.1;
|
||||||
const sum=arr.reduce((a,b)=>a+b,0);
|
const sum=arr.reduce((a,b)=>a+b,0);
|
||||||
return arr.map(v=>v*mtd/sum);
|
return arr.map(v=>v*(mtd||0.1)/sum);
|
||||||
}
|
}
|
||||||
function genLabels(n){
|
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'});});
|
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'});});
|
||||||
|
|
@ -225,91 +504,160 @@ function renderAll(){
|
||||||
const{summary,forecast,anomalies}=currentData;
|
const{summary,forecast,anomalies}=currentData;
|
||||||
const costs=genDailyFromMTD(forecast.month_to_date_cost||3,period);
|
const costs=genDailyFromMTD(forecast.month_to_date_cost||3,period);
|
||||||
buildBar(costs);buildDonut(summary);buildSvc(summary);buildAnom(anomalies);updateKPIs(forecast,anomalies.length);
|
buildBar(costs);buildDonut(summary);buildSvc(summary);buildAnom(anomalies);updateKPIs(forecast,anomalies.length);
|
||||||
document.getElementById('footer').textContent='Oppdatert '+new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
|
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){
|
function buildBar(costs){
|
||||||
const labels=genLabels(costs.length);
|
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 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');
|
const ctx=document.getElementById('bar-chart').getContext('2d');
|
||||||
if(barChart)barChart.destroy();
|
if(barChart)barChart.destroy();
|
||||||
const avgC=costs.reduce((a,b)=>a+b,0)/costs.length;
|
|
||||||
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
|
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
|
||||||
{label:'Kostnad',data:costs,backgroundColor:costs.map(v=>v>avgC*1.8?'rgba(221,105,116,0.65)':v>avgC*1.3?'rgba(253,171,67,0.55)':'rgba(79,152,163,0.45)'),borderRadius:3,borderSkipped:false,order:2},
|
{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(255,255,255,0.18)',borderWidth:1.5,pointRadius:0,tension:.4,fill:false,order:1}
|
{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,
|
]},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,4)}}},
|
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:'#3f3d3a',font:{size:10},maxTicksLimit:8}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(3)},border:{display:false}}}}});
|
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){
|
function buildDonut(summary){
|
||||||
const ctx=document.getElementById('donut-chart').getContext('2d');
|
const ctx=document.getElementById('donut-chart').getContext('2d');
|
||||||
if(donutChart)donutChart.destroy();
|
if(donutChart)donutChart.destroy();
|
||||||
const cols=['#4f98a3','#6daa45','#fdab43','#dd6974','#a86fdf','#5591c7','#3f3d3a'];
|
|
||||||
const top=summary.slice(0,7);
|
const top=summary.slice(0,7);
|
||||||
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.total_cost||s.cost||0),backgroundColor:cols,borderColor:'#161513',borderWidth:2,hoverBorderWidth:3}]},options:{responsive:true,maintainAspectRatio:false,cutout:'68%',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)}}}}});
|
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){
|
function buildSvc(summary){
|
||||||
const el=document.getElementById('svc-list');
|
const el=document.getElementById('svc-list');
|
||||||
const top=summary.slice(0,8);
|
const top=summary.slice(0,8);
|
||||||
const max=Math.max(...top.map(s=>s.total_cost||s.cost||0.01));
|
const max=Math.max(...top.map(s=>s.total_cost||0),0.001);
|
||||||
el.innerHTML=top.map(s=>{const c=s.total_cost||s.cost||0;return`<div class="svc-row"><span class="svc-name">${s.service}</span><div class="svc-bar-w"><div class="svc-bar" style="width:${(c/max*100).toFixed(1)}%"></div></div><span class="svc-cost">${fmtNOK(c)}</span></div>`;}).join('');
|
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){
|
function buildAnom(anomalies){
|
||||||
const el=document.getElementById('anom-list');
|
const el=document.getElementById('anom-list');
|
||||||
const tb=document.getElementById('top-badge'),ab=document.getElementById('anom-badge');
|
const tb=document.getElementById('top-badge'),ab=document.getElementById('anom-badge');
|
||||||
const kc=document.getElementById('k-anom-card'),kd=document.getElementById('k-anom-d');
|
|
||||||
if(!anomalies.length){
|
if(!anomalies.length){
|
||||||
el.innerHTML=`<div class="empty-state"><svg class="empty-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>Ingen anomalier detektert</div>`;
|
el.innerHTML=`<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anomalier detektert</div>`;
|
||||||
tb.className='badge b-ok';tb.textContent='Ingen anomalier';
|
tb.className='tb-badge ok';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
|
||||||
ab.className='badge b-ok';ab.textContent='OK';
|
ab.className='tb-badge ok';ab.textContent='OK';
|
||||||
kc.className='kpi';kd.textContent='siste 24t';kd.className='kpi-delta dn';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.className='badge b-err';tb.textContent=anomalies.length+' anomali'+(anomalies.length>1?'er':'');
|
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='badge b-err';ab.textContent='AKTIV';
|
ab.className='tb-badge err';ab.textContent='AKTIV';
|
||||||
kc.className='kpi k-e';kd.textContent='krever gjennomgang';kd.className='kpi-delta up';
|
|
||||||
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('');
|
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){
|
function buildGauge(mtd){
|
||||||
const pct=Math.min(mtd/BUDGET_NOK,1);
|
const pct=Math.min(Math.max(mtd/BUDGET_NOK,0),1);
|
||||||
const arc=document.getElementById('gauge-arc');
|
const arc=document.getElementById('gauge-arc');
|
||||||
arc.style.stroke=pct>.8?'var(--err)':pct>.6?'var(--warn)':'var(--primary)';
|
arc.style.stroke=pct>.8?'var(--red)':pct>.6?'var(--yellow)':'var(--blue)';
|
||||||
setTimeout(()=>arc.style.strokeDashoffset=157*(1-pct),80);
|
setTimeout(()=>arc.style.strokeDashoffset=173*(1-pct),80);
|
||||||
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
|
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
|
||||||
document.getElementById('gauge-used').textContent=fmtNOK(mtd);
|
document.getElementById('gauge-used').textContent=fmtNOK(mtd);
|
||||||
const r=BUDGET_NOK-mtd;
|
const r=BUDGET_NOK-mtd;
|
||||||
const rel=document.getElementById('gauge-remain');
|
const rel=document.getElementById('gauge-remain');
|
||||||
rel.textContent=r>0?fmtNOK(r)+' gjennstår':'Budsjett overskredet';
|
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
|
||||||
rel.style.color=r<0?'var(--err)':'var(--muted)';
|
rel.style.color=r<0?'var(--red)':'var(--muted)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKPIs(forecast,anomCount){
|
function updateKPIs(forecast,anomCount){
|
||||||
const mtd=forecast.month_to_date_cost||0;
|
const mtd=forecast.month_to_date_cost||0;
|
||||||
const daily=forecast.daily_average_last_7_days||0;
|
const daily=forecast.daily_average_last_7_days||0;
|
||||||
const fc=forecast.total_monthly_forecast||0;
|
const fc=forecast.total_monthly_forecast||0;
|
||||||
const left=31-new Date().getDate();
|
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-mtd'),mtd,v=>'kr '+v.toFixed(2));
|
||||||
animN(document.getElementById('k-daily'),daily,v=>'kr '+v.toFixed(4));
|
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-fc'),fc,v=>'kr '+v.toFixed(2));
|
||||||
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
|
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
|
||||||
document.getElementById('k-fc-d').textContent=left+' dager gjennstår';
|
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);
|
buildGauge(mtd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePeriod(n){
|
function updatePeriod(n){
|
||||||
period=n;
|
period=n;
|
||||||
document.getElementById('period-lbl').textContent=n+' dager';
|
document.getElementById('period-lbl').textContent=n+' dager · netto etter kreditter';
|
||||||
buildBar(genDailyFromMTD(currentData.forecast.month_to_date_cost||3,n));
|
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',()=>{
|
document.addEventListener('DOMContentLoaded',()=>{
|
||||||
fetchAll();
|
fetchAll();
|
||||||
setInterval(fetchAll,REFRESH_MS);
|
setInterval(fetchAll,REFRESH_MS);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user