1089 lines
55 KiB
HTML
1089 lines
55 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>Models — 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%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%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%22%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%22%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%22%23fff%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%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. Kept
|
|
subtle — this is a working dashboard, not a marketing page. ── */
|
|
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)}
|
|
/* ── The "gap only at wide windows" bug, root-caused: this Chrome/Linux
|
|
build reports a phantom left margin on <html> exactly equal to the
|
|
scrollbar's width whenever <html> itself is the scrolling element.
|
|
Moving the scroll context down to <body> sidesteps it. ── */
|
|
html{overflow:hidden;height:100%;margin:0!important;padding:0!important}
|
|
body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|
/* ── Buttons: glass pills instead of flat saturated fills. ── */
|
|
.btn-success{background:rgba(34,197,94,.16)!important;border-color:rgba(74,222,128,.45)!important;color:#4ade80!important}
|
|
.btn-success:hover{background:rgba(34,197,94,.28)!important;border-color:rgba(74,222,128,.7)!important;color:#86efac!important}
|
|
.btn-danger{background:rgba(239,68,68,.16)!important;border-color:rgba(248,113,113,.45)!important;color:#f87171!important}
|
|
.btn-danger:hover{background:rgba(239,68,68,.28)!important;border-color:rgba(248,113,113,.7)!important;color:#fca5a5!important}
|
|
.btn{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
|
|
/* ── Badges: same glass treatment. ── */
|
|
.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}
|
|
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
|
|
/* ── Progress bars: translucent track + glow instead of flat solid. ── */
|
|
.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}
|
|
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
|
.navbar-brand-image{filter:brightness(0) invert(1)} — attribute selector +
|
|
2 classes outranks a plain 2-class override, so `filter:none` alone loses
|
|
the cascade regardless of source order. !important is what actually beats
|
|
it. That filter was stripping the mascot's orange color, and the 2rem
|
|
.navbar-brand-image cap shrank his face to sub-pixel dots.
|
|
Restore color + force an explicit size (64px, below). ── */
|
|
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
|
|
/* ── 64px mascot, same bar height. His face is only ~40% of his height (the
|
|
rest is tail and wheels), so at 45px the eyes were ~4px and the whole point
|
|
of having a mascot was lost. The 19px comes out of padding, not out of the
|
|
navbar: the brand h1 carries 8px top and bottom, and the navbar itself 4px.
|
|
Zero the first and halve the second and the bar lands at 69px — 1px shorter
|
|
than the 45px version it replaces. Measure before changing either number. ── */
|
|
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
|
|
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|
/* ── Speed lines: he is a wheeled rover, so give him a trail. Drawn as their
|
|
own inline SVG rather than baked into the logo, so assets/*.svg stays the
|
|
plain mascot and the lines can drop out on narrow screens (d-none d-md-flex).
|
|
The gradient fades AWAY from him (transparent at the far left, amber where
|
|
he is), which is what reads as speed rather than as three floating dashes;
|
|
it is userSpaceOnUse because an objectBoundingBox gradient on a horizontal
|
|
line has a zero-height bbox and is undefined. ── */
|
|
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
|
|
/* -20px: the logo's own viewBox carries ~18px of empty space to the left of
|
|
his tail at 64px, so the pull has to cover that before it buys any real
|
|
closeness. Measured gap from the middle line's tip to his back: ~4.5px. */
|
|
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
|
|
.brand-speedlines svg{filter:none!important}
|
|
/* Motion only on hover — a dashboard that twitches at rest is a dashboard you
|
|
stop looking at. */
|
|
@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}
|
|
}
|
|
/* ── Tier colors on the availability table ── */
|
|
.tier-1{color:var(--tblr-success)}
|
|
.tier-2{color:var(--tblr-warning)}
|
|
.tier-3{color:var(--tblr-danger)}
|
|
/* ── Row density: same trade the decisions table makes, for the same
|
|
reason -- this is a scanning surface over 440 rows, so it gives up
|
|
card-table's comfortable spacing for smaller type AND tighter padding,
|
|
not one or the other. Rows were 57px against the decisions table's 36;
|
|
24px of that was td padding and 32px was the override select, so both
|
|
had to come down or the row would not move. ── */
|
|
#model-tbody td, thead th{padding-top:.2rem!important;padding-bottom:.2rem!important}
|
|
#model-tbody{font-size:.72rem}
|
|
#model-tbody td{line-height:1.25}
|
|
/* The select is the floor on row height: shrink it or nothing else matters. */
|
|
#model-tbody .model-override{height:24px;padding:.05rem .35rem;font-size:.7rem;line-height:1.2}
|
|
/* Numeric columns align right and share the tabular figures the rest of the
|
|
portal uses, so magnitudes line up down the column. */
|
|
tbody td.num, thead th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
.prof-n{color:var(--tblr-secondary);opacity:.6;font-size:.62rem;margin-left:1px}
|
|
.cost-free{color:#4ade80}
|
|
/* Capability chips reuse the decisions page's flag-chip language so the two
|
|
scanning tables read the same way. */
|
|
.cap-chip{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;
|
|
border-radius:4px;font-size:.58rem;font-weight:700;margin-right:2px;
|
|
background:rgba(255,255,255,.06);color:rgba(255,255,255,.28);cursor:default}
|
|
.cap-chip.on{background:rgba(59,130,246,.22);color:#93c5fd}
|
|
.tier-?{color:var(--tblr-secondary)}
|
|
/* 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}
|
|
/* Segmented control, shared with the home page and the quota graph. */
|
|
.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 .7rem;font-size:.73rem;font-weight:600;line-height:1.6}
|
|
.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}
|
|
/* Ranked usage rows. */
|
|
.usage-row{display:grid;grid-template-columns:minmax(0,15rem) 1fr auto;gap:.7rem;
|
|
align-items:center;padding:.28rem .35rem;border-radius:4px;cursor:pointer}
|
|
.usage-row:hover{background:rgba(255,255,255,.04)}
|
|
.usage-row:focus-visible{outline:2px solid rgba(59,130,246,.55);outline-offset:1px}
|
|
.usage-label{font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.usage-track{height:8px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}
|
|
.usage-track div{height:100%;border-radius:999px}
|
|
.usage-val{font-size:.75rem;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
.detail-facts{display:grid;grid-template-columns:auto 1fr;gap:.3rem .9rem;
|
|
font-size:.82rem;align-items:baseline;margin:0}
|
|
.detail-facts dt{color:var(--tblr-secondary)}
|
|
.detail-facts dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}
|
|
</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">Models</h2>
|
|
<div class="text-muted mt-1" id="models-subtitle">Available models, their tier, and live availability overrides</div>
|
|
</div>
|
|
<!-- Two questions, one page: what CAN route, and what actually did.
|
|
Same segmented control as the home page and the quota graph. -->
|
|
<div class="col-auto ms-auto">
|
|
<div class="btn-seg" id="models-views">
|
|
<button type="button" data-view="available" class="active">Available</button>
|
|
<button type="button" data-view="usage">Usage</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="page-body">
|
|
<div class="container-xl">
|
|
<div class="row row-cards mb-3" id="view-stalls">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
|
<h3 class="card-title mb-0">Stalls by model</h3>
|
|
</div>
|
|
<div class="card-subtitle text-muted ms-2">Flagged looping sessions per model</div>
|
|
<div class="ms-auto d-flex align-items-center gap-2">
|
|
<div class="btn-seg" id="stall-window">
|
|
<button type="button" data-hours="1">1h</button>
|
|
<button type="button" data-hours="24" class="active">24h</button>
|
|
<button type="button" data-hours="168">7d</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body" style="padding:.6rem .8rem">
|
|
<div id="stalls-body">
|
|
<div class="text-muted small">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row row-cards mb-3" id="view-blocked">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
|
<h3 class="card-title mb-0">Blocked models</h3>
|
|
</div>
|
|
</div>
|
|
<div class="card-body" style="padding:.6rem .8rem">
|
|
<div id="blocked-models-body">
|
|
<div class="text-muted small">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row row-cards" id="view-available">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
|
<h3 class="card-title mb-0">Model Availability</h3>
|
|
</div>
|
|
<div class="card-subtitle text-muted ms-2">Change the override dropdown to mark a model active, blocked, deprecated, or stale</div>
|
|
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap">
|
|
<span class="text-muted small" id="model-count">—</span>
|
|
</div>
|
|
</div>
|
|
<!-- Filter bar. 446 rows in one ungrouped table is not a list
|
|
anyone can work through; these are the four axes an
|
|
operator actually narrows by, in the order they reach for
|
|
them. Availability replaces the old show-deprecated
|
|
switch, which could only say all-or-nothing. -->
|
|
<div class="card-body border-bottom py-2">
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
<div class="input-group input-group-sm" style="width:20rem;max-width:100%">
|
|
<span class="input-group-text">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
</span>
|
|
<input type="search" class="form-control" id="mf-search"
|
|
placeholder="Filter by model id" autocomplete="off">
|
|
</div>
|
|
<select class="form-select form-select-sm" id="mf-provider" style="width:auto"></select>
|
|
<select class="form-select form-select-sm" id="mf-tier" style="width:auto">
|
|
<option value="">All tiers</option>
|
|
<option value="1">Tier 1</option>
|
|
<option value="2">Tier 2</option>
|
|
<option value="3">Tier 3</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" id="mf-availability" style="width:auto">
|
|
<option value="routable">Routable only</option>
|
|
<option value="">All statuses</option>
|
|
<option value="active">active</option>
|
|
<option value="blocked">blocked</option>
|
|
<option value="deprecated">deprecated</option>
|
|
<option value="stale">stale</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" id="mf-page-size" style="width:auto">
|
|
<option value="25">25 / page</option>
|
|
<option value="50" selected>50 / page</option>
|
|
<option value="100">100 / page</option>
|
|
</select>
|
|
<button class="btn btn-outline-secondary btn-sm" id="mf-clear">Clear</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-vcenter table-hover card-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Model</th>
|
|
<th>Provider</th>
|
|
<th>Tier</th>
|
|
<th class="num" title="Effective context window">Ctx</th>
|
|
<th class="num" title="List price per 1M completion tokens -- the figure profiles filter on">$/1M out</th>
|
|
<th class="num" title="Mean expected pass rate across measured categories">Prof</th>
|
|
<th title="Capabilities: Tools, Vision, JSON mode, Reasoning">Caps</th>
|
|
<th>Status</th>
|
|
<th>Override</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="model-tbody">
|
|
<tr><td colspan="9" class="text-muted">Loading…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="card-footer d-flex align-items-center gap-2 py-2" id="model-pager" style="display:none">
|
|
<span class="text-muted small" id="model-page-info"></span>
|
|
<div class="ms-auto d-flex gap-2">
|
|
<button class="btn btn-outline-secondary btn-sm" id="mf-prev">Prev</button>
|
|
<button class="btn btn-outline-secondary btn-sm" id="mf-next">Next</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div><!-- /row row-cards, available view -->
|
|
|
|
<!-- USAGE VIEW. Moved off the dashboard, which is a home page now.
|
|
This is where per-model analytics belongs: the same objects the
|
|
Available view lists, seen by what they actually did. -->
|
|
<div class="row row-cards" id="view-usage" hidden>
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header" style="gap:.75rem;flex-wrap:wrap">
|
|
<h3 class="card-title mb-0">Per-model usage</h3>
|
|
<span class="card-subtitle text-muted ms-2">last 30 days</span>
|
|
<div class="btn-seg ms-auto" id="usage-sort">
|
|
<button type="button" data-sort="calls" class="active">Calls</button>
|
|
<button type="button" data-sort="cost">Spend</button>
|
|
<button type="button" data-sort="tokens">Tokens</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body" id="usage-bars">
|
|
<div class="text-muted small">Loading…</div>
|
|
</div>
|
|
<div class="card-footer py-2">
|
|
<span class="text-muted" style="font-size:.7rem" id="usage-foot"></span>
|
|
</div>
|
|
</div>
|
|
</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 (Bootstrap native) -->
|
|
<!-- Per-model detail, same shape as proficiency.html's #cell-modal. -->
|
|
<div class="modal modal-blur fade" id="usage-modal" tabindex="-1" role="dialog" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="usage-modal-title">—</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="usage-modal-body"></div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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/models.html — model availability page
|
|
API base: relative (works under /admin/)
|
|
═══════════════════════════════════════════════════ */
|
|
|
|
const API = ''; // relative to /admin/
|
|
const SSE_URL = '/events/decisions'; // root-level endpoint
|
|
|
|
/* ── Toast (Bootstrap native via tabler wrapper) ── */
|
|
let _toastEl = null;
|
|
function toast(msg, type = 'info') {
|
|
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();
|
|
}
|
|
|
|
/* ── Fetch Wrapper ── */
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
SSE LIVE STREAM
|
|
═══════════════════════════════════════ */
|
|
let sseConn = null;
|
|
|
|
function connectSSE() {
|
|
try {
|
|
if (sseConn) { sseConn.close(); }
|
|
sseConn = new EventSource(SSE_URL);
|
|
|
|
navbarSse('connecting');
|
|
|
|
sseConn.onopen = () => {
|
|
navbarSse('live');
|
|
};
|
|
|
|
sseConn.onerror = () => {
|
|
navbarSse('offline', 'reconnecting');
|
|
};
|
|
} catch (e) {
|
|
navbarSse('offline');
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
MODEL AVAILABILITY TABLE
|
|
═══════════════════════════════════════ */
|
|
|
|
let _modelsCache = [];
|
|
|
|
/* ── Filtering and paging ──
|
|
The page shipped as one ungrouped table of every routable row with a
|
|
single show-deprecated switch, which is workable at 45 rows and not at
|
|
446. State lives in module scope rather than being re-read from the DOM
|
|
on every render, so a filter keystroke does not have to rebuild the
|
|
controls it is being typed into. */
|
|
let _modelPage = 1;
|
|
let _modelPageSize = 50;
|
|
|
|
function modelFilters() {
|
|
const val = id => (document.getElementById(id) || {}).value || '';
|
|
return {
|
|
q: val('mf-search').trim().toLowerCase(),
|
|
provider: val('mf-provider'),
|
|
tier: val('mf-tier'),
|
|
availability: document.getElementById('mf-availability')
|
|
? document.getElementById('mf-availability').value
|
|
: 'routable',
|
|
};
|
|
}
|
|
|
|
function matchesModelFilters(m, f) {
|
|
const avail = String(m.effective_availability || '');
|
|
// "routable" is the default because it answers the question the page is
|
|
// usually open for -- what can the router actually pick right now. The
|
|
// explicit statuses are for auditing an override.
|
|
if (f.availability === 'routable') {
|
|
if (avail === 'deprecated' || avail === 'stale') return false;
|
|
} else if (f.availability && avail !== f.availability) {
|
|
return false;
|
|
}
|
|
if (f.provider && String(m.provider || '') !== f.provider) return false;
|
|
if (f.tier && String(m.tier || '') !== f.tier) return false;
|
|
if (f.q && !String(m.model_id || '').toLowerCase().includes(f.q)) return false;
|
|
return true;
|
|
}
|
|
|
|
function refreshProviderOptions(models) {
|
|
const sel = document.getElementById('mf-provider');
|
|
if (!sel) return;
|
|
const chosen = sel.value;
|
|
const providers = [...new Set(models.map(m => String(m.provider || '')).filter(Boolean))].sort();
|
|
sel.innerHTML = '<option value="">All providers</option>'
|
|
+ providers.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
|
|
if (providers.includes(chosen)) sel.value = chosen;
|
|
}
|
|
|
|
function bindModelFilters() {
|
|
const rerender = (resetPage = true) => {
|
|
if (resetPage) _modelPage = 1;
|
|
renderModels(_modelsCache);
|
|
};
|
|
const search = document.getElementById('mf-search');
|
|
if (search) search.addEventListener('input', () => rerender());
|
|
['mf-provider', 'mf-tier', 'mf-availability'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener('change', () => rerender());
|
|
});
|
|
const size = document.getElementById('mf-page-size');
|
|
if (size) size.addEventListener('change', () => {
|
|
_modelPageSize = Number(size.value) || 50;
|
|
rerender();
|
|
});
|
|
const clear = document.getElementById('mf-clear');
|
|
if (clear) clear.addEventListener('click', () => {
|
|
if (search) search.value = '';
|
|
['mf-provider', 'mf-tier'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = '';
|
|
});
|
|
const av = document.getElementById('mf-availability');
|
|
if (av) av.value = 'routable';
|
|
rerender();
|
|
});
|
|
const prev = document.getElementById('mf-prev');
|
|
if (prev) prev.addEventListener('click', () => { _modelPage -= 1; renderModels(_modelsCache); });
|
|
const next = document.getElementById('mf-next');
|
|
if (next) next.addEventListener('click', () => { _modelPage += 1; renderModels(_modelsCache); });
|
|
}
|
|
|
|
/* ── Cell formatters ──
|
|
Everything below already ships in /admin/api/models; none of it needed an
|
|
API change. These are the four facts a routing decision actually turns on
|
|
-- the context ceiling that produces a 422, the completion price profiles
|
|
filter on, the proficiency term that is the only category-dependent part
|
|
of the ranking, and the capability gates that hard-filter a request. */
|
|
|
|
function humanCtx(n) {
|
|
if (n === null || n === undefined || n === 0) return '<span class="text-muted">-</span>';
|
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
|
return String(n);
|
|
}
|
|
|
|
function humanCost(v) {
|
|
if (v === null || v === undefined) return '<span class="text-muted">-</span>';
|
|
// Free rows are the whole point of the cheap profiles, so they get a word
|
|
// rather than "$0.00", which reads as a rounding artifact.
|
|
if (Number(v) === 0) return '<span class="cost-free">free</span>';
|
|
return `$${Number(v).toFixed(2)}`;
|
|
}
|
|
|
|
function proficiencyCell(prof) {
|
|
const entries = Object.entries(prof || {}).filter(([, v]) => typeof v === 'number');
|
|
if (!entries.length) return '<td class="num"><span class="text-muted">-</span></td>';
|
|
const mean = entries.reduce((a, [, v]) => a + v, 0) / entries.length;
|
|
// The mean is a summary of a per-category vector, so the breakdown has to
|
|
// stay reachable -- a single number here would otherwise imply the model
|
|
// is uniformly that good, which is the exact thing the proficiency table
|
|
// exists to disprove (deepseek-v4-flash: 1.00 coding, 0.33 tool use).
|
|
const detail = entries
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([k, v]) => `${k} ${v.toFixed(2)}`)
|
|
.join('\n');
|
|
return `<td class="num" title="${escapeHtml(detail)}">${mean.toFixed(2)}`
|
|
+ `<span class="prof-n">/${entries.length}</span></td>`;
|
|
}
|
|
|
|
function capabilityChips(m) {
|
|
const caps = [
|
|
['T', m.supports_tools, 'tools'],
|
|
['V', m.supports_vision, 'vision'],
|
|
['J', m.supports_json_mode, 'json mode'],
|
|
['R', m.supports_reasoning, 'reasoning'],
|
|
];
|
|
return caps.map(([letter, on, name]) =>
|
|
`<span class="cap-chip${on ? ' on' : ''}" title="${name}${on ? '' : ' not supported'}">${letter}</span>`
|
|
).join('');
|
|
}
|
|
|
|
function renderModels(models) {
|
|
_modelsCache = models || [];
|
|
const tbody = document.getElementById('model-tbody');
|
|
if (!models || !models.length) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center">No models</td></tr>';
|
|
return;
|
|
}
|
|
const f = modelFilters();
|
|
const eligible = models.filter(m => (m.access_level === 'public') || !m.access_level);
|
|
const matched = eligible.filter(m => matchesModelFilters(m, f));
|
|
|
|
// Counts name the window and the whole, the way the decisions page does:
|
|
// "31 of 446" is a filter result, "446" alone is a claim about the catalog.
|
|
const countEl = document.getElementById('model-count');
|
|
if (countEl) {
|
|
countEl.textContent = matched.length === eligible.length
|
|
? `${eligible.length} models`
|
|
: `${matched.length} of ${eligible.length} models`;
|
|
}
|
|
|
|
const pager = document.getElementById('model-pager');
|
|
if (!matched.length) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center">No models match the current filter</td></tr>';
|
|
if (pager) pager.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const totalPages = Math.max(1, Math.ceil(matched.length / _modelPageSize));
|
|
_modelPage = Math.min(Math.max(1, _modelPage), totalPages);
|
|
const start = (_modelPage - 1) * _modelPageSize;
|
|
const visible = matched.slice(start, start + _modelPageSize);
|
|
|
|
if (pager) {
|
|
// Hidden entirely on a single page -- a pager that can only say
|
|
// "Page 1 of 1" is furniture.
|
|
pager.style.display = totalPages > 1 ? 'flex' : 'none';
|
|
document.getElementById('model-page-info').textContent =
|
|
`Showing ${start + 1}-${start + visible.length} of ${matched.length}, page ${_modelPage} of ${totalPages}`;
|
|
document.getElementById('mf-prev').disabled = _modelPage <= 1;
|
|
document.getElementById('mf-next').disabled = _modelPage >= totalPages;
|
|
}
|
|
|
|
const html = visible.map(m => {
|
|
const availClass = { active: 'var(--tblr-success)', blocked: 'var(--tblr-danger)', deprecated: 'var(--tblr-danger)', stale: 'var(--tblr-warning)' }[m.effective_availability] || 'var(--tblr-secondary)';
|
|
const selectOpts = ['active','blocked','deprecated','stale'].map(a =>
|
|
`<option value="${a}"${m.effective_availability===a?' selected':''}>${a}</option>`
|
|
).join('');
|
|
const tierClass = `tier-${m.tier || '?'}`;
|
|
return `<tr data-model="${m.model_id}" data-provider="${m.provider}">
|
|
<td title="${escapeHtml(String(m.display_name || m.model_id))}">${escapeHtml(m.model_id)}</td>
|
|
<td>${escapeHtml(m.provider)}</td>
|
|
<td class="${tierClass}">${m.tier || '?'}</td>
|
|
<td class="num">${humanCtx(m.effective_context_window)}</td>
|
|
<td class="num">${humanCost(m.cost_per_1m_completion)}</td>
|
|
${proficiencyCell(m.proficiency)}
|
|
<td>${capabilityChips(m)}</td>
|
|
<td style="color:${availClass}"><span class="badge bg-secondary-subtle text-secondary">${escapeHtml(String(m.effective_availability))}</span></td>
|
|
<td><select class="form-select form-select-sm model-override" style="width:110px" onchange="updateAvailability('${escapeHtml(m.model_id)}','${escapeHtml(m.provider)}',this.value)">${selectOpts}</select><div class="inline-warning text-warning small d-none"></div></td>
|
|
</tr>`;
|
|
}).join('');
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
async function updateAvailability(modelId, provider, avail) {
|
|
// Don't post if already that value via dropdown
|
|
const body = { availability: avail };
|
|
if (avail === 'blocked') {
|
|
body.reason = 'set from Models dropdown';
|
|
}
|
|
const resp = await apiFetch(
|
|
`${API}api/models/${encodeURIComponent(modelId)}/${encodeURIComponent(provider)}/availability`,
|
|
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) }
|
|
);
|
|
const tr = document.querySelector(`tr[data-model="${modelId}"][data-provider="${provider}"]`);
|
|
const warningDiv = tr ? tr.querySelector('.inline-warning') : null;
|
|
if (resp && resp.warnings && resp.warnings.length) {
|
|
const warningText = resp.warnings.join('\n');
|
|
if (warningDiv) {
|
|
warningDiv.textContent = resp.warnings.join('\n');
|
|
warningDiv.classList.remove('d-none');
|
|
}
|
|
toast(resp.warnings.join(' '), 'error');
|
|
} else if (resp) {
|
|
if (warningDiv) warningDiv.classList.add('d-none');
|
|
toast('Availability updated', 'success');
|
|
} else {
|
|
toast('Update failed', 'error');
|
|
}
|
|
}
|
|
|
|
/* ── Usage view ────────────────────────────────────────────────────────────
|
|
*
|
|
* Moved here from the dashboard, which became a home page. It belongs with the
|
|
* Available view: both describe the same objects, one by what CAN route and
|
|
* one by what actually did.
|
|
*
|
|
* The bar VALUE follows the sort key. Before, bar length tracked the key while
|
|
* the label always printed cost and kWh, so "sort by calls" produced bars
|
|
* ordered by call count labelled in dollars.
|
|
*/
|
|
const USAGE_KEYS = {
|
|
calls: p => p.calls || 0,
|
|
cost: p => p.sum_cost_usd || 0,
|
|
tokens: p => (p.sum_prompt_tokens || 0) + (p.sum_completion_tokens || 0),
|
|
};
|
|
const USAGE_VALUE = {
|
|
calls: p => `<b>${Number(p.calls || 0).toLocaleString()}</b> calls`,
|
|
cost: p => `<b>${fmtUsage(p.sum_cost_usd, true)}</b>`,
|
|
tokens: p => `<b>${(((p.sum_prompt_tokens || 0) + (p.sum_completion_tokens || 0)) / 1e6).toFixed(1)}M</b> tokens`,
|
|
};
|
|
const USAGE_COLORS = ['#4ade80','#93c5fd','#67e8f9','#fbbf24','#d8b4fe','#a3e635'];
|
|
|
|
let _usageRows = [];
|
|
let _usageSort = 'calls';
|
|
let _usageShown = [];
|
|
let _usageModal = null;
|
|
|
|
/* Unknown is a dash, never a zero: a provider that reports no per-request cost
|
|
has not told us the cost was nothing. */
|
|
function fmtUsage(v, dollar) {
|
|
if (v == null) return '—';
|
|
const n = Number(v);
|
|
return dollar ? '$' + n.toFixed(4) : n.toFixed(5);
|
|
}
|
|
|
|
function renderUsageBars() {
|
|
const el = document.getElementById('usage-bars');
|
|
if (!el) return;
|
|
// ollama-local has its own accounting on the Quota page and also appears in
|
|
// energy_observations, so counting it here would count the same draw twice.
|
|
const rows = _usageRows.filter(p => p.provider !== 'ollama-local');
|
|
if (!rows.length) {
|
|
el.innerHTML = '<div class="text-muted small">No usage recorded in the last 30 days</div>';
|
|
return;
|
|
}
|
|
const key = USAGE_KEYS[_usageSort] || USAGE_KEYS.calls;
|
|
const valFn = USAGE_VALUE[_usageSort] || USAGE_VALUE.calls;
|
|
const sorted = rows.slice().sort((a, b) => key(b) - key(a)).slice(0, 15);
|
|
const max = Math.max(...sorted.map(key), 1e-9);
|
|
const total = rows.reduce((t, p) => t + key(p), 0);
|
|
_usageShown = sorted;
|
|
|
|
el.innerHTML = sorted.map((p, i) => {
|
|
const v = key(p);
|
|
const pct = Math.max((v / max) * 100, 1.5);
|
|
const share = total ? (v / total * 100) : 0;
|
|
const label = `${escapeHtml(String(p.model_id))} / ${escapeHtml(String(p.provider))}`;
|
|
return `<div class="usage-row" data-usage-row="${i}" role="button" tabindex="0"
|
|
title="${label} — click for energy, carbon and attribution">
|
|
<span class="usage-label" title="${label}">${label}</span>
|
|
<div class="usage-track"><div style="width:${pct}%;background:${USAGE_COLORS[i % USAGE_COLORS.length]}"></div></div>
|
|
<span class="usage-val">${valFn(p)}<span class="text-muted"> ${share.toFixed(1)}%</span></span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const unpriced = rows.filter(p => p.sum_cost_usd == null).length;
|
|
document.getElementById('usage-foot').innerHTML = unpriced
|
|
? `${unpriced} of ${rows.length} models report no per-request cost, so their spend shows as a dash rather than zero.`
|
|
: `${rows.length} models, all reporting per-request cost.`;
|
|
}
|
|
|
|
/* Energy, carbon, attribution and grid are inspection values, not ranking
|
|
values -- attribution alone spreads 750x between models and 1.8x within one.
|
|
They live behind a click, in the same modal pattern proficiency.html uses. */
|
|
function showUsageDetail(p) {
|
|
if (!_usageModal) _usageModal = new tabler.Modal(document.getElementById('usage-modal'));
|
|
document.getElementById('usage-modal-title').textContent = `${p.model_id} / ${p.provider}`;
|
|
const rows = [
|
|
['Calls', p.calls != null ? Number(p.calls).toLocaleString() : null],
|
|
['Spend', p.sum_cost_usd != null ? fmtUsage(p.sum_cost_usd, true) : null],
|
|
['Prompt tokens', p.sum_prompt_tokens ? Number(p.sum_prompt_tokens).toLocaleString() : null],
|
|
['Completion tokens', p.sum_completion_tokens ? Number(p.sum_completion_tokens).toLocaleString() : null],
|
|
['Cached prompt tokens', p.sum_cached_prompt_tokens ? Number(p.sum_cached_prompt_tokens).toLocaleString() : null],
|
|
['Mean completion length', p.avg_completion_tokens != null ? Math.round(p.avg_completion_tokens) + ' tokens' : null],
|
|
['Energy', p.sum_energy_kwh != null ? p.sum_energy_kwh.toFixed(5) + ' kWh' : null],
|
|
['Carbon', p.sum_carbon_g_co2eq != null ? p.sum_carbon_g_co2eq.toFixed(3) + ' gCO2eq' : null],
|
|
['Mean attribution', p.avg_attribution_ratio != null ? p.avg_attribution_ratio.toFixed(3) : null],
|
|
['Cost reported on', p.total_rows ? `${Number(p.cost_rows || 0).toLocaleString()} of ${Number(p.total_rows).toLocaleString()} calls` : null],
|
|
].filter(([, v]) => v != null);
|
|
|
|
document.getElementById('usage-modal-body').innerHTML =
|
|
'<dl class="detail-facts">'
|
|
+ rows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('')
|
|
+ '</dl>'
|
|
+ (p.avg_attribution_ratio != null
|
|
? `<div class="text-muted" style="font-size:.7rem;line-height:1.5;margin-top:.6rem">
|
|
Attribution is this request's share of a shared multi-tenant GPU pool.
|
|
It is a stable per-model property, not noise, and it is most of why two
|
|
models with similar power draw bill so differently.</div>`
|
|
: '');
|
|
_usageModal.show();
|
|
}
|
|
|
|
async function loadUsage() {
|
|
const snap = await apiFetch(`${API}api/snapshot`);
|
|
if (!snap) return;
|
|
_usageRows = snap.per_model || [];
|
|
renderUsageBars();
|
|
}
|
|
|
|
function setModelsView(view) {
|
|
const avail = view !== 'usage';
|
|
document.getElementById('view-available').hidden = !avail;
|
|
document.getElementById('view-usage').hidden = avail;
|
|
document.getElementById('models-subtitle').textContent = avail
|
|
? 'Available models, their tier, and live availability overrides'
|
|
: 'What each model actually did over the last 30 days';
|
|
try { localStorage.setItem('6krrt.modelsView', view); } catch (_) { /* private window */ }
|
|
if (!avail && !_usageRows.length) loadUsage();
|
|
}
|
|
|
|
function bindModelsViews() {
|
|
const seg = document.getElementById('models-views');
|
|
if (seg) {
|
|
seg.addEventListener('click', (ev) => {
|
|
const b = ev.target.closest('button[data-view]');
|
|
if (!b) return;
|
|
seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
|
|
setModelsView(b.getAttribute('data-view'));
|
|
});
|
|
}
|
|
const sort = document.getElementById('usage-sort');
|
|
if (sort) {
|
|
sort.addEventListener('click', (ev) => {
|
|
const b = ev.target.closest('button[data-sort]');
|
|
if (!b) return;
|
|
_usageSort = b.getAttribute('data-sort');
|
|
sort.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
|
|
renderUsageBars();
|
|
});
|
|
}
|
|
const bars = document.getElementById('usage-bars');
|
|
if (bars) {
|
|
const open = (ev) => {
|
|
const row = ev.target.closest('[data-usage-row]');
|
|
if (!row) return;
|
|
const p = _usageShown[Number(row.getAttribute('data-usage-row'))];
|
|
if (p) showUsageDetail(p);
|
|
};
|
|
bars.addEventListener('click', open);
|
|
bars.addEventListener('keydown', (ev) => {
|
|
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); open(ev); }
|
|
});
|
|
}
|
|
// Land on the view last used in this browser.
|
|
let saved = 'available';
|
|
try { saved = localStorage.getItem('6krrt.modelsView') || 'available'; } catch (_) { /* ignore */ }
|
|
if (saved === 'usage') {
|
|
const b = seg && seg.querySelector('[data-view="usage"]');
|
|
if (b) { seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b)); }
|
|
setModelsView('usage');
|
|
}
|
|
}
|
|
|
|
async function loadModels() {
|
|
// This page is the availability table, so it is the one caller that wants
|
|
// the unfiltered set -- the availability filter is what narrows it, and it
|
|
// needs the deprecated rows present to be able to show them. Every other
|
|
// consumer takes the endpoint's routable-only default on purpose.
|
|
const models = await apiFetch(`${API}api/models?include_unroutable=true`);
|
|
if (!models) return;
|
|
refreshProviderOptions(models);
|
|
renderModels(models);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
UTILITIES
|
|
═══════════════════════════════════════ */
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
/* LOAD_STALL_ROLLUP:BEGIN */
|
|
async function loadStallRollup(windowHours) {
|
|
const body = document.getElementById('stalls-body');
|
|
if (!body) return;
|
|
const data = await apiFetch(`${API}api/watchdog/rollup?hours=${windowHours}`);
|
|
if (!data || !data.length) {
|
|
body.innerHTML = '<div class="text-muted small">No stalled sessions in this window</div>';
|
|
return;
|
|
}
|
|
body.innerHTML = '<div class="table-responsive"><table class="table table-vcenter table-hover card-table"><thead><tr>'
|
|
+ '<th>Model</th><th>Provider</th><th class="num">Stalled</th><th class="num">$ Stalled</th><th class="num">Traffic</th><th>Availability</th><th></th>'
|
|
+ '</tr></thead><tbody>'
|
|
+ data.map(r => {
|
|
const model = escapeHtml(r.model_id || '');
|
|
const provider = escapeHtml(r.provider || '');
|
|
const stalled = r.stalled_sessions != null ? r.stalled_sessions : 0;
|
|
const cost = r.stalled_usd != null ? '$' + Number(r.stalled_usd).toFixed(2) : 'n/a';
|
|
const share = r.traffic_share != null ? (Number(r.traffic_share) * 100).toFixed(1) + '%' : '—';
|
|
const avail = escapeHtml(r.effective_availability || 'active');
|
|
const blocked = r.effective_availability === 'blocked';
|
|
const blockBtn = blocked ? '' : `<button class="btn btn-sm btn-outline-danger" style="font-size:.62rem;padding:1px 6px" data-model="${model}" data-provider="${provider}" data-reason="stalled ${stalled} sessions in ${windowHours}h" onclick="blockModelConfirm(event)">Block</button>`;
|
|
return '<tr>'
|
|
+ `<td class="text-nowrap">${model}</td>`
|
|
+ `<td>${provider}</td>`
|
|
+ `<td class="num">${stalled}</td>`
|
|
+ `<td class="num">${cost}</td>`
|
|
+ `<td class="num">${share}</td>`
|
|
+ `<td>${avail}</td>`
|
|
+ `<td class="text-end">${blockBtn}</td>`
|
|
+ '</tr>';
|
|
}).join('')
|
|
+ '</tbody></table></div>';
|
|
}
|
|
/* LOAD_STALL_ROLLUP:END */
|
|
|
|
/* BLOCK_CONFIRM:BEGIN */
|
|
function blockModelConfirm(event) {
|
|
const btn = event.currentTarget;
|
|
const model = btn.dataset.model;
|
|
const prov = btn.dataset.provider;
|
|
const reason = btn.dataset.reason;
|
|
if (!model) return;
|
|
const row = btn.closest('div.d-flex') || btn.closest('tr');
|
|
if (!row) return;
|
|
const existing = row.nextElementSibling;
|
|
if (existing && existing.classList.contains('inline-confirm-row')) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
const confirmRow = document.createElement('div');
|
|
confirmRow.className = 'inline-confirm-row d-flex align-items-center gap-2 py-1';
|
|
confirmRow.style.cssText = 'border-bottom:1px solid rgba(255,255,255,.05);background:rgba(220,38,38,.08);padding:4px 8px';
|
|
confirmRow.innerHTML = '<input type="text" class="form-control form-control-sm" style="font-size:.72rem;flex:1;min-width:120px" value="' + escapeHtml(reason) + '">'
|
|
+ '<button class="btn btn-sm btn-danger" style="font-size:.62rem;padding:1px 8px">Confirm</button>'
|
|
+ '<button class="btn btn-sm btn-outline-secondary" style="font-size:.62rem;padding:1px 8px">Cancel</button>';
|
|
|
|
const cancelBtn = confirmRow.querySelector('button:last-child');
|
|
cancelBtn.onclick = () => confirmRow.remove();
|
|
|
|
const confirmBtn = confirmRow.querySelector('button:nth-of-type(1)');
|
|
const input = confirmRow.querySelector('input');
|
|
confirmBtn.onclick = async () => {
|
|
const reason = input.value;
|
|
confirmBtn.disabled = true;
|
|
const resp = await apiFetch(
|
|
`${API}api/models/${encodeURIComponent(model)}/${encodeURIComponent(prov)}/availability`,
|
|
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ availability: 'blocked', reason: reason }) }
|
|
);
|
|
if (resp) {
|
|
toast('Model blocked', 'success');
|
|
confirmRow.remove();
|
|
// Refresh dependent views
|
|
if (typeof loadStallRollup === 'function') loadStallRollup(document.querySelector('#stall-window .active')?.dataset?.hours || 24);
|
|
if (typeof loadBlockedModels === 'function') loadBlockedModels();
|
|
if (typeof loadLoops === 'function') loadLoops();
|
|
if (typeof loadModels === 'function') loadModels();
|
|
} else {
|
|
confirmBtn.disabled = false;
|
|
confirmRow.querySelector('.inline-error')?.remove();
|
|
const err = document.createElement('div');
|
|
err.className = 'text-danger small inline-error';
|
|
err.textContent = 'Block failed';
|
|
confirmRow.appendChild(err);
|
|
}
|
|
};
|
|
|
|
row.after(confirmRow);
|
|
}
|
|
|
|
function unblockModelConfirm(modelId, provider) {
|
|
const btn = event.target;
|
|
const row = btn.closest('div.d-flex') || btn.closest('tr');
|
|
if (!row) return;
|
|
const existing = row.nextElementSibling;
|
|
if (existing && existing.classList.contains('inline-confirm-row')) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
const confirmRow = document.createElement('div');
|
|
confirmRow.className = 'inline-confirm-row d-flex align-items-center gap-2 py-1';
|
|
confirmRow.style.cssText = 'border-bottom:1px solid rgba(255,255,255,.05);background:rgba(220,38,38,.08);padding:4px 8px';
|
|
confirmRow.innerHTML = '<span class="small text-danger">Unblock this model?</span>'
|
|
+ '<button class="btn btn-sm btn-warning" style="font-size:.62rem;padding:1px 8px">Confirm</button>'
|
|
+ '<button class="btn btn-sm btn-outline-secondary" style="font-size:.62rem;padding:1px 8px">Cancel</button>';
|
|
|
|
const cancelBtn = confirmRow.querySelector('button:last-child');
|
|
cancelBtn.onclick = () => confirmRow.remove();
|
|
|
|
const confirmBtn = confirmRow.querySelector('button:nth-of-type(1)');
|
|
confirmBtn.onclick = async () => {
|
|
confirmBtn.disabled = true;
|
|
const resp = await apiFetch(
|
|
`${API}api/models/${encodeURIComponent(modelId)}/${encodeURIComponent(provider)}/availability`,
|
|
{ method:'DELETE' }
|
|
);
|
|
if (resp) {
|
|
toast('Model unblocked', 'success');
|
|
confirmRow.remove();
|
|
if (typeof loadBlockedModels === 'function') loadBlockedModels();
|
|
if (typeof loadModels === 'function') loadModels();
|
|
if (typeof loadStallRollup === 'function') loadStallRollup(document.querySelector('#stall-window .active')?.dataset?.hours || 24);
|
|
} else {
|
|
confirmBtn.disabled = false;
|
|
confirmRow.querySelector('.inline-error')?.remove();
|
|
const err = document.createElement('div');
|
|
err.className = 'text-danger small inline-error';
|
|
err.textContent = 'Unblock failed';
|
|
confirmRow.appendChild(err);
|
|
}
|
|
};
|
|
|
|
row.after(confirmRow);
|
|
}
|
|
/* BLOCK_CONFIRM:END */
|
|
|
|
/* ═══════════════════════════════════════
|
|
BLOCKED MODELS
|
|
═══════════════════════════════════════ */
|
|
|
|
/* BLOCKED_HELPERS:BEGIN */
|
|
function formatRelativeTime(iso8601) {
|
|
if (!iso8601) return '';
|
|
const now = new Date();
|
|
const date = new Date(iso8601);
|
|
const diffMs = now - date;
|
|
const diffSeconds = Math.floor(diffMs / 1000);
|
|
if (diffSeconds < 10) return 'just now';
|
|
if (diffSeconds < 60) return Math.floor(diffSeconds) + 's ago';
|
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
if (diffMinutes < 60) return diffMinutes + 'm ago';
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
if (diffHours < 24) return diffHours + 'h ago';
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
if (diffDays < 30) return diffDays + 'd ago';
|
|
return diffDays + 'd ago';
|
|
}
|
|
/* BLOCKED_HELPERS:END */
|
|
|
|
async function loadBlockedModels() {
|
|
const body = document.getElementById('blocked-models-body');
|
|
if (!body) return;
|
|
const data = await apiFetch(`${API}api/models/blocked`);
|
|
if (!data || !data.length) {
|
|
body.innerHTML = '<div class="text-muted small">No blocked models</div>';
|
|
return;
|
|
}
|
|
body.innerHTML = '<div class="table-responsive"><table class="table table-vcenter table-hover card-table"><thead><tr>'
|
|
+ '<th>Model</th><th>Provider</th><th>Reason</th><th>Blocked Since</th><th></th>'
|
|
+ '</tr></thead><tbody>'
|
|
+ data.map(r => {
|
|
const model = escapeHtml(r.model_id || '');
|
|
const provider = escapeHtml(r.provider || '');
|
|
const reason = escapeHtml(r.reason || '');
|
|
const since = r.blocked_since ? formatRelativeTime(r.blocked_since) : '—';
|
|
return '<tr>'
|
|
+ `<td class="text-nowrap">${model}</td>`
|
|
+ `<td>${provider}</td>`
|
|
+ `<td class="text-muted">${reason}</td>`
|
|
+ `<td class="text-muted">${since}</td>`
|
|
+ `<td class="text-end"><button class="btn btn-sm btn-outline-warning" style="font-size:.62rem;padding:1px 6px" onclick="unblockModelConfirm('${model}', '${provider}')">Unblock</button></td>`
|
|
+ '</tr>';
|
|
}).join('')
|
|
+ '</tbody></table></div>';
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
INIT
|
|
═══════════════════════════════════════ */
|
|
|
|
function bindStallWindow() {
|
|
const seg = document.getElementById('stall-window');
|
|
if (!seg) return;
|
|
seg.addEventListener('click', ev => {
|
|
const btn = ev.target.closest('button');
|
|
if (!btn || !btn.dataset.hours) return;
|
|
seg.querySelectorAll('button').forEach(b => b.classList.toggle('active', b === btn));
|
|
loadStallRollup(btn.dataset.hours);
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
// Bound once, before the first load: the controls are static markup, so
|
|
// they never get rebuilt and never need re-binding. (Rebuilding a control
|
|
// you are typing into is how the providers page ended up reversing text.)
|
|
bindModelFilters();
|
|
bindModelsViews();
|
|
bindStallWindow();
|
|
loadModels();
|
|
loadStallRollup(24);
|
|
loadBlockedModels();
|
|
connectSSE();
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|