The dashboard was eight cards restating what the other seven pages own, so it was the page you passed through rather than the one you started from. It is now the landing page: no nav entry of its own, reached by the logo, with two views over the one /api/snapshot payload so switching costs no request. Board is one tile per destination, identical anatomy every time: what it is, one number, one qualifier, a link. Under them, a full-width Activity band plots routed decisions against billed requests on one axis, so the gap between them -- local dispatch, cache hits, requests that never reached a provider -- is readable, which it is in neither series alone. Its range (24h/7d/30d) is separate from the tile sparklines, because it is the chart you come to the page to read. Beside it, the eight busiest models ranked by CALLS, not cost: cost coverage is partial, and an OpenRouter row with two priced calls out of hundreds otherwise outranks the model doing the work. Live loads the row itself -- category, model, provider, required context, tier, classify time, exploratory flag, cost -- so the usual question does not need the Decisions page, and groups rows by minute so a burst reads as one. The dot is the classification source: green for a real classification, amber for a degraded one, which still routes but never moves proficiency, and until now was visible only in a /metrics warning. The rail beside it answers what Board cannot: throughput against the last hour, classifier p50 and p95 (the latency floor every routed request pays before an upstream token), degraded share, and the top five models by share of the live window keyed on model AND provider, since the same model on two providers is two different routing outcomes. Spend is not restated here; it belongs to Quota. Warnings move behind a navbar bell. They are recomputed from live state on every poll, so the only honest dismissal is "hide until the condition changes": a dismissal is keyed on the warning's TEXT, so when the numbers move the key stops matching and the warning returns on its own. Nothing has to expire it. Per browser, in localStorage, wrapped in try/catch -- a reading state, not a fact about the router. Two tests changed shape rather than being deleted. The verdict-bar tests pinned a card this page no longer has, but the lesson they recorded is the expensive part and is preserved verbatim in the replacement's docstring: `unverifiable` is not a finding, it is the default for prose and for any turn ending in a tool call, and any bar scaled against it renders every meaningful verdict at the 2px minimum. Exclude it from the scale, never from the denominator. The Live rail states the same fact as one ratio, which needs no scale. The nav test's rule is inverted for index.html rather than skipped: the landing page must have no nav entry AND must claim no active one, so neither can drift back in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
1064 lines
51 KiB
HTML
1064 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">
|
|
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
|
<li class="nav-item active"><a class="nav-link" href="/admin/quota">Quota</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
|
</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> |