Files
6krrt/admin/frontend/quota.html

1056 lines
51 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quota &mdash; LLM Router Admin</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3Dnone%22%20stroke%3D%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3Dnone%22%20stroke%3D%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%23fff%22/%3E%3Ccircle%20cx%3D%22155%22%20cy%3D%22232%22%20r%3D%2212%22%20fill%3D%23fff%22/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
<link rel="stylesheet" href="navbar.css">
<script src="navbar.js"></script>
<style>
/* ── Liquid glass: soft gradient wash behind everything so the blur has
something to catch, then translucent+blurred navbar/cards on top. ── */
body{
background:
radial-gradient(900px circle at 6% -8%, rgba(245,158,11,.10), transparent 55%),
radial-gradient(800px circle at 94% -6%, rgba(59,130,246,.10), transparent 50%),
radial-gradient(760px circle at 50% 108%, rgba(139,92,246,.07), transparent 55%),
#111827;
background-attachment: fixed;
}
header.navbar{
background: rgba(31,41,55,.6)!important;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border-bottom: 1px solid rgba(255,255,255,.06);
}
.card{
background: rgba(31,41,55,.55);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border: 1px solid rgba(255,255,255,.07);
box-shadow: 0 10px 30px -14px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
}
.card-header{border-bottom-color: rgba(255,255,255,.06)}
html{overflow:hidden;height:100%;margin:0!important;padding:0!important}
body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
.btn{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
.badge.bg-success{background:rgba(34,197,94,.20)!important;color:#4ade80!important}
.badge.bg-warning{background:rgba(245,158,11,.20)!important;color:#fbbf24!important}
.badge.bg-danger{background:rgba(239,68,68,.20)!important;color:#f87171!important}
.badge.bg-info{background:rgba(59,130,246,.20)!important;color:#93c5fd!important}
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
.navbar-brand a{text-decoration:none!important;color:inherit}
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
.brand-speedlines svg{filter:none!important}
@keyframes brand-dash{
0%{transform:translateX(-5px);opacity:.2}
55%{opacity:1}
100%{transform:translateX(4px);opacity:0}
}
.navbar-brand a:hover .brand-speedlines line{animation:brand-dash .75s ease-in infinite}
.navbar-brand a:hover .brand-speedlines line:nth-child(2){animation-delay:.12s}
.navbar-brand a:hover .brand-speedlines line:nth-child(3){animation-delay:.24s}
@media (prefers-reduced-motion: reduce){
.navbar-brand a:hover .brand-speedlines line{animation:none}
}
/* ── Quota page-specific styling ── */
/* The one total, in the page header. */
.total-spend{text-align:right}
.total-spend-value{font-size:1.6rem;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.1;
font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif)}
.total-spend-cap{font-size:.66rem;text-transform:uppercase;letter-spacing:.06em;color:var(--tblr-secondary)}
/* Segmented control, shared by the metric and range switches. */
.btn-seg{display:inline-flex;border:1px solid rgba(255,255,255,.10);border-radius:6px;overflow:hidden}
.btn-seg button{background:none;border:0;color:var(--tblr-secondary);cursor:pointer;
padding:.2rem .6rem;font-size:.72rem;font-weight:600;line-height:1.5}
.btn-seg button+button{border-left:1px solid rgba(255,255,255,.08)}
.btn-seg button:hover{background:rgba(255,255,255,.05);color:var(--tblr-body-color)}
.btn-seg button.active{background:rgba(59,130,246,.18);color:#93c5fd}
.usage-note{margin-top:.5rem;font-size:.67rem;line-height:1.5;color:var(--tblr-secondary)}
/* Micro-label above its value. This is the replacement for dot-joined runs:
the label says what the number is and position says how they relate, so no
separator glyph has to hold the pair together. See
plans/admin-design-standards.md, "No middot separators". */
.lbl{display:block;font-size:.6rem;text-transform:uppercase;letter-spacing:.07em;
color:var(--tblr-secondary);margin-bottom:.1rem}
/* Terms of the account: two facts, two slots, one bordered row. */
.term{display:flex;justify-content:space-between;align-items:flex-end;
border:1px solid;border-radius:6px;padding:.45rem .6rem;margin-bottom:.85rem}
.term-v{font-weight:700;font-size:.85rem;font-variant-numeric:tabular-nums}
.hero{display:flex;align-items:center;gap:1rem;margin-bottom:.7rem}
.big{font-size:1.4rem;font-weight:700;line-height:1.1}
.big .of{font-size:.8rem;font-weight:600}
.sub{font-size:.74rem;margin-top:.2rem}
.ring{position:relative;width:84px;height:84px;flex-shrink:0}
.ring svg{transform:rotate(-90deg)}
.ring .ring-bg{fill:none;stroke:rgba(255,255,255,.08);stroke-width:8}
.ring-c{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font-size:1rem;font-weight:700}
/* Under the bar it describes. Namespaced: a bare `.legend` collides with
a Tabler/Chart.js rule on this page and rendered as one character per
line -- both Chart.js and Tabler ship on the quota page. */
.meter-legend{display:flex;justify-content:space-between;font-size:.68rem;
color:var(--tblr-secondary);margin-bottom:.7rem}
/* Label left, value right, one fact per line. */
.facts{display:grid;grid-template-columns:auto 1fr;gap:.28rem .9rem;
font-size:.79rem;align-items:baseline;margin:0 0 .8rem}
.facts dt{color:var(--tblr-secondary)}
.facts dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}
.foot-facts{display:grid;grid-template-columns:auto 1fr;gap:.2rem .7rem;
font-size:.67rem;line-height:1.4;margin:.7rem 0 0;padding-top:.5rem;
border-top:1px solid rgba(255,255,255,.06);color:var(--tblr-secondary)}
.foot-facts dt{text-transform:uppercase;letter-spacing:.06em;font-size:.6rem}
.foot-facts dd{margin:0}
/* Accordion: per-model spend, inside the account that spent it. */
.acc-hd{display:flex;align-items:center;gap:.45rem;width:100%;
background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06);
border-radius:6px;padding:.42rem .6rem;cursor:pointer;
font-size:.68rem;color:var(--tblr-secondary);text-transform:uppercase;
letter-spacing:.06em;font-weight:700}
.acc-hd:hover{background:rgba(255,255,255,.06)}
.acc-hd.open{border-bottom-left-radius:0;border-bottom-right-radius:0}
.acc-caret{transition:transform .18s ease;flex:0 0 auto}
.acc-hd.open .acc-caret{transform:rotate(90deg)}
.acc-count{margin-left:auto;text-transform:none;letter-spacing:0;font-weight:600}
.acc-body{border:1px solid rgba(255,255,255,.06);border-top:0;
border-radius:0 0 6px 6px;padding:.5rem .6rem}
.mrow{display:grid;grid-template-columns:1fr 54px 76px;gap:.6rem;align-items:center;
font-size:.73rem;padding:.26rem 0;border-bottom:1px solid rgba(255,255,255,.04)}
.mrow:last-of-type{border-bottom:0}
.mrow span{text-align:right}
.mhead{font-size:.58rem;text-transform:uppercase;letter-spacing:.06em;
color:var(--tblr-secondary);border-bottom-color:rgba(255,255,255,.08)}
.mhead span:first-child,.mname{text-align:left}
.mname div:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mbar{height:4px;border-radius:999px;background:rgba(255,255,255,.08);margin-top:.2rem}
.mbar div{height:100%;border-radius:999px;background:#93c5fd}
.acc-note{font-size:.65rem;line-height:1.45;color:var(--tblr-secondary);margin-top:.45rem}
/* Runway: both accounts on one shared time axis. */
.runway-row{display:grid;grid-template-columns:130px 1fr 150px;gap:1rem;align-items:center}
.runway-track{position:relative;height:22px}
.runway-base{position:absolute;inset:7px 0 auto 0;height:8px;background:rgba(255,255,255,.08)}
.runway-fill{position:absolute;inset:7px auto auto 0;height:8px}
.runway-gap{position:absolute;inset:7px auto auto 0;height:8px}
.runway-mark{position:absolute;top:0;bottom:0;width:2px}
.runway-note{font-size:.71rem;line-height:1.4;color:var(--tblr-secondary);margin-left:146px}
/* metered_plan: burned vs elapsed on one track. The tick is the comparison --
fill past the tick is overspending the period, fill behind it is slack. */
.pace-bar{position:relative;height:8px;border-radius:999px;background:rgba(255,255,255,.08);overflow:visible;margin-bottom:.25rem}
.pace-fill{height:100%;border-radius:999px;transition:width .5s ease}
.pace-tick{position:absolute;top:-3px;bottom:-3px;left:calc(var(--at) * 1%);width:2px;background:#e2e8f0;box-shadow:0 0 4px rgba(0,0,0,.6)}
.pace-legend{display:flex;justify-content:space-between;font-size:.68rem;color:var(--tblr-secondary);margin-bottom:.5rem}
/* prepaid_credit: a one-way drain. Square ends, not a ring -- a pool does not
come back around. */
.drain-bar{height:8px;border-radius:2px;background:rgba(255,255,255,.08);overflow:hidden;margin-bottom:.25rem}
.drain-fill{height:100%;border-radius:2px;transition:width .5s ease}
.card-foot{margin-top:.6rem;padding-top:.5rem;border-top:1px solid rgba(255,255,255,.06);
font-size:.68rem;line-height:1.4;color:var(--tblr-secondary)}
.alarm-detail{display:block;font-weight:400;color:var(--tblr-secondary);font-size:.75rem;margin-top:.15rem}
/* Gauge ring */
.gauge-ring{
position:relative;
width:100px;
height:100px;
flex-shrink:0;
}
.gauge-ring svg{transform:rotate(-90deg)}
.gauge-ring .bg{fill:none;stroke:rgba(255,255,255,.08);stroke-width:8}
.gauge-ring .fg{fill:none;stroke-width:8;stroke-linecap:round;transition:stroke-dashoffset .6s ease}
.gauge-ring .center{
position:absolute;
inset:0;
display:flex;
align-items:center;
justify-content:center;
font-size:1.2rem;
font-weight:700;
font-variant-numeric:tabular-nums;
}
/* Account card inner layout */
.account-card .card-body{padding:1rem}
.account-header{display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem}
.account-header .provider-icon{width:36px;height:36px;border-radius:999px;display:flex;align-items:center;justify-content:center;font-size:1rem;flex-shrink:0}
.account-header .provider-name{font-weight:700;font-size:.9rem;font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif)}
.account-header .shape-badge{font-size:.65rem;text-transform:uppercase;letter-spacing:.05em;padding:.1rem .45rem;border-radius:999px;background:rgba(255,255,255,.08);color:var(--tblr-secondary);margin-left:auto}
/* Metric rows inside account cards */
.metric-row{display:flex;justify-content:space-between;align-items:baseline;padding:.2rem 0;font-size:.78rem;line-height:1.35}
.metric-row .metric-label{color:var(--tblr-secondary)}
.metric-row .metric-value{font-variant-numeric:tabular-nums;font-weight:600}
/* Spend ledger table */
.spend-table{width:100%;font-size:.78rem;border-collapse:collapse}
.spend-table th{text-align:left;padding:.3rem .5rem;color:var(--tblr-secondary);font-weight:600;text-transform:uppercase;font-size:.7rem;letter-spacing:.05em;border-bottom:1px solid rgba(255,255,255,.06)}
.spend-table td{padding:.3rem .5rem;border-bottom:1px solid rgba(255,255,255,.04);font-variant-numeric:tabular-nums}
.spend-table tr:last-child td{border-bottom:none}
.spend-table .provider-cell{font-weight:600}
.spend-table .coverage-note{color:var(--tblr-secondary);font-size:.7rem;font-style:italic}
.spend-table .total-row td{padding-top:.6rem;font-weight:700;border-top:1px solid rgba(255,255,255,.1)}
.spend-table .total-row .total-label{text-transform:uppercase;letter-spacing:.04em;font-size:.78rem}
.spend-table .total-row .total-val{font-size:1.1rem}
/* Estimate ratio diagnostic strip */
.estimate-strip{
display:flex;
align-items:center;
gap:.5rem;
padding:.4rem .75rem;
border-radius:8px;
font-size:.72rem;
background:rgba(255,255,255,.04);
margin-top:.75rem;
}
.estimate-strip .label{color:var(--tblr-secondary)}
.alarm-strip{
display:flex;
align-items:center;
gap:.5rem;
padding:.5rem .75rem;
border-radius:8px;
margin-bottom:1rem;
font-size:.82rem;
font-weight:600;
}
.alarm-strip.severity-critical{background:rgba(239,68,68,.15);color:#f87171;border:1px solid rgba(239,68,68,.3)}
.alarm-strip.severity-warning{background:rgba(245,158,11,.15);color:#fbbf24;border:1px solid rgba(245,158,11,.3)}
.alarm-strip.severity-info{background:rgba(59,130,246,.12);color:#93c5fd;border:1px solid rgba(59,130,246,.25)}
/* Spacer fill for even card heights in a row */
.card-spacer{flex:1;min-height:0}
/* Empty state */
.empty{text-align:center;padding:2rem 1rem;color:var(--tblr-secondary);font-size:.85rem}
/* Footer parts are laid out, not joined by a glyph. See
plans/admin-design-standards.md, "No middot separators". */
.footer-bits{display:inline-flex;flex-wrap:wrap;gap:.25rem 1.4rem}
</style>
</head>
<body>
<!-- Navbar -->
<header class="navbar navbar-expand-md navbar-dark" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<a href="/admin/" style="display:inline-flex;align-items:center;gap:8px">
<span class="brand-speedlines d-none d-md-flex" aria-hidden="true">
<svg viewBox="0 0 40 64" width="40" height="64" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="speedfade" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="40" y2="0"><stop offset="0" stop-color="#f59e0b" stop-opacity="0"/><stop offset="1" stop-color="#f59e0b" stop-opacity=".85"/></linearGradient></defs>
<g stroke="url(#speedfade)" stroke-linecap="round" fill="none"><line x1="14" y1="20" x2="38" y2="20" stroke-width="5"/><line x1="2" y1="33" x2="36" y2="33" stroke-width="6"/><line x1="18" y1="46" x2="34" y2="46" stroke-width="4.5"/></g>
</svg>
</span>
<img class="navbar-brand-image" src="6krrt-logo.webp" width="64" height="64" style="width:64px;height:64px" alt="6krrt logo">
</a>
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="d-flex align-items-center gap-3">
<span id="nav-widgets" class="d-flex align-items-center"></span>
</div>
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>
</header>
<!-- Page shell -->
<div class="page">
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Quota &amp; Billing</h2>
<div class="text-muted mt-1" id="period-line">&nbsp;</div>
</div>
<!-- The one total. Every other dollar figure on this page belongs to
a single account; this is the only one that sums them, so it is
the only one allowed to be big. -->
<div class="col-auto ms-auto d-print-none">
<div class="total-spend">
<div class="total-spend-value" id="total-spend">&mdash;</div>
<div class="total-spend-cap">total spend this period</div>
</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Alarm strip (if any) -->
<!-- Usage over time, per provider. Replaced the runway projection:
"what has actually happened" is actionable in a way that a single
extrapolated rate is not. -->
<div class="card mb-3">
<div class="card-header" style="padding:.55rem .8rem;gap:.75rem;flex-wrap:wrap">
<h3 class="card-title mb-0" style="font-size:.85rem">Usage</h3>
<div class="btn-seg" id="usage-metrics">
<button type="button" data-metric="spend" class="active">Spend</button>
<button type="button" data-metric="calls">Calls</button>
<button type="button" data-metric="energy">Energy</button>
</div>
<div class="btn-seg ms-auto" id="usage-ranges">
<button type="button" data-range="1h">1h</button>
<button type="button" data-range="6h">6h</button>
<button type="button" data-range="24h" class="active">24h</button>
<button type="button" data-range="7d">7d</button>
<button type="button" data-range="30d">30d</button>
</div>
</div>
<div class="card-body" style="padding:.8rem">
<div id="usage-empty" class="empty" hidden>No usage in this window</div>
<div style="position:relative;height:210px"><canvas id="usage-chart"></canvas></div>
<div id="usage-note" class="usage-note"></div>
</div>
</div>
<!-- One card per account, laid out by billing SHAPE. A renewing kWh
subscription and a draining prepaid pool are different objects and
must not read as the same card with different numbers in it. -->
<div class="row row-cards" id="accounts-grid">
<div class="empty">Loading&hellip;</div>
</div>
</div><!-- /container-xl -->
</div><!-- /page-body -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<span class="footer-bits"><span>&copy; 2026 adLee</span><span>6krrt, local LLM model router</span><span>admin</span></span>
</div>
</footer>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast align-items-center text-bg-info border-0 position-fixed bottom-0 end-0 p-3" role="alert" aria-live="assertive" aria-atomic="true"></div>
<script>
/* ═══════════════════════════════════════════════════
admin/frontend/quota.html — quota & billing dashboard
═══════════════════════════════════════════════════ */
const API = '';
const REFRESH_MS = 30000;
/* ── Utility helpers (moved from index.html) ── */
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatBalanceStaleness(balanceAt) {
if (!balanceAt) return '';
try {
const then = new Date(balanceAt);
const ms = Date.now() - then.getTime();
if (Number.isNaN(ms) || ms < 0) return '';
const minutes = Math.floor(ms / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m old`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h old`;
const days = Math.floor(hours / 24);
return `${days}d old`;
} catch (_) {
return '';
}
}
function formatProviderBalance(provider, entry) {
const source = entry && entry.balance_source ? entry.balance_source : '';
const balance = entry && typeof entry.balance_usd === 'number' ? entry.balance_usd : null;
const balanceAt = entry && entry.balance_at ? entry.balance_at : null;
const suffix = formatBalanceStaleness(balanceAt);
const suffixText = suffix ? ` (${suffix})` : '';
if (balance === null) {
return `${escapeHtml(provider)}: account balance not configured`;
}
let amountText;
if (balance === 0) {
amountText = '$0.00';
} else if (Math.abs(balance) < 0.005) {
amountText = balance < 0 ? '~$0.00 (slight overage)' : '<$0.01';
} else {
amountText = '$' + balance.toFixed(2);
}
const sourceText = source === 'polled'
? 'credits'
: source === 'telemetry'
? 'overage allowance'
: source === 'unconfigured'
? 'account balance not configured'
: 'account';
if (source === 'unconfigured') {
return `${escapeHtml(provider)}: ${amountText} (account balance not configured)`;
}
return `${escapeHtml(provider)}: ${amountText} ${sourceText}${suffixText}`;
}
/* ── Icon helper ── */
function icon(name, size) {
const s = size || 18;
const svgs = {
energy: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
cloud: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19c0-2.2-1.8-4-4-4h-1c-2.2 0-4 1.8-4 4H6c-3.3 0-6-2.7-6-6 0-3.1 2.4-5.7 5.4-6 0.4-3.1 3.1-5.5 6.3-5.5 3.1 0 5.8 2.3 6.3 5.4C21.6 7.3 24 9.9 24 13c0 3.3-2.7 6-6 6h-0.5z"/></svg>`,
credit: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg>`,
server: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
'bar-chart':`<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>`,
};
return svgs[name] || '';
}
function renderStaticIcons() {
document.querySelectorAll('[data-icon]').forEach(el => {
el.innerHTML = icon(el.dataset.icon);
});
}
/* ── Toast ── */
let _toastEl = null;
function toast(msg, type) {
const bgClass = { success: 'text-bg-success', error: 'text-bg-danger', info: 'text-bg-info' }[type] || 'text-bg-info';
if (!_toastEl) {
_toastEl = document.getElementById('toast');
_toastEl.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button></div>';
_toastEl._bt = new tabler.Toast(_toastEl, { delay: 4000 });
}
_toastEl.querySelector('.toast-body').textContent = msg;
_toastEl.className = `toast align-items-center ${bgClass} border-0 position-fixed bottom-0 end-0 p-3`;
_toastEl._bt.show();
}
/* ── API fetch ── */
async function apiFetch(url, opts) {
try {
const resp = await fetch(url, opts);
if (!resp.ok) throw new Error(resp.status + ' ' + resp.statusText);
return await resp.json();
} catch (e) {
console.warn('API call failed:', url, e);
return null;
}
}
/* ═══════════════════════════════════════
RENDER FUNCTIONS
═══════════════════════════════════════ */
/* ── SVG gauge ring ── */
function gaugeHTML(pct, color) {
const r = 38;
const circ = 2 * Math.PI * r;
const offset = circ * (1 - Math.min(pct, 1));
return `<svg width="100" height="100" viewBox="0 0 100 100">
<circle class="bg" cx="50" cy="50" r="${r}"/>
<circle class="fg" cx="50" cy="50" r="${r}" stroke="${color}" stroke-dasharray="${circ}" stroke-dashoffset="${offset}" style="transition:stroke-dashoffset .6s ease"/>
</svg>`;
}
/* ── Account cards ─────────────────────────────────────────────────────────
*
* The distinction this page exists to make: a kWh SUBSCRIPTION that empties
* and RENEWS on a date, versus a prepaid POOL that only ever drains and has no
* renewal at all. Rendering both through one template with different numbers
* in it is what made the first version unreadable.
*
* So the two shapes deliberately do not look alike:
* metered_plan ring gauge (a cycle) + "renews <date>"
* prepaid_credit drain bar (one-way) + "no renewal - top up to refill"
*
* They also answer different questions. A subscription asks "am I on pace to
* blow the allowance before it resets"; a pool asks "how long until this hits
* zero". Neither question is meaningful for the other shape.
*/
function daysBetween(fromIso, toIso) {
const a = new Date(fromIso), b = new Date(toIso);
if (isNaN(a) || isNaN(b)) return null;
return Math.round((b - a) / 86400000);
}
function money(v, dp) {
if (v == null) return '&mdash;';
return '$' + Number(v).toFixed(dp == null ? 2 : dp);
}
/* Footnote: how much of this account's total the per-model breakdown can
explain, and how the total was arrived at. Only shown when it is not the
trivial "we measured every request". */
function provenanceNote(spend) {
const cov = spend.attribution_coverage;
const src = spend.source;
if (src === 'account_poll_delta') {
return 'total from the provider account; per-request costs cover '
+ ((cov || 0) * 100).toFixed(0) + '% of it so far';
}
if (cov != null && cov < 1) {
return 'per-request billed; ' + (cov * 100).toFixed(0) + '% of calls reported a cost';
}
return 'per-request billed, every call';
}
function colSpanFor(n) {
if (n <= 1) return 'col-xl-6 col-md-8';
if (n === 2) return 'col-xl-6 col-md-6';
return 'col-xl-4 col-md-6';
}
function daysFromNow(iso) {
const d = new Date(iso);
if (isNaN(d)) return null;
return Math.max(Math.round((d - Date.now()) / 86400000), 0);
}
function money(v, dp) {
if (v == null) return '&mdash;';
const n = Number(v);
// A tiny negative is an overage residue, not a debt worth four decimals:
// NeuralWatt's allowance sits at -$0.004 essentially always, and "-$0.00"
// reads as a bug.
if (n < 0 && n > -0.005) return '~$0.00';
if (n !== 0 && Math.abs(n) < 0.005) return '&lt;$0.01';
return '$' + n.toFixed(dp == null ? 2 : dp);
}
/* Which per-model accordions the operator has open, by id, surviving the
poll's full re-render of the grid. */
const _openAccordions = new Set();
/* Per-model spend, inside the account it belongs to. Never a shared ledger:
a blended provider-vs-provider table is what made the first version
unreadable. */
function modelBreakdown(acc, idx) {
const models = acc.models || [];
if (!models.length) return '';
const byCost = acc.models_ranked_by === 'spend';
const top = models.reduce(
(m, r) => Math.max(m, byCost ? (r.cost_usd || 0) : (r.calls || 0)), 0) || 1;
const anyUnpriced = models.some(m => m.cost_usd == null);
const id = 'models-' + idx;
const rows = models.map(m => {
const v = byCost ? (m.cost_usd || 0) : (m.calls || 0);
return `<div class="mrow">
<div class="mname">
<div title="${escapeHtml(m.model_id)}">${escapeHtml(m.model_id)}</div>
<div class="mbar"><div style="width:${Math.max((v / top) * 100, 1.5)}%"></div></div>
</div>
<span class="num mut">${Number(m.calls || 0).toLocaleString()}</span>
<span class="num">${m.cost_usd == null ? '<span class="mut">&mdash;</span>' : money(m.cost_usd, 4)}</span>
</div>`;
}).join('');
return `
<button class="acc-hd${_openAccordions.has(id) ? ' open' : ''}" type="button" data-acc="${id}"
aria-expanded="${_openAccordions.has(id)}" aria-controls="${id}">
<svg class="acc-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
where it went
<span class="acc-count">${models.length} model${models.length === 1 ? '' : 's'}</span>
</button>
<div class="acc-body" id="${id}"${_openAccordions.has(id) ? '' : ' hidden'}>
<div class="mrow mhead">
<span>model</span><span>calls</span><span>spend</span>
</div>
${rows}
<div class="acc-note">
Ranked by ${byCost ? 'spend' : 'call count, because this provider has not reported spend for most of them yet'}.
${anyUnpriced ? 'A dash means the call predates per-request cost capture, so it sits in the account total but not in this list.' : ''}
</div>
</div>`;
}
/* ── Usage graph ───────────────────────────────────────────────────────────
*
* Replaces the runway projection. A projection answered "when will this run
* out" from a single extrapolated rate; the graph answers "what has actually
* been happening", which is the question you can act on.
*
* Per provider, because this page is organised by account and a blended total
* is what made the first version unreadable. Three metrics, because they have
* genuinely different coverage:
*
* spend exact per request where the provider reports it; for a prepaid
* pool whose requests carry no cost, taken from the movement of the
* polled balance instead. Coarser, but a flat zero line would be a
* lie -- which is exactly what the account cards used to show.
* calls perfect coverage for every provider, all the way back.
* energy telemetry providers only; a provider reporting none is omitted
* rather than drawn as a zero line.
*
* The footnote names each provider's spend source, so two lines of different
* fidelity are never presented as if they were the same measurement.
*/
const USAGE_METRICS = {
spend: {label: 'Spend', unit: 'USD', fmt: v => '$' + v.toFixed(4)},
calls: {label: 'Calls', unit: 'calls', fmt: v => Number(v).toLocaleString()},
energy: {label: 'Energy', unit: 'kWh', fmt: v => v.toFixed(5) + ' kWh'},
};
const PROVIDER_COLORS = {
neuralwatt: '#fbbf24',
openrouter: '#93c5fd',
'ollama-local': '#4ade80',
};
const SOURCE_WORDS = {
per_request: 'per-request billed',
balance_poll: 'dashed, because it comes from balance polls rather than '
+ 'per-request costs, so it is only as fine as the poll interval',
unavailable: 'not reported',
};
/* The vertical rule under the cursor, which is the half of Grafana's hover
Chart.js does not ship. Without it the tooltip tells you the values but not
WHERE on the timeline you are reading them, and on a wide chart the eye
cannot hold the column. Drawn under the datasets so it never obscures a
line. */
const crosshairPlugin = {
id: 'crosshair',
afterDatasetsDraw(chart) {
const active = chart.tooltip && chart.tooltip.getActiveElements
? chart.tooltip.getActiveElements() : [];
if (!active.length) return;
const x = active[0].element.x;
const {top, bottom} = chart.chartArea;
const c = chart.ctx;
c.save();
c.beginPath();
c.moveTo(x, top);
c.lineTo(x, bottom);
c.lineWidth = 1;
c.setLineDash([3, 3]);
c.strokeStyle = 'rgba(255,255,255,.35)';
c.stroke();
c.restore();
},
};
let _usageMetric = 'spend';
let _usageRange = '24h';
let _usageChart = null;
let _usageData = null;
async function loadUsage() {
const d = await apiFetch(API + 'api/quota/history?range=' + encodeURIComponent(_usageRange));
if (d) { _usageData = d; renderUsage(); }
}
function renderUsage() {
if (!_usageData) return;
const meta = USAGE_METRICS[_usageMetric];
const series = (_usageData.series || {})[_usageMetric] || {};
// A provider with nothing to show is left out entirely rather than drawn as
// a flat zero -- OpenRouter reports no energy at all, and a zero line reads
// as "measured, and it was nothing".
const visible = Object.entries(series)
.filter(([, pts]) => pts.length && pts.some(p => p[1] !== 0));
const datasets = visible
.map(([provider, pts]) => ({
label: provider,
data: pts.map(([ts, v]) => ({x: ts * 1000, y: v})),
borderColor: PROVIDER_COLORS[provider] || '#cbd5e1',
// Fill only when one series is on its own. Two translucent areas over
// each other muddy both and hide the smaller one entirely.
backgroundColor: (PROVIDER_COLORS[provider] || '#cbd5e1')
+ (visible.length === 1 ? '2b' : '00'),
borderWidth: 1.6,
pointRadius: 0,
// Invisible until hovered, then the exact bucket being reported gets a
// dot on it, so the tooltip's numbers are anchored to a point you can see.
pointHoverRadius: 4,
pointHoverBorderWidth: 2,
pointHoverBackgroundColor: '#111827',
pointHoverBorderColor: PROVIDER_COLORS[provider] || '#cbd5e1',
fill: visible.length === 1,
tension: 0.25,
// Coarse series are DASHED, not a different shape. This was `stepped`
// plus a fill, which renders as a block and reads as a bar chart mixed
// into a line chart -- shape should not vary between series in one plot.
// A dash says "lower confidence" while keeping the mark identical, and
// the footnote names the actual source.
borderDash: (_usageMetric === 'spend'
&& (_usageData.sources || {})[provider] === 'balance_poll') ? [5, 3] : [],
}));
document.getElementById('usage-empty').hidden = datasets.length > 0;
const notes = Object.entries(_usageData.sources || {})
.filter(([p]) => series[p] && series[p].length)
.map(([p, s]) => `${escapeHtml(p)}: ${SOURCE_WORDS[s] || s}`);
document.getElementById('usage-note').innerHTML =
_usageMetric === 'spend' && notes.length ? notes.join('<br>') : '';
if (_usageChart) {
_usageChart.data.datasets = datasets;
_usageChart.options.scales.y.title.text = meta.unit;
_usageChart.update('none');
return;
}
const ctx = document.getElementById('usage-chart');
if (!ctx) return;
_usageChart = new Chart(ctx, {
type: 'line',
data: {datasets},
plugins: [crosshairPlugin],
options: {
responsive: true,
maintainAspectRatio: false,
// Grafana-style: hovering anywhere on the plot reports every series at
// that instant, not just the line the cursor happens to be over.
interaction: {mode: 'index', intersect: false},
hover: {mode: 'index', intersect: false},
plugins: {
legend: {labels: {color: '#8b94a7', boxWidth: 10, font: {size: 11}}},
tooltip: {
backgroundColor: 'rgba(17,24,39,.96)',
borderColor: 'rgba(255,255,255,.12)',
borderWidth: 1,
padding: 10,
titleColor: '#e6e9ef',
bodyColor: '#e6e9ef',
bodySpacing: 4,
callbacks: {
// Each point is a BUCKET, not a reading, so the header names the
// window it covers. "$12 at 18:00" invites reading it as a rate;
// "$12 between 18:00 and 00:00" is what the number means.
title: (items) => {
if (!items.length) return '';
const start = new Date(items[0].parsed.x);
const secs = (_usageData && _usageData.bucket_seconds) || 3600;
const end = new Date(start.getTime() + secs * 1000);
const day = {month: 'short', day: 'numeric'};
const hm = {hour: '2-digit', minute: '2-digit'};
return secs >= 86400
? start.toLocaleDateString([], day)
: `${start.toLocaleDateString([], day)} `
+ `${start.toLocaleTimeString([], hm)} to ${end.toLocaleTimeString([], hm)}`;
},
label: (c) => ` ${c.dataset.label} ${USAGE_METRICS[_usageMetric].fmt(c.parsed.y)}`,
},
},
},
scales: {
x: {
type: 'time',
grid: {color: 'rgba(255,255,255,.05)'},
ticks: {color: '#8b94a7', maxRotation: 0, font: {size: 10}},
},
y: {
beginAtZero: true,
title: {display: true, text: meta.unit, color: '#8b94a7', font: {size: 10}},
grid: {color: 'rgba(255,255,255,.05)'},
ticks: {color: '#8b94a7', font: {size: 10}},
},
},
},
});
}
function wireUsageControls() {
const metrics = document.getElementById('usage-metrics');
const ranges = document.getElementById('usage-ranges');
if (metrics) {
metrics.addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-metric]');
if (!b) return;
_usageMetric = b.getAttribute('data-metric');
metrics.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
renderUsage(); // no refetch: every metric is in the payload
});
}
if (ranges) {
ranges.addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-range]');
if (!b) return;
_usageRange = b.getAttribute('data-range');
ranges.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
loadUsage(); // a different span does need a fetch
});
}
}
/* Delegated so it survives every re-render of the grid (the page polls).
Toggles `hidden` rather than style.display, matching the rest of the
portal. */
function wireAccordions(root) {
if (root.dataset.accWired) return; // the grid is re-rendered, the node is not
root.dataset.accWired = '1';
root.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-acc]');
if (!btn) return;
const id = btn.getAttribute('data-acc');
const body = document.getElementById(id);
if (!body) return;
const open = body.hidden; // about to become open
body.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
btn.classList.toggle('open', open);
// Remembered across renders: the page re-renders the whole grid every
// REFRESH_MS, so without this an accordion you opened silently shuts
// itself 30 seconds later while you are reading it.
if (open) _openAccordions.add(id); else _openAccordions.delete(id);
});
}
function renderAccountCard(acc, quota, idx) {
const shape = acc.shape;
const name = escapeHtml(acc.provider);
const spend = acc.spend_usd || {};
const periodCost = spend.period != null ? spend.period : 0;
const period = quota.period || {};
const ratio = (quota.spend && quota.spend.estimate_ratio_by_provider) || {};
const credit = acc.credit || {};
const SHAPES = {
metered_plan: {icon: 'energy', bg: 'rgba(245,158,11,.2)', fg: '#fbbf24', label: 'subscription'},
prepaid_credit: {icon: 'credit', bg: 'rgba(59,130,246,.2)', fg: '#93c5fd', label: 'prepaid credit'},
self_hosted: {icon: 'server', bg: 'rgba(34,197,94,.2)', fg: '#4ade80', label: 'your hardware'},
};
const s = SHAPES[shape] || {icon: 'cloud', bg: 'rgba(148,163,184,.2)', fg: '#e2e8f0', label: shape};
/* The renewal block. Two facts, two slots in one bordered row -- no
separator glyph holding them together. */
function termBlock(leftLbl, leftVal, rightLbl, rightVal, tint, border) {
return `<div class="term" style="background:${tint};border-color:${border}">
<div><span class="lbl">${leftLbl}</span><span class="term-v">${leftVal}</span></div>
<div style="text-align:right"><span class="lbl">${rightLbl}</span><span class="term-v">${rightVal}</span></div>
</div>`;
}
let bodyHtml = '';
if (shape === 'metered_plan' && acc.plan) {
const plan = acc.plan;
const usedFrac = plan.used_fraction || 0;
const elapsed = period.elapsed_fraction;
const usedPct = Math.min(usedFrac * 100, 100);
const pace = elapsed ? usedFrac / elapsed : null;
const col = pace == null ? '#4ade80' : pace > 2 ? '#f87171' : pace > 1.25 ? '#fbbf24' : '#4ade80';
const left = period.next_reset ? daysFromNow(period.next_reset) : null;
const circ = 2 * Math.PI * 38;
bodyHtml =
termBlock('renews', escapeHtml(period.next_reset || 'not configured'),
'in', left != null ? left + ' days' : '&mdash;',
'rgba(245,158,11,.07)', 'rgba(245,158,11,.18)') + `
<div class="hero">
<div class="ring">
<svg width="84" height="84" viewBox="0 0 100 100">
<circle class="ring-bg" cx="50" cy="50" r="38"/>
<circle cx="50" cy="50" r="38" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"
stroke-dasharray="${circ.toFixed(1)}" stroke-dashoffset="${(circ * (1 - Math.min(usedFrac, 1))).toFixed(1)}"/>
</svg>
<div class="ring-c num" style="color:${col}">${usedPct.toFixed(0)}%</div>
</div>
<div style="flex:1;min-width:0">
<span class="lbl">burn pace</span>
<div class="num big" style="color:${col}">${pace != null ? pace.toFixed(1) + '&times;' : '&mdash;'}</div>
<div class="mut num sub">${plan.used_kwh} of ${plan.kwh_per_period} kWh used</div>
</div>
</div>
<div class="pace-bar">
<div class="pace-fill" style="width:${usedPct}%;background:${col}"></div>
${elapsed ? `<div class="pace-tick" style="left:${Math.min(elapsed * 100, 100)}%"></div>` : ''}
</div>
<div class="meter-legend"><span>burned ${usedPct.toFixed(0)}%</span><span>elapsed ${elapsed ? (elapsed * 100).toFixed(0) : '?'}%</span></div>
<dl class="facts">
<dt>Billed this period</dt><dd>${money(periodCost)}</dd>
<dt>Overage credits</dt><dd style="color:#fbbf24">${credit.balance_usd != null ? money(credit.balance_usd) : '&mdash;'}</dd>
<dt>Over-plan usage</dt><dd>billed, never refused</dd>
</dl>`;
} else if (shape === 'prepaid_credit') {
const pool = acc.pool || {};
const bal = pool.balance_usd != null ? pool.balance_usd : credit.balance_usd;
const total = pool.total_credits_usd;
const hrs = acc.burn && acc.burn.projected_hours_remaining;
const rate = acc.burn && acc.burn.burn_rate_usd_per_hour;
const leftFrac = (total && bal != null) ? Math.max(bal / total, 0) : null;
const col = (hrs != null && hrs < 48) ? '#f87171' : '#93c5fd';
bodyHtml =
termBlock('renews', 'never', 'to refill', 'top up',
'rgba(148,163,184,.06)', 'rgba(148,163,184,.16)') + `
<div style="margin-bottom:.7rem">
<span class="lbl">balance</span>
<div class="num big" style="color:${col}">${money(bal)}${total != null ? ` <span class="mut of">of ${money(total)}</span>` : ''}</div>
</div>
${leftFrac != null ? `
<div class="drain"><div class="drain-fill" style="width:${(leftFrac * 100).toFixed(1)}%;background:${col}"></div></div>
<div class="meter-legend"><span>${(leftFrac * 100).toFixed(0)}% of the pool left</span>
<span class="num">${rate != null ? '$' + rate.toFixed(3) + ' per hour' : ''}</span></div>`
: `<div class="meter-legend"><span>pool size unknown until the next balance poll</span>
<span class="num">${rate != null ? '$' + rate.toFixed(3) + ' per hour' : ''}</span></div>`}
<dl class="facts">
<dt>Used this period</dt><dd>${money(periodCost)}</dd>
<dt>Balance read</dt><dd>${credit.balance_at ? escapeHtml(formatBalanceStaleness(credit.balance_at)) : '&mdash;'}</dd>
<dt>At zero</dt><dd>stops until topped up</dd>
</dl>`;
} else if (shape === 'self_hosted') {
const energy = acc.energy || {};
bodyHtml =
termBlock('renews', 'never', 'billed to', 'your utility',
'rgba(34,197,94,.06)', 'rgba(34,197,94,.16)') + `
<div style="margin-bottom:.7rem">
<span class="lbl">electricity this period</span>
<div class="num big" style="color:#4ade80">${money(periodCost, 4)}</div>
</div>
<dl class="facts">
<dt>30d energy</dt><dd>${energy.kwh_30d != null ? energy.kwh_30d.toFixed(4) + ' kWh' : '&mdash;'}</dd>
<dt>30d calls</dt><dd>${energy.calls_30d != null ? Number(energy.calls_30d).toLocaleString() : '&mdash;'}</dd>
<dt>Cap</dt><dd>none, no account to run out</dd>
</dl>`;
} else {
bodyHtml = `<div class="mut" style="font-size:.78rem;line-height:1.5">Nothing to meter:
this provider reports no plan, no balance endpoint and no per-request cost.</div>`;
}
const r = ratio[acc.provider];
return `<div class="${colSpanFor(quota.accounts ? quota.accounts.length : 1)}">
<div class="card h-100 account-card">
<div class="card-header" style="padding:.55rem .8rem">
<div class="account-header" style="margin-bottom:0;flex:1">
<div class="provider-icon" style="background:${s.bg};color:${s.fg}">${icon(s.icon, 16)}</div>
<span class="provider-name">${name}</span>
<span class="shape-badge">${escapeHtml(s.label)}</span>
</div>
</div>
<div class="card-body">
${bodyHtml}
${modelBreakdown(acc, idx)}
<dl class="foot-facts">
<dt>Source</dt><dd>${provenanceNote(spend)}</dd>
${r != null ? `<dt>Estimate</dt><dd title="The router's own est_cost_usd against what this provider actually billed. Above 1 means the cost tiebreak overprices this provider.">router runs ${r.toFixed(1)}&times; the bill</dd>` : ''}
</dl>
</div>
</div>
</div>`;
}
/* ── Render accounts grid ── */
function renderAccounts(quota) {
const grid = document.getElementById('accounts-grid');
if (!quota || !quota.accounts || !quota.accounts.length) {
grid.innerHTML = '<div class="col-12"><div class="empty">No provider accounts configured</div></div>';
return;
}
grid.innerHTML = quota.accounts.map((acc, i) => renderAccountCard(acc, quota, i)).join('');
wireAccordions(grid);
}
/* The single total, in the page header. The blended provider-vs-provider
ledger that used to sit at the bottom is gone: every dollar figure now
lives inside the account it belongs to, and this is the only one that adds
them up. */
function renderTotals(quota) {
const spend = quota.spend || {};
const period = quota.period || {};
const el = document.getElementById('total-spend');
if (el) el.innerHTML = money(spend.total_usd, 2);
const line = document.getElementById('period-line');
if (line) {
line.textContent = period.start
? (period.next_reset
? `Billing period ${period.start} to ${period.next_reset}`
: `Rolling 30 days from ${period.start} - no billing_reset_day configured`)
: 'Per-provider billing shape, spend, and plan adherence';
}
}
/* ═══════════════════════════════════════
DATA LOADING
═══════════════════════════════════════ */
async function loadQuota() {
const data = await apiFetch(API + 'api/quota');
if (data) {
renderAccounts(data);
renderTotals(data);
navbarUpdated(new Date().toLocaleString());
}
}
/* ═══════════════════════════════════════
SSE (live badge only)
═══════════════════════════════════════ */
let _sse = null;
function connectSSE() {
try {
_sse = new EventSource('/events/decisions');
navbarSse('connecting');
_sse.onopen = () => { navbarSse('live'); };
_sse.onerror = () => { navbarSse('offline', 'reconnecting'); };
} catch (e) {
navbarSse('offline', String(e));
}
}
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
function init() {
renderStaticIcons();
wireUsageControls();
loadQuota();
loadUsage();
connectSSE();
// The graph refreshes on the same beat as the cards. A range change refetches
// on its own; a metric change does not, because every metric ships in the
// one payload.
setInterval(() => { loadQuota(); loadUsage(); }, REFRESH_MS);
}
init();
</script>
</body>
</html>