OSVauco/static/billing-dashboard.html
Chris Christiansen 9fcb9c354a
Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
feat(core): Fresh initialization - Deploy v3.6.1 Singularity Architecture
2026-09-03 04:03:09 +00:00

1042 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CostGuard · Billing</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0a0a0f">
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{
--bg:#0a0a0f;--surface:#13131c;--surface-2:#1a1a26;--surface-3:#242436;
--border:rgba(124,92,255,0.12);--border-h:rgba(124,92,255,0.28);
--text:#ececf5;--muted:#8a8aa3;--faint:#3a3a4d;
--blue:#7c5cff;--blue-bg:rgba(124,92,255,0.12);--blue-border:rgba(124,92,255,0.3);
--primary:#7c5cff;--accent-2:#00d4a8;
--green:#00d4a8;--green-bg:rgba(0,212,168,0.12);
--yellow:#ffb454;--yellow-bg:rgba(255,180,84,0.12);
--red:#ff5c7c;--red-bg:rgba(255,92,124,0.12);
--ok:#00d4a8;--warn:#ffb454;
--font:'Inter',-apple-system,sans-serif;--mono:'JetBrains Mono',monospace;
--r:10px;--tr:200ms cubic-bezier(0.2,0,0,1);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
/* ── topbar ── */
.topbar{height:56px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:0;position:sticky;top:0;z-index:200}
.tb-logo{display:flex;align-items:center;gap:10px;padding:0 16px;border-right:1px solid var(--border)}
.tb-logo-icon{width:28px;height:28px;background:linear-gradient(135deg,#7c5cff,#00d4a8);border-radius:8px;display:flex;align-items:center;justify-content:center}
.tb-logo-icon svg{fill:none;stroke:#fff;stroke-width:2;width:16px;height:16px}
.tb-logo-name{font-size:15px;font-weight:500;letter-spacing:-.01em}
.tb-logo-name span{background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;font-weight:700}
.tb-project{display:flex;align-items:center;gap:6px;padding:6px 12px;margin:0 8px;font-size:13px;color:var(--muted)}
.tb-project .material-symbols-outlined{font-size:16px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.tb-badge{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;padding:4px 10px;border-radius:16px;border:1px solid}
.tb-badge.ok{background:var(--green-bg);color:var(--green);border-color:rgba(0,212,168,0.25)}
.tb-badge.warn{background:var(--yellow-bg);color:var(--yellow);border-color:rgba(255,180,84,0.25)}
.tb-badge.err{background:var(--red-bg);color:var(--red);border-color:rgba(255,92,124,0.25)}
.tb-period{background:var(--surface-2);border:1px solid var(--border);color:var(--text);font-size:13px;font-family:var(--font);padding:5px 12px;border-radius:6px;cursor:pointer;outline:none;transition:border-color var(--tr)}
.tb-period:hover{border-color:var(--border-h)}
.pulse{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;position:relative}
.pulse::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:var(--green);opacity:.4;animation:pulse 2s ease-out infinite}
@keyframes pulse{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.5);opacity:0}}
/* ── layout ── */
.layout{display:flex;min-height:calc(100vh - 56px)}
.sidebar{width:200px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);padding:12px 0}
.nav-section{padding:8px 16px 4px;font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:12px;padding:8px 16px;font-size:13px;color:var(--muted);cursor:pointer;transition:all var(--tr);border-radius:0 20px 20px 0;margin-right:8px}
.nav-item:hover{background:var(--surface-2);color:var(--text)}
.nav-item.active{background:var(--blue-bg);color:var(--blue);font-weight:600;border-left:2px solid var(--blue)}
.nav-item .material-symbols-outlined{font-size:18px}
.content{flex:1;padding:24px;overflow:hidden}
/* ── sel / buttons ── */
.sel{background:var(--surface-2);border:1px solid var(--border);color:var(--muted);padding:5px 10px;border-radius:6px;font-size:12px;font-family:var(--font);display:inline-flex;align-items:center;gap:6px;cursor:pointer;transition:background var(--tr),border-color var(--tr),color var(--tr)}
.sel:hover{background:var(--surface-3);border-color:var(--border-h);color:var(--text)}
.dropdown{position:relative;display:inline-block}
.dropdown-content{display:none;position:absolute;background:var(--surface-2);min-width:140px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:99;right:0;border-radius:var(--r);border:1px solid var(--border)}
.dropdown-content a{color:var(--text);padding:8px 12px;text-decoration:none;display:block;font-size:12px}
.dropdown-content a:hover{background:var(--surface-3)}
.dropdown:hover .dropdown-content{display:block}
/* ── module grid ── */
.module-grid{display:flex;flex-direction:column;gap:12px}
.kpi-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
.bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
/* ── credits kpi row ── */
.credits-kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
/* ── credit pool bars ── */
.credit-pools{display:flex;flex-direction:column;gap:14px;margin-top:16px}
.pool-item{display:flex;flex-direction:column;gap:6px}
.pool-header{display:flex;align-items:center;justify-content:space-between;font-size:12px}
.pool-label{font-weight:500;color:var(--text);display:flex;align-items:center;gap:6px}
.pool-badge{font-size:10px;font-weight:600;padding:2px 7px;border-radius:99px}
.pool-badge.crit{background:var(--red-bg);color:var(--red)}
.pool-badge.warn{background:var(--yellow-bg);color:var(--yellow)}
.pool-badge.ok{background:var(--green-bg);color:var(--green)}
.pool-badge.inactive{background:var(--surface-3);color:var(--muted)}
.pool-meta{font-size:11px;color:var(--muted);font-family:var(--mono)}
.pool-bar-bg{height:6px;background:var(--surface-3);border-radius:3px;overflow:hidden}
.pool-bar-fill{height:100%;border-radius:3px;transition:width 1.2s cubic-bezier(.16,1,.3,1)}
.pool-warning{font-size:11px;padding:6px 10px;border-radius:6px;margin-top:2px;display:none}
.pool-warning.show{display:flex;align-items:center;gap:6px}
.pool-warning.crit{background:var(--red-bg);color:var(--red)}
.pool-warning.warn{background:var(--yellow-bg);color:var(--yellow)}
/* ── draggable module ── */
.module{
position:relative;
background:var(--surface);
border:1px solid var(--border);
border-radius:12px;
transition:box-shadow var(--tr),border-color var(--tr),opacity .15s;
}
.module:hover{border-color:var(--border-h)}
.module.dragging{opacity:.45;box-shadow:0 8px 32px rgba(124,92,255,.25);cursor:grabbing;}
.module.drag-over{border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-bg)}
.module-handle{
display:flex;align-items:center;justify-content:space-between;
padding:14px 18px 0;
cursor:grab;
user-select:none;
}
.module-handle:active{cursor:grabbing}
.module-handle-left{display:flex;align-items:center;gap:8px}
.drag-grip{color:var(--faint);font-size:16px!important;transition:color var(--tr)}
.module-handle:hover .drag-grip{color:var(--muted)}
.module-title{font-size:14px;font-weight:500}
.module-sub{font-size:12px;color:var(--muted)}
.module-actions{display:flex;align-items:center;gap:4px}
.module-btn{background:none;border:none;cursor:pointer;color:var(--muted);padding:2px 4px;border-radius:4px;transition:color var(--tr),background var(--tr);display:flex;align-items:center}
.module-btn:hover{color:var(--text);background:var(--surface-3)}
.module-btn .material-symbols-outlined{font-size:16px!important}
.module-body{padding:14px 18px 18px}
.module.collapsed .module-body{display:none}
.module.collapsed{border-color:var(--border)}
.collapse-icon{transition:transform .2s}
.module.collapsed .collapse-icon{transform:rotate(-90deg)}
/* ── kpi card ── */
.kpi{padding:0}
.kpi .module-body{display:flex;flex-direction:column;gap:4px}
.kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.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-size:22px;font-weight:700;font-family:var(--mono);letter-spacing:-.03em}
.kpi-delta{font-size:12px;color:var(--muted)}
/* ── free trial expiry banner (topbar) ── */
.freetrial-banner{display:none;width:100%;background:var(--red-bg);border-bottom:1px solid rgba(255,92,124,.25);padding:7px 24px;font-size:12px;color:var(--red);align-items:center;gap:8px;position:sticky;top:56px;z-index:190}
.freetrial-banner.show{display:flex}
.freetrial-banner .material-symbols-outlined{font-size:15px;animation:blink 1.2s ease-in-out infinite}
/* ── credits warning ── */
.credits-warning{display:none;margin-top:12px;padding:10px 14px;border-radius:8px;font-size:12px;align-items:center;gap:8px}
.credits-warning.show{display:flex}
.credits-warning.crit{background:var(--red-bg);color:var(--red);border:1px solid rgba(255,92,124,.2)}
.credits-warning.med{background:var(--yellow-bg);color:var(--yellow);border:1px solid rgba(255,180,84,.2)}
/* ── chart ── */
.chart-wrap{position:relative;height:200px}
.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}
/* ── service list ── */
.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
.svc-row:last-child{border-bottom:none}
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.svc-name{font-size:13px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.svc-bar-w{width:80px;height:2px;background:var(--surface-3);border-radius:2px;flex-shrink:0}
.svc-bar{height:100%;border-radius:2px;transition:width .8s cubic-bezier(.16,1,.3,1)}
.svc-cost{font-family:var(--mono);font-size:12px;color:var(--muted);width:60px;text-align:right;flex-shrink:0}
.svc-chevron{font-size:14px!important;transition:transform 0.15s;color:var(--muted);flex-shrink:0}
.svc-row.expanded .svc-chevron{transform:rotate(90deg)}
.svc-skus{display:none;padding:4px 0 4px 28px;border-left:2px solid var(--surface-3);margin:0 0 6px 18px}
.svc-skus.open{display:block}
.sku-row{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);padding:3px 0}
/* ── gauge ── */
.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}
.gauge-meta{font-size:12px;color:var(--muted);text-align:center}
/* ── anomaly rows ── */
.anom-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)}
.anom-row:last-child{border-bottom:none}
.anom-badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;flex-shrink:0}
.anom-badge.high{background:var(--red-bg);color:var(--red)}
.anom-badge.med{background:var(--yellow-bg);color:var(--yellow)}
/* ── misc ── */
.err-banner{background:var(--red-bg);border:1px solid rgba(255,92,124,.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}
.page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
.page-title{font-size:22px;font-weight:600;letter-spacing:-.02em}
.page-title strong{font-weight:700;background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.blink{animation:blink 1.2s ease-in-out infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:center}
/* ── responsive ── */
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){
.topbar{height:auto;flex-wrap:wrap;padding:8px 12px;gap:8px}
.tb-project{display:none}
.tb-right{width:100%;margin-left:0;flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;gap:6px}
.tb-right>*{flex-shrink:0}
.layout{flex-direction:column}
.sidebar{width:100%;display:flex;overflow-x:auto;border-right:none;border-bottom:1px solid var(--border);padding:8px;gap:4px}
.nav-section{display:none}
.nav-item{flex-shrink:0;border-radius:99px;border-left:none;margin-right:0;padding:8px 14px;white-space:nowrap}
.nav-item.active{border-left:none;border:1px solid var(--blue)}
.content{padding:16px 12px}
.kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.credits-kpi-row{grid-template-columns:1fr 1fr;gap:8px}
.page-title{font-size:18px}
.freetrial-banner{top:auto;position:relative}
}
@media(max-width:420px){.kpi-row{grid-template-columns:1fr}.credits-kpi-row{grid-template-columns:1fr}}
@media print{
.topbar,.sidebar,#budget-modal{display:none!important}
.module{border:1px solid #ddd}
.kpi-row,.charts-row,.bottom-row,.credits-kpi-row{grid-template-columns:1fr!important}
}
</style>
</head>
<body>
<!-- Free Trial kritisk-banner (vises øverst hvis < 30 dager) -->
<div class="freetrial-banner" id="freetrial-banner">
<span class="material-symbols-outlined">warning</span>
<span id="freetrial-msg"></span>
</div>
<header class="topbar">
<div class="tb-logo">
<div class="tb-logo-icon">
<svg viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<span class="tb-logo-name">Cost<span>Guard</span></span>
</div>
<div class="tb-project">
<span class="material-symbols-outlined">folder_open</span>
propane-will-491900-m5
</div>
<div class="tb-right">
<span class="pulse" id="live-dot"></span>
<span id="top-badge" class="tb-badge ok">
<span class="material-symbols-outlined" style="font-size:13px">check_circle</span>
Ingen anomalier
</span>
<select class="tb-period" onchange="updatePeriod(+this.value)">
<option value="7">7 dager</option>
<option value="30" selected>30 dager</option>
<option value="90">90 dager</option>
</select>
<button class="sel" onclick="fetchAll()" title="Oppdater">
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
</button>
<div class="dropdown">
<button class="sel">
<span class="material-symbols-outlined" style="font-size:16px">download</span>
<span>Eksport</span>
</button>
<div class="dropdown-content">
<a href="#" onclick="exportCSV()">Eksporter som CSV</a>
<a href="#" onclick="window.print()">Eksporter til PDF</a>
</div>
</div>
<div id="user-profile" style="display:none;align-items:center;gap:8px">
<img id="user-avatar" src="" alt="" style="width:28px;height:28px;border-radius:50%">
<div style="display:flex;flex-direction:column;line-height:1.2">
<span id="user-name" style="font-size:12px;font-weight:600"></span>
<a href="/auth/logout" style="font-size:10px;color:var(--muted);text-decoration:none">Logg ut</a>
</div>
</div>
</div>
</header>
<div class="layout">
<nav class="sidebar">
<div class="nav-section">Oversikt</div>
<div class="nav-item active"><span class="material-symbols-outlined">bar_chart</span>Billing</div>
<div class="nav-item"><span class="material-symbols-outlined">notifications</span>Varsler</div>
<div class="nav-item"><span class="material-symbols-outlined">trending_up</span>Prognoser</div>
<div class="nav-section" style="margin-top:12px">Innstillinger</div>
<div class="nav-item" onclick="openBudgetModal()"><span class="material-symbols-outlined">account_balance_wallet</span>Budsjett</div>
<div class="nav-item"><span class="material-symbols-outlined">settings</span>Konfigurasjon</div>
</nav>
<main class="content">
<div class="err-banner" id="err-banner">
<span class="material-symbols-outlined" style="font-size:14px">warning</span>
<span id="err-msg">Kan ikke nå API.</span>
</div>
<div class="page-hdr">
<h1 class="page-title">Billing <strong>Oversikt</strong></h1>
<span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
</div>
<div class="module-grid" id="module-grid">
<!-- KPI-rad -->
<div class="module" id="mod-kpis" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-kpis')" ondragover="dragOver(event)" ondrop="drop(event,'mod-kpis')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">Nøkkeltall</span>
<span class="module-sub" id="period-lbl">30 dager</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-kpis')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div class="kpi-row">
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:14px">
<div class="kpi-top">
<span class="kpi-lbl">Totalkostnad MTD</span>
<div class="kpi-icon ki-blue"><span class="material-symbols-outlined">payments</span></div>
</div>
<div class="kpi-val" id="k-mtd"></div>
<div class="kpi-delta">hittil denne måneden</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:14px">
<div class="kpi-top">
<span class="kpi-lbl">Daglig snitt (7d)</span>
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">today</span></div>
</div>
<div class="kpi-val" id="k-daily"></div>
<div class="kpi-delta">snitt siste 7 dager</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:14px">
<div class="kpi-top">
<span class="kpi-lbl">Prognose måneden</span>
<div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">trending_up</span></div>
</div>
<div class="kpi-val" id="k-fc"></div>
<div class="kpi-delta"><span id="k-fc-d">— dager gjenstår</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ CG4-CREDITS: Kreditt-panel ═══ -->
<div class="module" id="mod-credits" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-credits')" ondragover="dragOver(event)" ondrop="drop(event,'mod-credits')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">💳 GCP Kreditter</span>
<span class="module-sub" id="credits-sub">Laster...</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="fetchCredits()" title="Oppdater kreditter">
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
</button>
<button class="module-btn" onclick="toggleModule('mod-credits')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<!-- 4 KPI-kort -->
<div class="credits-kpi-row" style="margin-bottom:16px">
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Totalt brukt</span>
<div class="kpi-icon ki-blue"><span class="material-symbols-outlined">credit_card</span></div>
</div>
<div class="kpi-val" id="cr-used"></div>
<div class="kpi-delta" id="cr-used-pct">siste 90 dager</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Infra gjenstår</span>
<div class="kpi-icon ki-red"><span class="material-symbols-outlined">timer</span></div>
</div>
<div class="kpi-val" id="cr-infra-remaining"></div>
<div class="kpi-delta" id="cr-infra-days">Free Trial</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Daglig burn</span>
<div class="kpi-icon ki-yellow"><span class="material-symbols-outlined">local_fire_department</span></div>
</div>
<div class="kpi-val" id="cr-burn"></div>
<div class="kpi-delta">NOK/dag (snitt 7d)</div>
</div>
</div>
<div class="module" style="border:none;box-shadow:none">
<div class="module-body" style="padding:12px">
<div class="kpi-top">
<span class="kpi-lbl">Vertex gjenstår</span>
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">bolt</span></div>
</div>
<div class="kpi-val" id="cr-vertex-remaining"></div>
<div class="kpi-delta">Gen App Builder</div>
</div>
</div>
</div>
<!-- Tre separate kreditt-pools -->
<div class="credit-pools" id="cr-pools"></div>
<!-- Advarsel-banner -->
<div class="credits-warning" id="cr-warning">
<span class="material-symbols-outlined" style="font-size:16px">warning</span>
<span id="cr-warning-msg"></span>
</div>
</div>
</div>
<!-- ═══ /CG4-CREDITS ═══ -->
<!-- Charts-rad -->
<div class="module" id="mod-charts" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-charts')" ondragover="dragOver(event)" ondrop="drop(event,'mod-charts')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">Kostnadsutvikling</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-charts')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div class="charts-row">
<div>
<div style="font-size:13px;font-weight:500;margin-bottom:12px">Daglig kostnad <span style="font-size:11px;color:var(--muted)">netto etter kreditter</span></div>
<div class="chart-wrap">
<div class="empty-state" id="bar-empty"><span class="material-symbols-outlined">bar_chart</span>Ingen daglig data</div>
<canvas id="bar-chart" style="display:none"></canvas>
</div>
</div>
<div>
<div style="font-size:13px;font-weight:500;margin-bottom:12px">Fordeling per tjeneste <span style="font-size:11px;color:var(--muted)">andel av total</span></div>
<div class="chart-wrap">
<div class="empty-state" id="donut-empty"><span class="material-symbols-outlined">donut_large</span>Ingen tjenestedata</div>
<canvas id="donut-chart" style="display:none"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Tjenester + Budsjett -->
<div class="module" id="mod-services" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-services')" ondragover="dragOver(event)" ondrop="drop(event,'mod-services')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">Tjenester &amp; Budsjett</span>
<span class="module-sub">Klikk for SKU-detaljer</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-services')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div class="bottom-row">
<div id="services-body"><div class="empty-state"><span class="material-symbols-outlined">list</span>Laster...</div></div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span style="font-size:13px;font-weight:500">Budsjett-status</span>
<span style="display:flex;align-items:center;gap:6px">
<span style="font-size:12px;color:var(--muted)" id="budget-month"></span>
<button class="module-btn" onclick="openBudgetModal()" title="Endre budsjett">
<span class="material-symbols-outlined">edit</span>
</button>
</span>
</div>
<div class="gauge-wrap">
<div class="gauge-ring">
<svg width="130" height="72" viewBox="0 0 130 72">
<path d="M15,65 A50,50 0 0,1 115,65" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M15,65 A50,50 0 0,1 115,65" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
</svg>
<span class="gauge-num" id="gauge-pct">0%</span>
</div>
<div>
<div style="font-family:var(--mono);font-size:13px;font-weight:600;text-align:center" id="gauge-used">kr 0,00</div>
<div class="gauge-meta" id="gauge-budget-total">av kr 500,00 budsjett</div>
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenstår</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Anomalier -->
<div class="module" id="mod-anomalies" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-anomalies')" ondragover="dragOver(event)" ondrop="drop(event,'mod-anomalies')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">⚡ Anomalier</span>
<span class="module-sub">Kostnadsavvik i dag vs 7d snitt</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-anomalies')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div id="anomalies-body">
<div class="empty-state"><span class="material-symbols-outlined">warning</span>Laster...</div>
</div>
</div>
</div>
<!-- Anbefalinger -->
<div class="module" id="mod-recommendations" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-recommendations')" ondragover="dragOver(event)" ondrop="drop(event,'mod-recommendations')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">💡 Anbefalinger</span>
<span class="module-sub" id="rec-savings"></span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-recommendations')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div id="recommendations-body">
<div class="empty-state"><span class="material-symbols-outlined">tips_and_updates</span>Laster anbefalinger...</div>
</div>
</div>
</div>
<!-- Tokenbruk -->
<div class="module" id="mod-tokens" draggable="true">
<div class="module-handle" ondragstart="dragStart(event,'mod-tokens')" ondragover="dragOver(event)" ondrop="drop(event,'mod-tokens')">
<div class="module-handle-left">
<span class="material-symbols-outlined drag-grip">drag_indicator</span>
<span class="module-title">⚡ Tokenbruk per agent</span>
<span class="module-sub">Siste 30 dager</span>
</div>
<div class="module-actions">
<button class="module-btn" onclick="toggleModule('mod-tokens')" title="Minimer">
<span class="material-symbols-outlined collapse-icon">expand_less</span>
</button>
</div>
</div>
<div class="module-body">
<div id="token-usage-body">
<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Laster tokendata...</div>
</div>
</div>
</div>
</div><!-- /module-grid -->
</main>
</div>
<footer>
<span id="footer-txt">Henter data...</span>
&nbsp;&middot;&nbsp;
<span style="color:var(--faint)">CostGuard · Vauco · nettokostnad etter kreditter</span>
</footer>
<!-- Budget modal -->
<div id="budget-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:300;align-items:center;justify-content:center;backdrop-filter:blur(4px)">
<div style="background:var(--surface-2);padding:24px;border-radius:var(--r);border:1px solid var(--border);width:320px;display:flex;flex-direction:column;gap:16px">
<h3 style="font-size:16px;font-weight:600">Sett månedsbudsjett</h3>
<div>
<label for="budget-input" style="font-size:11px;color:var(--muted);margin-bottom:4px;display:block">Budsjett (NOK)</label>
<input id="budget-input" type="number" style="width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-family:var(--mono);font-size:14px">
</div>
<div style="display:flex;justify-content:flex-end;gap:10px">
<button onclick="closeBudgetModal()" style="background:var(--surface-3);border:1px solid var(--border);color:var(--text);padding:6px 14px;border-radius:6px;cursor:pointer">Avbryt</button>
<button id="budget-save-btn" onclick="saveBudget()" style="background:var(--primary);border:none;color:var(--bg);font-weight:600;padding:7px 16px;border-radius:6px;cursor:pointer">Lagre</button>
</div>
</div>
</div>
<script>
const API_BASE = '';
const REFRESH_MS = 5 * 60 * 1000;
const LS_BUDGET = 'cg_budget';
const LS_COLLAPSED = 'cg_collapsed';
const LS_ORDER = 'cg_order';
let currentBudget = parseFloat(localStorage.getItem(LS_BUDGET) || '500');
let barChart, donutChart, period = 30;
let currentData = null;
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}];
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:21};
const DEMO_ANOMALIES = [];
const SVC_COLORS = ['#7c5cff','#00d4a8','#ffb454','#ff5c7c','#a78bfa','#5eead4','#c4b5fd'];
const POOL_COLORS = ['#ff5c7c','#7c5cff','#00d4a8'];
function fmtNOK(v,d=2){return 'kr '+Math.abs(Number(v)||0).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*Math.abs(end));if(p<1)requestAnimationFrame(go);};requestAnimationFrame(go);}
function serviceColor(i){return SVC_COLORS[i%SVC_COLORS.length];}
async function apiFetch(url,opts={}){
try{
const r=await fetch(url,opts);
if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});}
return r;
} catch(e) {
console.warn('apiFetch error:',url,e.message);
throw e;
}
}
// ── auth ──────────────────────────────────────────────────────────────────
async function checkAuth(){
try{
const r=await apiFetch(API_BASE+'/auth/me');
if(!r.ok) throw new Error('not logged in');
const u=await r.json();
document.getElementById('user-name').textContent=u.name;
document.getElementById('user-avatar').src=u.picture;
document.getElementById('user-profile').style.display='flex';
}catch(e){console.warn('auth:',e.message);}
restoreLayout();
await fetchAll();
setInterval(fetchAll,REFRESH_MS);
}
// ── budget ────────────────────────────────────────────────────────────────
function openBudgetModal(){document.getElementById('budget-input').value=currentBudget;document.getElementById('budget-modal').style.display='flex';}
function closeBudgetModal(){document.getElementById('budget-modal').style.display='none';}
function saveBudget(){
const v=parseFloat(document.getElementById('budget-input').value);
if(isNaN(v)||v<=0){alert('Oppgi et gyldig beløp.');return;}
currentBudget=v;
localStorage.setItem(LS_BUDGET,v);
closeBudgetModal();
if(currentData)buildGauge(currentData.forecast.month_to_date_cost||0);
}
// ── drag & drop ───────────────────────────────────────────────────────────
let dragSrc=null;
function dragStart(e,id){dragSrc=id;e.dataTransfer.effectAllowed='move';document.getElementById(id).classList.add('dragging');}
document.addEventListener('dragend',()=>{document.querySelectorAll('.module').forEach(m=>{m.classList.remove('dragging','drag-over');});saveOrder();});
function dragOver(e){e.preventDefault();e.dataTransfer.dropEffect='move';const m=e.currentTarget.closest('.module');if(m&&m.id!==dragSrc)m.classList.add('drag-over');}
function drop(e,targetId){
e.preventDefault();
if(!dragSrc||dragSrc===targetId)return;
const grid=document.getElementById('module-grid');
const src=document.getElementById(dragSrc);
const tgt=document.getElementById(targetId);
if(!src||!tgt)return;
const children=[...grid.children];
const si=children.indexOf(src),ti=children.indexOf(tgt);
if(si<ti)grid.insertBefore(src,tgt.nextSibling);
else grid.insertBefore(src,tgt);
document.querySelectorAll('.module').forEach(m=>m.classList.remove('drag-over'));
saveOrder();
}
function saveOrder(){
const ids=[...document.getElementById('module-grid').children].map(m=>m.id);
localStorage.setItem(LS_ORDER,JSON.stringify(ids));
}
function restoreLayout(){
const collapsed=JSON.parse(localStorage.getItem(LS_COLLAPSED)||'[]');
collapsed.forEach(id=>{const m=document.getElementById(id);if(m)m.classList.add('collapsed');});
const order=JSON.parse(localStorage.getItem(LS_ORDER)||'[]');
if(!order.length)return;
const grid=document.getElementById('module-grid');
order.forEach(id=>{const m=document.getElementById(id);if(m)grid.appendChild(m);});
}
function toggleModule(id){
const m=document.getElementById(id);
if(!m)return;
m.classList.toggle('collapsed');
const collapsed=[...document.querySelectorAll('.module.collapsed')].map(x=>x.id);
localStorage.setItem(LS_COLLAPSED,JSON.stringify(collapsed));
}
// ── fetch (med retry-fallback) ────────────────────────────────────────────
async function fetchAll(){
const dot=document.getElementById('live-dot');
dot.style.background='var(--warn)';
const results = await Promise.allSettled([
apiFetch(API_BASE+'/billing/summary'),
apiFetch(API_BASE+'/billing/live'),
apiFetch(API_BASE+'/billing/anomalies'),
apiFetch(API_BASE+'/billing/history'),
apiFetch(API_BASE+'/billing/by-service'),
]);
const [sr,fr,ar,hr,svcr] = results;
const coreOk = sr.status==='fulfilled'&&sr.value.ok && fr.status==='fulfilled'&&fr.value.ok;
if(!coreOk){
console.warn('Core endpoints feilet, bruker demo-data');
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES,history:[],serviceGroups:null};
document.getElementById('err-msg').textContent='API utilgjengelig — viser demo-data';
document.getElementById('err-banner').classList.add('show');
dot.style.background='var(--red)';
} else {
const [s,f] = await Promise.all([sr.value.json(),fr.value.json()]);
const a = ar.status==='fulfilled'&&ar.value.ok ? await ar.value.json() : {anomalies:[]};
const h = hr.status==='fulfilled'&&hr.value.ok ? await hr.value.json() : {history:[]};
const svc = svcr.status==='fulfilled'&&svcr.value.ok ? await svcr.value.json() : [];
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[],history:h.history||[],serviceGroups:svc};
document.getElementById('err-banner').classList.remove('show');
dot.style.background='var(--ok)';
}
renderAll();
fetchTokens();
fetchRecommendations();
fetchCredits();
}
// ── CG4-credits: fetchCredits ─────────────────────────────────────────────
async function fetchCredits(){
document.getElementById('credits-sub').textContent='Oppdaterer...';
try{
const r = await apiFetch(API_BASE+'/billing/credits?days=90');
if(!r.ok) throw new Error('HTTP '+r.status);
const d = await r.json();
renderCredits(d);
} catch(e){
document.getElementById('credits-sub').textContent='Ikke tilgjengelig';
document.getElementById('cr-used').textContent='—';
document.getElementById('cr-infra-remaining').textContent='—';
document.getElementById('cr-vertex-remaining').textContent='—';
document.getElementById('cr-burn').textContent='—';
console.warn('[credits]',e.message);
}
}
function renderCredits(d){
const pools = d.credit_pools ?? [];
const usedTotal = d.credits_used_total_nok ?? 0;
const dailyBurn = d.daily_credit_burn_nok ?? 0;
const topWarning = d.warning ?? '';
// Subtitle
const anyRunway = pools.find(p=>p.runway_days!=null);
document.getElementById('credits-sub').textContent =
anyRunway ? `Infra runway: ${anyRunway.runway_days} dager` : 'Kreditter lastet';
// KPI: totalt brukt
animN(document.getElementById('cr-used'), usedTotal, v => fmtNOK(v));
// KPI: Infra gjenstår (Free Trial)
const infraPool = pools.find(p=>p.env_key==='GOOGLE_CREDIT_INFRA_NOK');
if(infraPool && infraPool.remaining_nok!=null){
animN(document.getElementById('cr-infra-remaining'), infraPool.remaining_nok, v=>fmtNOK(v));
document.getElementById('cr-infra-days').textContent =
infraPool.expires_days!=null ? `⚠️ Utløper om ${infraPool.expires_days}d` : 'Free Trial';
} else {
document.getElementById('cr-infra-remaining').textContent='—';
}
// KPI: daglig burn
animN(document.getElementById('cr-burn'), dailyBurn, v=>fmtNOK(v,4));
// KPI: Vertex gjenstår
const vertexPool = pools.find(p=>p.env_key==='GOOGLE_CREDIT_VERTEX_NOK');
if(vertexPool && vertexPool.remaining_nok!=null){
animN(document.getElementById('cr-vertex-remaining'), vertexPool.remaining_nok, v=>fmtNOK(v));
} else {
document.getElementById('cr-vertex-remaining').textContent='—';
}
// Tre pool-bars
const poolsEl = document.getElementById('cr-pools');
if(pools.length){
poolsEl.innerHTML = pools.map((pool, i) => {
const color = POOL_COLORS[i] || '#7c5cff';
const pct = (pool.total_nok && pool.used_nok)
? Math.min(pool.used_nok / pool.total_nok * 100, 100).toFixed(1)
: 0;
const barColor = pool.expires_days!=null&&pool.expires_days<=30
? 'var(--red)'
: pct>80 ? 'var(--red)' : pct>60 ? 'var(--yellow)' : color;
let badgeClass='ok', badgeTxt='OK';
if(!pool.configured){badgeClass='inactive';badgeTxt='Ikke satt';}
else if(pool.expires_days!=null&&pool.expires_days<=30){badgeClass='crit';badgeTxt=`${pool.expires_days}d`;}
else if(pool.runway_days!=null&&pool.runway_days<30){badgeClass='crit';badgeTxt=`${pool.runway_days}d`;}
else if(pool.runway_days!=null&&pool.runway_days<60){badgeClass='warn';badgeTxt=`${pool.runway_days}d`;}
const metaTxt = pool.total_nok
? `${fmtNOK(pool.used_nok)} brukt av ${fmtNOK(pool.total_nok)}${fmtNOK(pool.remaining_nok??0)} gjenstår`
: 'Ikke konfigurert';
const warnHtml = pool.warning
? `<div class="pool-warning show ${pool.expires_days!=null&&pool.expires_days<=30?'crit':'warn'}">
<span class="material-symbols-outlined" style="font-size:13px">warning</span>
${pool.warning}
</div>`
: '';
return `<div class="pool-item">
<div class="pool-header">
<span class="pool-label">
<span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;display:inline-block"></span>
${pool.label}
<span class="pool-badge ${badgeClass}">${badgeTxt}</span>
</span>
<span class="pool-meta">${metaTxt}</span>
</div>
<div class="pool-bar-bg">
<div class="pool-bar-fill" style="width:${pct}%;background:${barColor}" data-pct="${pct}"></div>
</div>
${warnHtml}
</div>`;
}).join('');
// Animate bars
setTimeout(()=>{
poolsEl.querySelectorAll('.pool-bar-fill').forEach(el=>{
const target = el.dataset.pct;
el.style.width = '0%';
requestAnimationFrame(()=>{ el.style.width = target+'%'; });
});
}, 80);
} else {
poolsEl.innerHTML = '<div class="empty-state" style="padding:12px 0"><span class="material-symbols-outlined">credit_card</span>Ingen kredittdata</div>';
}
// Global advarsel-banner
const warn = document.getElementById('cr-warning');
const warnMsg = document.getElementById('cr-warning-msg');
if(topWarning){
warnMsg.textContent = topWarning;
warn.className = 'credits-warning show crit';
} else {
warn.className = 'credits-warning';
}
// Free Trial topbar-banner
const ftBanner = document.getElementById('freetrial-banner');
const ftMsg = document.getElementById('freetrial-msg');
if(infraPool && infraPool.expires_days!=null && infraPool.expires_days<=30){
ftMsg.textContent = `⚠️ Free Trial utløper om ${infraPool.expires_days} dager (${fmtNOK(infraPool.remaining_nok??0)} gjenstår) — aktiver fakturering for å unngå driftsstopp!`;
ftBanner.classList.add('show');
} else {
ftBanner.classList.remove('show');
}
}
// ── render ────────────────────────────────────────────────────────────────
function renderAll(){
if(!currentData)return;
const{summary,forecast,anomalies,history,serviceGroups}=currentData;
buildBar(history);
buildDonut(summary);
serviceGroups&&serviceGroups.length?renderServiceRows(serviceGroups):buildSvc(summary);
buildAnom(anomalies);
if(forecast)updateKPIs(forecast);
const ts=new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
document.getElementById('footer-txt').textContent='Sist oppdatert '+ts;
document.getElementById('last-updated').textContent='Sist oppdatert '+ts;
const mn=new Date().toLocaleString('no-NO',{month:'long'});
document.getElementById('budget-month').textContent=mn.charAt(0).toUpperCase()+mn.slice(1)+' '+new Date().getFullYear();
}
function buildBar(history){
const ctx=document.getElementById('bar-chart').getContext('2d');
if(barChart)barChart.destroy();
if(!history||!history.length){
document.getElementById('bar-empty').style.display='flex';
document.getElementById('bar-chart').style.display='none';
return;
}
document.getElementById('bar-empty').style.display='none';
document.getElementById('bar-chart').style.display='block';
const labels=[],costs=[];
history.forEach((e,i)=>{
const d=new Date(e.date);
labels.push(d.toLocaleDateString('no-NO',{day:'numeric',month:'short'}));
const prev=i>0&&d.getDate()!==1?(history[i-1].mtd||0):0;
costs.push(Math.max(0,(e.mtd||0)-prev));
});
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[{label:'Kostnad',data:costs,backgroundColor:'rgba(124,92,255,.55)',borderRadius:4,borderSkipped:false}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>' '+fmtNOK(c.raw)}}},scales:{x:{grid:{display:false},ticks:{color:'#3f3d3a',font:{size:10},maxTicksLimit:15}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(2)},border:{display:false}}}}});
}
function buildDonut(summary){
const hasData=summary.some(s=>(s.daily_cost??s.total_cost??0)>0);
if(!hasData){document.getElementById('donut-empty').style.display='flex';document.getElementById('donut-chart').style.display='none';if(donutChart){donutChart.destroy();donutChart=null;}return;}
document.getElementById('donut-empty').style.display='none';document.getElementById('donut-chart').style.display='block';
const ctx=document.getElementById('donut-chart').getContext('2d');
if(donutChart)donutChart.destroy();
const top=summary.filter(s=>(s.daily_cost??s.total_cost??0)>0).slice(0,7);
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.daily_cost??s.total_cost??0),backgroundColor:SVC_COLORS,borderColor:'#13131c',borderWidth:2}]},options:{responsive:true,maintainAspectRatio:false,cutout:'70%',plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
}
function renderServiceRows(data){
const el=document.getElementById('services-body');
if(!data||!data.length){el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';return;}
const max=Math.max(...data.map(s=>s.total_cost||0),1e-6);
el.innerHTML=data.slice(0,8).map((svc,i)=>{
const hasSkus=svc.skus&&svc.skus.length>1;
const color=serviceColor(i);
const pct=((svc.total_cost||0)/max*100).toFixed(1);
const skuRows=(svc.skus||[]).map(s=>`<div class="sku-row"><span>${s.sku}</span><span>${fmtNOK(s.sku_cost)}</span></div>`).join('');
return `<div class="svc-row" data-idx="${i}" ${hasSkus?`onclick="toggleSkus(${i})"`:''}>
<div class="svc-dot" style="background:${color}"></div>
<span class="svc-name">${svc.service}</span>
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${color}"></div></div>
<span class="svc-cost">${fmtNOK(svc.total_cost)}</span>
${hasSkus?'<span class="material-symbols-outlined svc-chevron">chevron_right</span>':''}
</div>${hasSkus?`<div class="svc-skus" id="skus-${i}">${skuRows}</div>`:''}`;}).join('');
}
function toggleSkus(i){const row=document.querySelector(`.svc-row[data-idx="${i}"]`);const skus=document.getElementById(`skus-${i}`);if(!row||!skus)return;row.classList.toggle('expanded');skus.classList.toggle('open');}
function buildSvc(summary){
const el=document.getElementById('services-body');
const top=[...summary].sort((a,b)=>(b.daily_cost??b.total_cost??0)-(a.daily_cost??a.total_cost??0)).slice(0,8);
if(!top.length){el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>';return;}
const max=Math.max(...top.map(s=>Math.abs(s.daily_cost??s.total_cost??0)),1e-6);
el.innerHTML=top.map((s,i)=>{const c=s.daily_cost??s.total_cost??0;const pct=(Math.abs(c)/max*100).toFixed(1);return `<div class="svc-row"><div class="svc-dot" style="background:${serviceColor(i)}"></div><span class="svc-name">${s.service}</span><div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${serviceColor(i)}"></div></div><span class="svc-cost">${fmtNOK(c)}</span></div>`;}).join('');
}
function buildAnom(anomalies){
const tb=document.getElementById('top-badge');
const body=document.getElementById('anomalies-body');
if(!anomalies||!anomalies.length){
tb.className='tb-badge ok';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen avvik oppdaget i dag</div>';
return;
}
const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
if(maxRatio<2.5){tb.className='tb-badge warn';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">error</span> Økt bruk';}
else{tb.className='tb-badge err';tb.innerHTML='<span class="material-symbols-outlined blink" style="font-size:13px">warning</span> Kraftig avvik';}
body.innerHTML=anomalies.map(a=>{
const severity=a.ratio>4?'high':'med';
return `<div class="anom-row">
<span class="anom-badge ${severity}">×${(a.ratio||0).toFixed(1)}</span>
<span class="svc-name">${a.service}</span>
<span style="font-family:var(--mono);font-size:12px;color:var(--muted)">${fmtNOK(a.today_cost)} <span style="color:var(--faint)">vs snitt ${fmtNOK(a.avg_7d)}</span></span>
</div>`;}).join('');
}
function buildGauge(mtd){
const pct=Math.min(mtd/currentBudget,1);
const arc=document.getElementById('gauge-arc');
arc.style.stroke=pct>.8?'var(--red)':pct>.6?'var(--yellow)':'var(--blue)';
setTimeout(()=>arc.style.strokeDashoffset=157*(1-pct),80);
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
document.getElementById('gauge-used').textContent=fmtNOK(mtd);
document.getElementById('gauge-budget-total').textContent=`av ${fmtNOK(currentBudget,0)} budsjett`;
const r=currentBudget-mtd;
const rel=document.getElementById('gauge-remain');
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
rel.style.color=r<0?'var(--red)':'var(--muted)';
}
function updateKPIs(forecast){
const mtd=forecast.month_to_date_cost||0;
const daily=forecast.daily_average_last_7_days;
const fc=forecast.total_monthly_forecast||0;
const left=forecast.remaining_days_in_month??(31-new Date().getDate());
animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v));
if(daily>0)animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v,4));
else document.getElementById('k-daily').textContent='kr —';
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
buildGauge(mtd);
}
function updatePeriod(n){period=n;if(currentData)buildBar(currentData.history);}
function exportCSV(){
if(!currentData||!currentData.summary)return;
const rows=currentData.summary.map(s=>`"${s.service}",${(s.daily_cost??s.total_cost??0).toFixed(2)}`);
const csv='data:text/csv;charset=utf-8,Tjeneste,Kostnad (NOK)\n'+rows.join('\n');
const a=document.createElement('a');a.href=encodeURI(csv);a.download=`costguard_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);a.click();document.body.removeChild(a);
}
// ── anbefalinger ──────────────────────────────────────────────────────────
function fetchRecommendations(){
const ICONS={high:'🔴',medium:'🟡',low:'🟢',anomaly:'⚡',waste:'♻️',budget:'💸'};
fetch(API_BASE+'/billing/recommendations')
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(data=>{
const body=document.getElementById('recommendations-body');
const sub=document.getElementById('rec-savings');
if(!data.recommendations||!data.recommendations.length){
body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger — forbruket ser normalt ut</div>';
sub.textContent='Alt OK';
return;
}
sub.textContent=`Estimert besparelse: ${(data.total_estimated_savings_nok||0).toLocaleString('no-NO',{minimumFractionDigits:2})} kr/mnd`;
body.innerHTML=data.recommendations.map(r=>`
<div class="svc-row">
<span style="font-size:16px">${ICONS[r.severity]||'💡'}</span>
<span class="svc-name">${r.message}</span>
<span style="font-size:11px;color:var(--muted);background:var(--surface-3);padding:2px 8px;border-radius:99px;flex-shrink:0">${r.service}</span>
${r.estimated_savings_nok>0?`<span class="svc-cost">-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr</span>`:''}
</div>`).join('');
})
.catch(()=>{document.getElementById('recommendations-body').innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger tilgjengelig</div>';});
}
// ── tokenbruk ─────────────────────────────────────────────────────────────
function fetchTokens(){
fetch(API_BASE+'/billing/tokens/summary')
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(data=>{
const body=document.getElementById('token-usage-body');
if(!data.rows||!data.rows.length){body.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Ingen tokendata ennå</div>';return;}
body.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="color:var(--muted);font-size:11px;text-align:left">
<th style="padding:6px 0">Agent</th><th>Modell</th>
<th style="text-align:right">Tokens</th><th style="text-align:right">Kost (USD)</th>
</tr></thead>
<tbody>${data.rows.map(r=>`<tr style="border-top:1px solid var(--border)">
<td style="padding:7px 0">${r.agent_name}</td>
<td style="color:var(--muted)">${r.model_name}</td>
<td style="text-align:right;font-family:var(--mono)">${r.total_tokens.toLocaleString()}</td>
<td style="text-align:right;font-family:var(--mono)">$${r.total_cost_usd.toFixed(4)}</td>
</tr>`).join('')}</tbody></table>`;
})
.catch(()=>{document.getElementById('token-usage-body').innerHTML='<div class="empty-state"><span class="material-symbols-outlined">bolt</span>Ingen tokendata ennå</div>';});
}
document.addEventListener('DOMContentLoaded',checkAuth);
if('serviceWorker' in navigator){
window.addEventListener('load',()=>navigator.serviceWorker.register('/sw.js').catch(()=>{}));
}
</script>
</body>
</html>