Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
503 lines
21 KiB
HTML
503 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="no">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<title>CostGuard — Dashboard</title>
|
|
<style>
|
|
:root{
|
|
--bg:#0a0a0f;--bg-2:#11131a;--bg-3:#161922;
|
|
--border:rgba(255,255,255,0.08);--border-2:rgba(255,255,255,0.14);
|
|
--text:#e8eaf0;--text-dim:#8b909c;--text-faint:#5a5f6b;
|
|
--accent:#7c5cff;--accent-2:#00d4a8;
|
|
--green:#3ecf8e;--amber:#ffb547;--danger:#ff5c7c;
|
|
--radius:12px;
|
|
--sans:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;
|
|
--mono:'SF Mono',ui-monospace,'JetBrains Mono',Menlo,monospace;
|
|
}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
html,body{min-height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased}
|
|
a{color:inherit;text-decoration:none}
|
|
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
|
|
::-webkit-scrollbar{width:6px}
|
|
::-webkit-scrollbar-thumb{background:var(--bg-3);border-radius:6px}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
|
/* TOPBAR */
|
|
.topbar{
|
|
display:flex;align-items:center;gap:14px;
|
|
padding:14px 28px;
|
|
border-bottom:1px solid var(--border);
|
|
background:rgba(10,10,15,.95);
|
|
backdrop-filter:blur(16px);
|
|
position:sticky;top:0;z-index:10;
|
|
}
|
|
.logo{
|
|
width:32px;height:32px;border-radius:10px;
|
|
background:linear-gradient(135deg,var(--accent),var(--accent-2));
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-weight:900;font-size:13px;color:#050509;flex-shrink:0;
|
|
}
|
|
.brand-name{font-size:16px;font-weight:800;letter-spacing:-0.02em}
|
|
.brand-sub{font-size:11px;color:var(--text-faint);font-family:var(--mono)}
|
|
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
|
.live-pill{
|
|
display:flex;align-items:center;gap:7px;
|
|
padding:5px 12px;border-radius:99px;
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
font-size:11px;color:var(--text-dim);font-weight:500;
|
|
}
|
|
.live-dot{width:7px;height:7px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
|
|
.tier-badge{
|
|
padding:5px 12px;border-radius:99px;
|
|
font-size:11px;font-weight:700;
|
|
background:rgba(124,92,255,.15);color:var(--accent);
|
|
border:1px solid rgba(124,92,255,.25);
|
|
}
|
|
.refresh-btn{
|
|
width:32px;height:32px;border-radius:8px;
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:14px;color:var(--text-dim);transition:color .15s,background .15s;
|
|
}
|
|
.refresh-btn:hover{background:var(--bg-3);color:var(--text)}
|
|
.refresh-btn.spinning svg{animation:spin .7s linear infinite}
|
|
|
|
/* PAGE */
|
|
.page{max-width:1100px;margin:0 auto;padding:32px 28px 56px;display:flex;flex-direction:column;gap:32px}
|
|
|
|
/* SECTION HEADER */
|
|
.sec-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
|
|
.sec-title{font-size:12px;font-weight:700;color:var(--text-faint);text-transform:uppercase;letter-spacing:.07em}
|
|
.sec-ts{font-size:11px;color:var(--text-faint);font-family:var(--mono)}
|
|
|
|
/* STAT CARDS */
|
|
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
|
.stat-card{
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
border-radius:var(--radius);padding:20px 22px;
|
|
}
|
|
.stat-card.hi-accent{border-color:rgba(124,92,255,.3);background:rgba(124,92,255,.06)}
|
|
.stat-card.hi-green {border-color:rgba(62,207,142,.3); background:rgba(62,207,142,.06)}
|
|
.stat-card.hi-amber {border-color:rgba(255,181,71,.3); background:rgba(255,181,71,.06)}
|
|
.stat-card.hi-danger{border-color:rgba(255,92,124,.3); background:rgba(255,92,124,.06)}
|
|
.stat-label{font-size:11px;color:var(--text-faint);text-transform:uppercase;letter-spacing:.06em;font-weight:600}
|
|
.stat-value{font-size:28px;font-weight:800;letter-spacing:-.03em;margin-top:7px}
|
|
.stat-sub{font-size:11px;color:var(--text-dim);margin-top:3px}
|
|
.stat-skeleton .stat-value{height:28px;border-radius:6px;background:var(--bg-3);width:80px;margin-top:7px;animation:pulse .9s infinite}
|
|
|
|
/* TWO COL */
|
|
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
|
@media(max-width:760px){.two-col{grid-template-columns:1fr}}
|
|
|
|
/* CARD BLOCK */
|
|
.card{
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
border-radius:var(--radius);overflow:hidden;
|
|
}
|
|
.card-head{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
|
.card-icon{font-size:16px}
|
|
.card-title{font-size:13px;font-weight:700}
|
|
.card-sub{font-size:11px;color:var(--text-faint);margin-top:1px}
|
|
.card-body{padding:18px}
|
|
|
|
/* ANOMALY LIST */
|
|
.anom-list{display:flex;flex-direction:column;gap:8px}
|
|
.anom{
|
|
display:flex;align-items:center;gap:12px;
|
|
background:var(--bg-3);border:1px solid var(--border);
|
|
border-radius:10px;padding:12px 14px;
|
|
}
|
|
.anom-ico{font-size:17px;flex-shrink:0}
|
|
.anom-info{flex:1;min-width:0}
|
|
.anom-title{font-size:13px;font-weight:600}
|
|
.anom-desc{font-size:11px;color:var(--text-dim);margin-top:2px}
|
|
.anom-sev{font-size:10px;font-weight:700;padding:3px 8px;border-radius:99px;white-space:nowrap}
|
|
.anom-sev.high{background:rgba(255,92,124,.15);color:var(--danger)}
|
|
.anom-sev.medium{background:rgba(255,181,71,.15);color:var(--amber)}
|
|
.anom-sev.low{background:rgba(62,207,142,.15);color:var(--green)}
|
|
.no-anoms{padding:20px;text-align:center;font-size:13px;color:var(--green);font-weight:600}
|
|
|
|
/* MODULE TABLE */
|
|
.mod-table{width:100%;border-collapse:collapse;font-size:13px}
|
|
.mod-table th{
|
|
text-align:left;padding:8px 12px;
|
|
font-size:10px;font-weight:700;color:var(--text-faint);
|
|
text-transform:uppercase;letter-spacing:.06em;
|
|
border-bottom:1px solid var(--border);
|
|
}
|
|
.mod-table td{padding:10px 12px;border-bottom:1px solid var(--border)}
|
|
.mod-table tr:last-child td{border-bottom:none}
|
|
.mod-table tr:hover td{background:rgba(255,255,255,.02)}
|
|
.mod-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;flex-shrink:0}
|
|
.bar-wrap{display:flex;align-items:center;gap:8px}
|
|
.bar-bg{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden}
|
|
.bar-fill{height:100%;border-radius:2px;transition:width .5s}
|
|
|
|
/* DONUT */
|
|
.donut-wrap{display:flex;justify-content:center;padding:16px 0 8px}
|
|
|
|
/* DELIVERY CHANNELS */
|
|
.ch-list{display:flex;flex-direction:column;gap:10px}
|
|
.ch-item{
|
|
display:flex;align-items:center;gap:12px;
|
|
background:var(--bg-3);border:1px solid var(--border);
|
|
border-radius:10px;padding:12px 16px;
|
|
}
|
|
.ch-icon{font-size:20px;flex-shrink:0}
|
|
.ch-info{flex:1;min-width:0}
|
|
.ch-name{font-size:13px;font-weight:600}
|
|
.ch-detail{font-size:11px;color:var(--text-dim);margin-top:2px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.ch-status{font-size:11px;font-weight:700;padding:3px 10px;border-radius:99px;white-space:nowrap}
|
|
.ch-status.active{background:rgba(62,207,142,.15);color:var(--green)}
|
|
.ch-status.inactive{background:var(--bg-2);color:var(--text-faint);border:1px solid var(--border)}
|
|
.ch-test-btn{
|
|
padding:5px 12px;border-radius:7px;
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
font-size:11px;font-weight:600;color:var(--text-dim);
|
|
transition:background .14s,color .14s;
|
|
}
|
|
.ch-test-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
|
|
/* TOAST */
|
|
#toast{
|
|
position:fixed;bottom:28px;right:28px;
|
|
background:var(--bg-2);border:1px solid var(--border-2);
|
|
border-radius:10px;padding:12px 18px;
|
|
font-size:13px;color:var(--text);
|
|
box-shadow:0 8px 32px rgba(0,0,0,.5);
|
|
opacity:0;transform:translateY(8px);
|
|
transition:opacity .2s,transform .2s;
|
|
pointer-events:none;z-index:100;
|
|
}
|
|
#toast.show{opacity:1;transform:translateY(0)}
|
|
|
|
/* EMPTY / SKELETON */
|
|
.loader{display:flex;align-items:center;justify-content:center;padding:40px;color:var(--text-faint);gap:10px;font-size:13px}
|
|
.spinner{width:16px;height:16px;border:2px solid var(--bg-3);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0}
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="topbar">
|
|
<div class="logo">CG</div>
|
|
<div>
|
|
<div class="brand-name">CostGuard</div>
|
|
<div class="brand-sub">klient-dashboard</div>
|
|
</div>
|
|
<div class="topbar-right">
|
|
<div class="live-pill"><div class="live-dot"></div><span id="last-updated">laster...</span></div>
|
|
<div class="tier-badge" id="tier-badge">— tier</div>
|
|
<button class="refresh-btn" id="refresh-btn" onclick="loadAll()" title="Refresh">
|
|
<svg id="refresh-icon" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24">
|
|
<polyline points="23 4 23 10 17 10"/>
|
|
<polyline points="1 20 1 14 7 14"/>
|
|
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<main class="page">
|
|
|
|
<!-- STATS -->
|
|
<section>
|
|
<div class="sec-hd">
|
|
<span class="sec-title">Oversikt</span>
|
|
<span class="sec-ts" id="summary-ts"></span>
|
|
</div>
|
|
<div class="stat-grid" id="stat-grid">
|
|
<div class="stat-card stat-skeleton"><div class="stat-label">Totale tokens (30d)</div><div class="stat-value"></div></div>
|
|
<div class="stat-card stat-skeleton"><div class="stat-label">Estimert kostnad</div><div class="stat-value"></div></div>
|
|
<div class="stat-card stat-skeleton"><div class="stat-label">Budsjettgrense</div><div class="stat-value"></div></div>
|
|
<div class="stat-card stat-skeleton"><div class="stat-label">Anomalier (aktive)</div><div class="stat-value"></div></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ANOMALIES + DONUT -->
|
|
<div class="two-col">
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<span class="card-icon">⚠️</span>
|
|
<div>
|
|
<div class="card-title">Anomalier</div>
|
|
<div class="card-sub">Avvik oppdaget av CostGuard AI</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body" id="anom-body">
|
|
<div class="loader"><div class="spinner"></div> Laster...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<span class="card-icon">🍩</span>
|
|
<div>
|
|
<div class="card-title">Token-fordeling</div>
|
|
<div class="card-sub">Per modul · siste 30 dager</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="donut-wrap">
|
|
<canvas id="donut-canvas" width="220" height="220"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MODULE TABLE -->
|
|
<section>
|
|
<div class="sec-hd">
|
|
<span class="sec-title">Forbruk per modul</span>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body" style="padding:0">
|
|
<div id="mod-table-wrap">
|
|
<div class="loader"><div class="spinner"></div> Laster...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- DELIVERY CHANNELS -->
|
|
<section>
|
|
<div class="sec-hd">
|
|
<span class="sec-title">Varslings-kanaler</span>
|
|
<span class="sec-ts">CG5a · CG5b · CG5c</span>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="ch-list" id="ch-list">
|
|
<div class="loader"><div class="spinner"></div> Laster...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
const COLS = ['#7c5cff','#00d4a8','#ffb547','#ff5c7c','#3ecf8e','#5c8fff','#ff9f7c','#c084fc']
|
|
|
|
const TIER_MAP = [
|
|
{ max:3000, name:'Starter', price:499 },
|
|
{ max:15000, name:'Guard', price:999 },
|
|
{ max:50000, name:'Shield', price:1999 },
|
|
{ max:Infinity,name:'Enterprise', price:3500 },
|
|
]
|
|
|
|
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') }
|
|
function fmtNum(n){ return typeof n==='number'?n.toLocaleString('no'):n }
|
|
function toast(msg,dur=3000){
|
|
const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show')
|
|
setTimeout(()=>t.classList.remove('show'),dur)
|
|
}
|
|
function setSpinning(on){
|
|
document.getElementById('refresh-btn').classList.toggle('spinning',on)
|
|
}
|
|
function tsNow(){
|
|
return new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit',second:'2-digit'})
|
|
}
|
|
|
|
// ── SUMMARY ──────────────────────────────────────────────────────────────────
|
|
function renderStats(sum, anomCount){
|
|
const total = sum?.total_tokens ?? null
|
|
const cost = sum?.estimated_cost_usd ?? null
|
|
const budget = sum?.budget_limit ?? null
|
|
|
|
const gcpSpend = cost != null ? cost * 100 : 0
|
|
const tier = TIER_MAP.find(t => gcpSpend < t.max) || TIER_MAP[TIER_MAP.length-1]
|
|
document.getElementById('tier-badge').textContent = tier.name
|
|
|
|
const anomHi = typeof anomCount==='number' && anomCount>0
|
|
|
|
document.getElementById('stat-grid').innerHTML = `
|
|
<div class="stat-card hi-accent">
|
|
<div class="stat-label">Totale tokens (30d)</div>
|
|
<div class="stat-value">${total!=null?fmtNum(total):'—'}</div>
|
|
<div class="stat-sub">Alle moduler · Vertex AI</div>
|
|
</div>
|
|
<div class="stat-card hi-green">
|
|
<div class="stat-label">Estimert kostnad</div>
|
|
<div class="stat-value">${cost!=null?'$'+Number(cost).toFixed(4):'—'}</div>
|
|
<div class="stat-sub">Gemini 2.5 Pro · ${tier.name} tier</div>
|
|
</div>
|
|
<div class="stat-card ${budget!=null&&cost!=null&&cost/budget>.8?'hi-amber':''}">
|
|
<div class="stat-label">Budsjettgrense</div>
|
|
<div class="stat-value">${budget!=null?'$'+budget:'—'}</div>
|
|
<div class="stat-sub">${budget!=null&&cost!=null?Math.round(cost/budget*100)+'% brukt':''}</div>
|
|
</div>
|
|
<div class="stat-card ${anomHi?'hi-danger':'hi-green'}">
|
|
<div class="stat-label">Anomalier (aktive)</div>
|
|
<div class="stat-value">${typeof anomCount==='number'?anomCount:'—'}</div>
|
|
<div class="stat-sub">${anomHi?'Krever oppmerksomhet':'Alt ser normalt ut'}</div>
|
|
</div>
|
|
`
|
|
document.getElementById('summary-ts').textContent = 'Oppdatert ' + tsNow()
|
|
}
|
|
|
|
// ── ANOMALIES ─────────────────────────────────────────────────────────────────
|
|
function renderAnoms(data){
|
|
const el = document.getElementById('anom-body')
|
|
const anoms = data?.anomalies || []
|
|
if(!anoms.length){
|
|
el.innerHTML = '<div class="no-anoms">✅ Ingen aktive anomalier</div>'
|
|
return
|
|
}
|
|
el.innerHTML = '<div class="anom-list">' + anoms.slice(0,6).map(a => {
|
|
const sev = (a.severity||'medium').toLowerCase()
|
|
const ico = sev==='high'?'🔴':sev==='low'?'🟢':'🟡'
|
|
return `<div class="anom">
|
|
<div class="anom-ico">${ico}</div>
|
|
<div class="anom-info">
|
|
<div class="anom-title">${esc(a.service||a.type||'Ukjent')}</div>
|
|
<div class="anom-desc">${esc(a.description||a.message||'')}</div>
|
|
</div>
|
|
<div class="anom-sev ${sev}">${esc(a.severity||'medium')}</div>
|
|
</div>`
|
|
}).join('') + '</div>'
|
|
}
|
|
|
|
// ── MODULE TABLE + DONUT ──────────────────────────────────────────────────────
|
|
let donutChart = null
|
|
function renderModules(data){
|
|
const mods = data?.modules || data?.data || []
|
|
const wrap = document.getElementById('mod-table-wrap')
|
|
if(!mods.length){
|
|
wrap.innerHTML='<div class="loader" style="color:var(--text-faint)">Ingen moduldata</div>'
|
|
return
|
|
}
|
|
const maxTok = Math.max(...mods.map(m=>m.total_tokens||0),1)
|
|
let rows = mods.map((m,i)=>{
|
|
const pct = Math.round((m.total_tokens||0)/maxTok*100)
|
|
return `<tr>
|
|
<td><span class="mod-dot" style="background:${COLS[i%COLS.length]}"></span>${esc(m.module||m.name||'—')}</td>
|
|
<td>${fmtNum(m.total_tokens||0)}</td>
|
|
<td>${fmtNum(m.input_tokens||0)}</td>
|
|
<td>${fmtNum(m.output_tokens||0)}</td>
|
|
<td style="min-width:100px">
|
|
<div class="bar-wrap">
|
|
<div class="bar-bg"><div class="bar-fill" style="width:${pct}%;background:${COLS[i%COLS.length]}"></div></div>
|
|
<span style="font-size:10px;color:var(--text-faint);font-family:var(--mono);width:32px;text-align:right">${pct}%</span>
|
|
</div>
|
|
</td>
|
|
</tr>`
|
|
}).join('')
|
|
wrap.innerHTML = `<table class="mod-table">
|
|
<thead><tr><th>Modul</th><th>Tokens</th><th>Input</th><th>Output</th><th>Andel</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`
|
|
|
|
const canvas = document.getElementById('donut-canvas')
|
|
if(donutChart) donutChart.destroy()
|
|
donutChart = new Chart(canvas,{
|
|
type:'doughnut',
|
|
data:{
|
|
labels: mods.map(m=>m.module||m.name||'—'),
|
|
datasets:[{
|
|
data: mods.map(m=>m.total_tokens||0),
|
|
backgroundColor: mods.map((_,i)=>COLS[i%COLS.length]),
|
|
borderColor:'#0a0a0f',borderWidth:3,hoverOffset:6
|
|
}]
|
|
},
|
|
options:{
|
|
cutout:'68%',
|
|
plugins:{
|
|
legend:{position:'bottom',labels:{color:'#8b909c',font:{size:11},padding:12,boxWidth:10,boxHeight:10}},
|
|
tooltip:{callbacks:{label:ctx=>` ${ctx.label}: ${ctx.parsed.toLocaleString('no')} tokens`}}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── DELIVERY CHANNELS ─────────────────────────────────────────────────────────
|
|
// Matcher /notify/channels respons: { channels: [{type, configured, target, url}] }
|
|
const CH_META = {
|
|
webhook: { icon:'🔗', name:'Webhook', tier:'Starter+' },
|
|
email: { icon:'📧', name:'E-post', tier:'Starter+' },
|
|
sms: { icon:'📱', name:'SMS', tier:'Guard+' },
|
|
}
|
|
|
|
async function loadChannels(){
|
|
const el = document.getElementById('ch-list')
|
|
let apiChannels = []
|
|
try{
|
|
const r = await fetch('/notify/channels')
|
|
if(r.ok){
|
|
const data = await r.json()
|
|
apiChannels = data.channels || []
|
|
}
|
|
}catch(_){}
|
|
|
|
if(!apiChannels.length){
|
|
el.innerHTML = '<div class="loader" style="color:var(--text-faint)">Kanaler ikke tilgjengelig</div>'
|
|
return
|
|
}
|
|
|
|
el.innerHTML = '<div class="ch-list">' + apiChannels.map(ch => {
|
|
const meta = CH_META[ch.type] || { icon:'📡', name:ch.type, tier:'' }
|
|
const active = ch.configured === true
|
|
const detail = ch.url || ch.target || `POST /notify/${ch.type}`
|
|
return `<div class="ch-item">
|
|
<div class="ch-icon">${meta.icon}</div>
|
|
<div class="ch-info">
|
|
<div class="ch-name">${meta.name} <span style="font-size:10px;color:var(--text-faint);font-family:var(--mono);font-weight:400">${meta.tier}</span></div>
|
|
<div class="ch-detail">${esc(detail)}</div>
|
|
</div>
|
|
<div class="ch-status ${active?'active':'inactive'}">${active?'Aktiv':'Ikke konfigurert'}</div>
|
|
${active?`<button class="ch-test-btn" onclick="testChannel('${ch.type}')">Test</button>`:''}
|
|
</div>`
|
|
}).join('') + '</div>'
|
|
}
|
|
|
|
async function testChannel(key){
|
|
toast('Sender testmelding via '+key+'...')
|
|
try{
|
|
const body = key==='webhook'
|
|
? JSON.stringify({url:'', event:'custom', title:'CostGuard testmelding', body:'Test fra klient-dashboard'})
|
|
: JSON.stringify({event:'custom', subject:'CostGuard testmelding', body_html:'<p>Test fra klient-dashboard</p>'})
|
|
const r = await fetch('/notify/'+key,{method:'POST',headers:{'Content-Type':'application/json'},body})
|
|
if(r.ok) toast('✅ Testmelding sendt via '+key)
|
|
else toast('⚠️ Svarte '+r.status+' — sjekk konfig')
|
|
}catch(e){
|
|
toast('❌ Feil: '+e.message)
|
|
}
|
|
}
|
|
|
|
// ── LOAD ALL ──────────────────────────────────────────────────────────────────
|
|
async function loadAll(){
|
|
setSpinning(true)
|
|
document.getElementById('last-updated').textContent = 'laster...'
|
|
try{
|
|
const [sum, anm, mod] = await Promise.all([
|
|
fetch('/billing/summary').then(r=>r.ok?r.json():null).catch(()=>null),
|
|
fetch('/billing/anomalies').then(r=>r.ok?r.json():null).catch(()=>null),
|
|
fetch('/billing/tokens/by-module').then(r=>r.ok?r.json():null).catch(()=>null),
|
|
])
|
|
const anomCount = anm?.anomalies?.length ?? 0
|
|
renderStats(sum, anomCount)
|
|
renderAnoms(anm)
|
|
if(mod) renderModules(mod)
|
|
else document.getElementById('mod-table-wrap').innerHTML='<div class="loader" style="color:var(--text-faint)">Ingen moduldata</div>'
|
|
await loadChannels()
|
|
document.getElementById('last-updated').textContent = 'oppdatert ' + tsNow()
|
|
}catch(e){
|
|
toast('❌ Lastingsfeil: '+e.message)
|
|
document.getElementById('last-updated').textContent = 'feil'
|
|
}finally{
|
|
setSpinning(false)
|
|
}
|
|
}
|
|
|
|
// Auto-load + refresh hvert 60s
|
|
loadAll()
|
|
setInterval(loadAll, 60000)
|
|
</script>
|
|
</body>
|
|
</html>
|