990 lines
54 KiB
HTML
990 lines
54 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="no">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CostGuard · Billing</title>
|
||
<link rel="manifest" href="/manifest.json">
|
||
<meta name="theme-color" content="#0a0a0f">
|
||
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||
<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}
|
||
|
||
/* ── 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)}
|
||
|
||
/* ── credits runway bar ── */
|
||
.runway-bar-wrap{margin-top:14px}
|
||
.runway-label{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:6px}
|
||
.runway-bar-bg{height:8px;background:var(--surface-3);border-radius:4px;overflow:hidden}
|
||
.runway-bar-fill{height:100%;border-radius:4px;transition:width 1.2s cubic-bezier(.16,1,.3,1)}
|
||
.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}
|
||
}
|
||
@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>
|
||
|
||
<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">Kreditter 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">av total</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">Gjenstår</span>
|
||
<div class="kpi-icon ki-green"><span class="material-symbols-outlined">savings</span></div>
|
||
</div>
|
||
<div class="kpi-val" id="cr-remaining">—</div>
|
||
<div class="kpi-delta">estimert saldo</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">USD/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">Runway</span>
|
||
<div class="kpi-icon ki-red" id="cr-runway-icon"><span class="material-symbols-outlined">schedule</span></div>
|
||
</div>
|
||
<div class="kpi-val" id="cr-runway">—</div>
|
||
<div class="kpi-delta" id="cr-exhaustion">dager igjen</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Runway-bar -->
|
||
<div class="runway-bar-wrap">
|
||
<div class="runway-label">
|
||
<span>Kredittforbruk</span>
|
||
<span id="cr-bar-label">$0 / $—</span>
|
||
</div>
|
||
<div class="runway-bar-bg">
|
||
<div class="runway-bar-fill" id="cr-bar-fill" style="width:0%;background:var(--blue)"></div>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- Kreditter per type -->
|
||
<div id="cr-types" style="margin-top:14px"></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 & 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>
|
||
·
|
||
<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'];
|
||
function fmtNOK(v,d=2){return 'kr '+Math.abs(Number(v)||0).toLocaleString('no-NO',{minimumFractionDigits:d,maximumFractionDigits:d});}
|
||
function fmtUSD(v,d=2){return '$'+(Math.abs(Number(v)||0)).toLocaleString('en-US',{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={}){const r=await fetch(url,opts);if(r.status===401){window.location.href='/auth/login';return new Promise(()=>{});}return r;}
|
||
|
||
// ── 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 ─────────────────────────────────────────────────────────────────
|
||
async function fetchAll(){
|
||
const dot=document.getElementById('live-dot');
|
||
dot.style.background='var(--warn)';
|
||
try{
|
||
const [sr,fr,ar,hr,svcr] = await Promise.all([
|
||
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'),
|
||
]);
|
||
if(!sr.ok||!fr.ok) throw new Error('Core endpoints failed');
|
||
const [s,f] = await Promise.all([sr.json(),fr.json()]);
|
||
const a = ar.ok ? await ar.json() : {anomalies:[]};
|
||
const h = hr.ok ? await hr.json() : {history:[]};
|
||
const svc = svcr.ok ? await svcr.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)';
|
||
}catch(err){
|
||
console.warn('API feil, demo-data:',err.message);
|
||
currentData={summary:DEMO_SUMMARY,forecast:DEMO_FORECAST,anomalies:DEMO_ANOMALIES,history:[],serviceGroups:null};
|
||
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message;
|
||
document.getElementById('err-banner').classList.add('show');
|
||
dot.style.background='var(--red)';
|
||
}
|
||
renderAll();
|
||
fetchTokens();
|
||
fetchRecommendations();
|
||
fetchCredits(); // CG4-credits: hentes parallelt med alt annet
|
||
}
|
||
|
||
// ── 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-remaining').textContent='—';
|
||
document.getElementById('cr-burn').textContent='—';
|
||
document.getElementById('cr-runway').textContent='—';
|
||
document.getElementById('cr-exhaustion').textContent=e.message;
|
||
console.warn('[credits]',e.message);
|
||
}
|
||
}
|
||
|
||
function renderCredits(d){
|
||
// --- KPI-tall ---
|
||
const used = d.credits_used_total_usd ?? 0;
|
||
const remaining = d.credits_remaining_usd ?? null;
|
||
const total = d.credit_total_usd ?? null;
|
||
const burn = d.daily_credit_burn_usd ?? 0;
|
||
const runway = d.credit_runway_days ?? null;
|
||
const exhausted = d.credit_exhaustion_date ?? null;
|
||
const pct = total ? Math.min(used/total,1) : null;
|
||
|
||
// subtitle
|
||
document.getElementById('credits-sub').textContent =
|
||
runway != null ? `Runway: ${runway} dager` : 'Sett GOOGLE_CREDIT_TOTAL_USD for runway';
|
||
|
||
// KPI: brukt
|
||
animN(document.getElementById('cr-used'), used, v => fmtUSD(v));
|
||
document.getElementById('cr-used-pct').textContent =
|
||
(pct!=null) ? `${Math.round(pct*100)}% av ${fmtUSD(total,0)}` : 'total ukjent — sett env-var';
|
||
|
||
// KPI: gjenstår
|
||
if(remaining!=null){
|
||
animN(document.getElementById('cr-remaining'), remaining, v => fmtUSD(v));
|
||
} else {
|
||
document.getElementById('cr-remaining').textContent = '—';
|
||
}
|
||
|
||
// KPI: daglig burn
|
||
animN(document.getElementById('cr-burn'), burn, v => fmtUSD(v,4));
|
||
|
||
// KPI: runway
|
||
const runwayEl = document.getElementById('cr-runway');
|
||
const iconEl = document.getElementById('cr-runway-icon');
|
||
const exhaustEl = document.getElementById('cr-exhaustion');
|
||
if(runway!=null){
|
||
animN(runwayEl, runway, v => Math.round(v)+' d');
|
||
exhaustEl.textContent = exhausted ? 'tom ca. '+exhausted : 'dager igjen';
|
||
// Ikon-farge basert på kritikalitet
|
||
iconEl.className = 'kpi-icon ' + (runway<30 ? 'ki-red' : runway<60 ? 'ki-yellow' : 'ki-green');
|
||
} else {
|
||
runwayEl.textContent = '—';
|
||
exhaustEl.textContent = 'sett GOOGLE_CREDIT_TOTAL_USD';
|
||
iconEl.className = 'kpi-icon ki-blue';
|
||
}
|
||
|
||
// --- Runway-bar ---
|
||
const fill = document.getElementById('cr-bar-fill');
|
||
const barLbl= document.getElementById('cr-bar-label');
|
||
if(pct!=null){
|
||
setTimeout(()=>fill.style.width=(pct*100).toFixed(1)+'%',80);
|
||
fill.style.background = pct>0.8 ? 'var(--red)' : pct>0.6 ? 'var(--yellow)' : 'var(--blue)';
|
||
barLbl.textContent = fmtUSD(used)+' / '+fmtUSD(total,0);
|
||
} else {
|
||
fill.style.width='0%';
|
||
barLbl.textContent = fmtUSD(used)+' brukt';
|
||
}
|
||
|
||
// --- Advarsel-banner ---
|
||
const warn = document.getElementById('cr-warning');
|
||
const warnMsg = document.getElementById('cr-warning-msg');
|
||
const rawWarn = d.warning ?? '';
|
||
if(rawWarn){
|
||
warnMsg.textContent = rawWarn;
|
||
warn.className = 'credits-warning show ' + (runway!=null && runway<30 ? 'crit' : 'med');
|
||
} else {
|
||
warn.className = 'credits-warning';
|
||
}
|
||
|
||
// --- Kreditter per type (credits_by_type) ---
|
||
const types = d.credits_by_type ?? [];
|
||
const typesEl = document.getElementById('cr-types');
|
||
if(types.length){
|
||
typesEl.innerHTML =
|
||
'<div style="font-size:11px;font-weight:500;color:var(--muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.06em">Kreditter per type</div>' +
|
||
types.map(t =>
|
||
`<div class="svc-row">
|
||
<div class="svc-dot" style="background:var(--blue)"></div>
|
||
<span class="svc-name">${t.type??t.credit_type??'Ukjent'}</span>
|
||
<span class="svc-cost">${fmtUSD(t.amount??t.total_amount??0)}</span>
|
||
</div>`
|
||
).join('');
|
||
} else {
|
||
typesEl.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
// ── 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>
|