Files
6krrt/admin/frontend/models.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 &mdash; LLM Router Admin</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%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&hellip;</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&hellip;</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">&mdash;</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&hellip;</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&hellip;</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>&copy; 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">&mdash;</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 '&mdash;';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* 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) + '%' : '&mdash;';
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) : '&mdash;';
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>