1056 lines
51 KiB
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 — 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 & Billing</h2>
|
|
<div class="text-muted mt-1" id="period-line"> </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">—</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…</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>© 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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 '—';
|
|
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 '—';
|
|
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 '<$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">—</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' : '—',
|
|
'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) + '×' : '—'}</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) : '—'}</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)) : '—'}</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' : '—'}</dd>
|
|
<dt>30d calls</dt><dd>${energy.calls_30d != null ? Number(energy.calls_30d).toLocaleString() : '—'}</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)}× 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> |