Files
6krrt/admin/frontend/providers.html

1231 lines
60 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>Providers &mdash; LLM Router Admin</title>
<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%2219%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%2211%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22119%22%20cy%3D%22226%22%20r%3D%2211%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>
<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="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);
position: relative;
z-index: 1030;
}
.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)}
/* ── Scroll context: body scrolls, html does not. ── */
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-primary{background:rgba(59,130,246,.16)!important;border-color:rgba(96,165,250,.45)!important;color:#93c5fd!important}
.btn-primary:hover{background:rgba(59,130,246,.28)!important;border-color:rgba(96,165,250,.7)!important;color:#bfdbfe!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}
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 mascot sizing and filter guard. ── */
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
/* ── Speed lines. ── */
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
.brand-speedlines svg{filter:none!important}
@keyframes brand-dash{
0%{transform:translateX(-5px);opacity:.2}
55%{opacity:1}
100%{transform:translateX(4px);opacity:0}
}
.navbar-brand a:hover .brand-speedlines line{animation:brand-dash .75s ease-in infinite}
.navbar-brand a:hover .brand-speedlines line:nth-child(2){animation-delay:.12s}
.navbar-brand a:hover .brand-speedlines line:nth-child(3){animation-delay:.24s}
@media (prefers-reduced-motion: reduce){
.navbar-brand a:hover .brand-speedlines line{animation:none}
}
/* ── Provider card specifics. ── */
.provider-empty{color:var(--tblr-secondary);text-align:center;padding:2rem 1rem}
.provider-readonly-hint{color:var(--tblr-secondary);font-size:.72rem;display:inline-flex;align-items:center;gap:4px}
.provider-field-label{color:var(--tblr-secondary);font-size:.72rem}
.provider-field-val{font-size:.78rem;word-break:break-word}
.provider-row-actions{display:flex;align-items:center;gap:6px;justify-content:flex-end}
.provider-card .card-title{font-size:1rem}
.provider-source{font-size:.72rem}
/* ── Settings rows for add/edit form: key | meta | control. ── */
.provider-form-list{margin:0 -.5rem}
.provider-form-row{
display:grid;
grid-template-columns:minmax(0,1fr) auto 200px;
align-items:center;
gap:.75rem;
min-height:38px;
padding:.25rem .5rem;
border-left:2px solid transparent;
border-radius:6px;
}
.provider-form-row+.provider-form-row{border-top:1px solid rgba(255,255,255,.05)}
/* ── Allowlist folds ──
Each section is its own boxed region with a clickable header, because the
two lists ask different questions and act differently (remove vs add).
Run together with identical row styling, the only cue was button colour. */
.allowlist-section{
margin:.5rem .5rem 0;border:1px solid rgba(255,255,255,.09);border-radius:8px;
overflow:hidden;background:rgba(0,0,0,.14)
}
.allowlist-section:last-child{margin-bottom:.5rem}
.allowlist-section-head{
display:flex;align-items:center;gap:.5rem;width:100%;text-align:left;
padding:.45rem .6rem;border:0;background:rgba(255,255,255,.045);
color:var(--tblr-secondary);font-size:.7rem;font-weight:700;
letter-spacing:.07em;text-transform:uppercase
}
.allowlist-section-head:hover{background:rgba(255,255,255,.08);color:#e2e8f0}
.allowlist-section.is-open .allowlist-section-head{
border-bottom:1px solid rgba(255,255,255,.08)
}
.allowlist-caret{font-size:.7rem;opacity:.7;width:.7rem}
.allowlist-section-title{flex:1 1 auto}
.allowlist-section-count{
font-weight:600;letter-spacing:normal;text-transform:none;font-size:.72rem;
font-variant-numeric:tabular-nums;opacity:.85
}
/* The catalog fold gets a faint tint so the two boxes never read as one list
even when both are open and scrolled together. */
.allowlist-section[data-section="catalog"]{background:rgba(59,130,246,.045)}
.allowlist-section-body{padding-bottom:.15rem}
/* A catalog row must be the same height as an allowed row -- the two lists
sit in one panel and should read as the same kind of thing. An always-on
note input made every catalog row three lines tall, so the note is a
button that opens a popover instead. */
.allowlist-note-btn{
font-size:.72rem;padding:.05rem .45rem;max-width:11rem;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;color:var(--tblr-secondary)
}
.allowlist-note-btn.has-note{color:#93c5fd}
.allowlist-note-pop{
position:fixed;z-index:1080;display:flex;gap:.4rem;align-items:center;
padding:.5rem;border-radius:8px;
background:rgba(31,41,55,.97);border:1px solid rgba(255,255,255,.12);
box-shadow:0 12px 30px -12px rgba(0,0,0,.7)
}
.allowlist-note-pop input{width:18rem}
.provider-form-key{font-size:.82rem;line-height:1.25;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.provider-form-meta{font-size:.72rem;white-space:nowrap;color:var(--tblr-secondary);font-variant-numeric:tabular-nums}
.provider-form-control{display:flex;justify-content:flex-end;align-items:center;min-width:0}
.provider-form-control .form-control,
.provider-form-control .form-select{width:100%;font-variant-numeric:tabular-nums}
.provider-form-control .form-check{margin:0;padding:0;min-height:0;display:flex;justify-content:flex-end}
.provider-form-control .form-check-input{margin:0;float:none}
/* 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}
/* Independent stats in a row: spaced, not joined by a glyph. Each stands
alone so the eye can tell which number answers which question. See
plans/admin-design-standards.md, "No middot separators". */
.stat-set{display:inline-flex;flex-wrap:wrap;gap:.15rem .85rem;align-items:baseline}
.stat-set b{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">Providers</h2>
<div class="text-muted mt-1">Dispatch providers and their API endpoints</div>
<div class="text-muted mt-1" style="font-size:.75rem"><span data-icon="alert" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>Creating, editing, or deleting a provider writes to config.local.yaml. A service reload is required before dispatch sees the change.</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Global restart-required banner (shown after edits/deletes) -->
<div class="row row-cards" id="restart-banner-row" style="display:none">
<div class="col-12">
<div class="alert alert-warning" role="alert">
<span data-icon="alert" style="display:inline-flex;vertical-align:middle;margin-right:6px"></span>
A restart is required for provider changes to take effect.
</div>
</div>
</div>
<div class="row row-cards align-items-start">
<!-- Add provider card -->
<div class="col-xl-4">
<div class="card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="plus"></span>Add provider</h3>
</div>
<div class="card-body">
<div class="provider-form-list" id="add-provider-form">
<div class="provider-form-row">
<span class="provider-form-key" title="name">name</span>
<span class="provider-form-meta">unique id</span>
<span class="provider-form-control">
<input class="form-control form-control-sm" type="text" id="add-name" placeholder="e.g. openrouter" autocomplete="off">
</span>
</div>
<div class="provider-form-row">
<span class="provider-form-key" title="base_url">base_url</span>
<span class="provider-form-meta">http(s) URL</span>
<span class="provider-form-control">
<input class="form-control form-control-sm" type="text" id="add-base-url" placeholder="https://api.example.com/v1" autocomplete="off">
</span>
</div>
<div class="provider-form-row">
<span class="provider-form-key" title="api_key_env">api_key_env</span>
<span class="provider-form-meta">env var name</span>
<span class="provider-form-control">
<input class="form-control form-control-sm" type="text" id="add-api-key-env" placeholder="e.g. OPENROUTER_API_KEY" autocomplete="off">
</span>
</div>
<div class="provider-form-row">
<span class="provider-form-key" title="has_energy_telemetry">has_energy_telemetry</span>
<span class="provider-form-meta">SSE kWh comments</span>
<span class="provider-form-control">
<div class="form-check form-switch">
<input class="form-check-input toggle-input" type="checkbox" id="add-has-energy-telemetry">
</div>
</span>
</div>
<div class="provider-form-row">
<span class="provider-form-key" title="enabled">enabled</span>
<span class="provider-form-meta">available to dispatch</span>
<span class="provider-form-control">
<div class="form-check form-switch">
<input class="form-check-input toggle-input" type="checkbox" id="add-enabled" checked>
</div>
</span>
</div>
</div>
<div class="mt-3">
<button class="btn btn-success btn-sm" id="add-provider-btn" onclick="createProvider()">Create provider</button>
</div>
</div>
</div>
</div>
<!-- Provider list -->
<div class="col-xl-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="server"></span>Configured providers</h3>
</div>
<div class="card-body p-0">
<div id="provider-list">
<div class="provider-empty">Loading&hellip;</div>
</div>
</div>
</div>
</div>
</div><!-- /row row-cards -->
<div class="row row-cards mt-3">
<div class="col-12">
<div class="card" id="allowlist-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="lock"></span>Provider allowlists</h3>
</div>
<div class="card-body p-0">
<div id="allowlist-root">
<div class="provider-empty">Loading&hellip;</div>
</div>
</div>
</div>
</div>
</div><!-- /row row-cards -->
</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>
<!-- ═══ Delete confirm modal ═══ -->
<div class="modal modal-blur fade" id="delete-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">Delete provider</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete provider <strong id="delete-modal-name"></strong>?</p>
<p class="text-muted small">This writes to config.local.yaml; a service reload is required before dispatch sees the change.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="delete-confirm-btn">Delete</button>
</div>
</div>
</div>
</div>
<!-- Toast (Bootstrap native) -->
<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/providers.html — provider management
API base: relative (works under /admin/)
═══════════════════════════════════════════════════ */
const API = ''; // relative to /admin/
const SSE_URL = '/events/decisions'; // root-level endpoint
/* ═─ Inline icon helper (no tabler-icons webfont) ═─ */
function icon(name, size) {
const s = size || 18;
const svgs = {
alert: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
check: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
package: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
server: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
default: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`,
};
return svgs[name] || svgs.default;
}
function renderStaticIcons() {
document.querySelectorAll('[data-icon]').forEach(el => {
el.innerHTML = icon(el.dataset.icon);
});
}
/* ── Toast (Bootstrap native via tabler wrapper) ── */
let _toastEl = null;
function toast(msg, type = 'info') {
const bgClass = { success: 'text-bg-success', error: 'text-bg-danger', warning: 'text-bg-warning', 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 wrappers ── */
async function apiFetch(url, opts = {}) {
try {
const resp = await fetch(url, opts);
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
}
return await resp.json();
} catch (e) {
console.warn('API call failed:', url, e);
return null;
}
}
async function apiFetchDetail(url, opts = {}) {
const resp = await fetch(url, opts);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
const detail = body.detail || `${resp.status} ${resp.statusText}`;
throw new Error(detail);
}
return body;
}
/* ═══════════════════════════════════════
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');
}
}
/* ═══════════════════════════════════════
PROVIDERS
═══════════════════════════════════════ */
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function sourceBadge(inBase, inOverlay) {
if (inBase && inOverlay) return '<span class="badge bg-purple">overlay</span>';
if (inBase) return '<span class="badge bg-secondary">base config</span>';
if (inOverlay) return '<span class="badge bg-purple">overlay</span>';
return '<span class="badge bg-secondary">unknown</span>';
}
function boolSwitch(id, checked, onchange, disabled) {
return `<div class="form-check form-switch">
<input class="form-check-input toggle-input" type="checkbox" id="${id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''} onchange="${onchange}">
</div>`;
}
function renderProvider(p) {
const name = escapeHtml(String(p.name || ''));
const baseUrl = escapeHtml(String(p.base_url || ''));
const apiKeyEnv = escapeHtml(String(p.api_key_env || ''));
const hasEnergy = !!p.has_energy_telemetry;
const enabled = !!p.enabled;
const requireAllowlist = !!p.require_allowlist;
const inBase = !!p.in_base;
const inOverlay = !!p.in_overlay;
const isEditable = inOverlay;
const readonlyHint = !isEditable
? `<span class="provider-readonly-hint" title="Base-config providers cannot be edited or deleted"><span data-icon="lock"></span> read-only</span>`
: '';
const allowlistHint = requireAllowlist
? '<span class="badge bg-warning ms-2">allowlist required</span>'
: '';
const actions = !isEditable
? ''
: `<div class="provider-row-actions">
<button class="btn btn-outline-secondary btn-sm" title="Edit provider" data-action="edit" data-provider-name="${name}"><span data-icon="edit"></span></button>
<button class="btn btn-danger btn-sm" title="Delete provider" data-action="delete" data-provider-name="${name}"><span data-icon="trash"></span></button>
</div>`;
// View mode row.
let html = `<div class="provider-form-row provider-view" data-provider="${name}">
<span class="provider-form-key" title="${name}">
${name}
<span class="provider-source ms-2">${sourceBadge(inBase, inOverlay)} ${readonlyHint}${allowlistHint}</span>
</span>
<span class="provider-form-meta stat-set"><span>${baseUrl || '&mdash;'}</span><span>key from <b>${apiKeyEnv || '&mdash;'}</b></span></span>
<span class="provider-form-control">${actions}</span>
</div>
<div class="provider-form-row provider-view" data-provider="${name}">
<span class="provider-form-key text-muted">
<span class="provider-field-label me-3">energy telemetry ${hasEnergy ? 'on' : 'off'}</span>
<span class="provider-field-label me-3">enabled ${enabled ? 'yes' : 'no'}</span>
<span class="provider-field-label">allowlist ${requireAllowlist ? 'required' : 'off'}</span>
</span>
<span class="provider-form-meta"></span>
<span class="provider-form-control"></span>
</div>`;
// Edit mode form (hidden by default).
html += `<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
<span class="provider-form-key"><input class="form-control form-control-sm" type="text" value="${baseUrl}" data-field="base_url" placeholder="https://api.example.com/v1"></span>
<span class="provider-form-meta">base_url</span>
<span class="provider-form-control"></span>
</div>
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
<span class="provider-form-key"><input class="form-control form-control-sm" type="text" value="${apiKeyEnv}" data-field="api_key_env" placeholder="env var name"></span>
<span class="provider-form-meta">api_key_env</span>
<span class="provider-form-control"></span>
</div>
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
<span class="provider-form-key">has_energy_telemetry</span>
<span class="provider-form-meta">SSE kWh comments</span>
<span class="provider-form-control">${boolSwitch(`edit-energy-${name}`, hasEnergy, `toggleProviderField('${name}', 'has_energy_telemetry', this.checked)`, false)}</span>
</div>
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
<span class="provider-form-key">enabled</span>
<span class="provider-form-meta">available to dispatch</span>
<span class="provider-form-control">${boolSwitch(`edit-enabled-${name}`, enabled, `toggleProviderField('${name}', 'enabled', this.checked)`, false)}</span>
</div>
<div class="provider-form-row provider-edit" data-provider="${name}" style="display:none">
<span class="provider-form-key"></span>
<span class="provider-form-meta"></span>
<span class="provider-form-control">
<button class="btn btn-success btn-sm" onclick="saveProviderEdit('${name}')">Save</button>
<button class="btn btn-outline-secondary btn-sm ms-2" onclick="cancelProviderEdit('${name}')">Cancel</button>
</span>
</div>`;
return html;
}
function renderProviders(providers) {
const el = document.getElementById('provider-list');
if (!providers) {
el.innerHTML = '<div class="provider-empty">Could not load providers. Check the API.</div>';
return;
}
if (!providers.length) {
el.innerHTML = '<div class="provider-empty">No providers configured.</div>';
return;
}
el.innerHTML = providers.map(renderProvider).join('');
renderStaticIcons();
}
async function loadProviders() {
const providers = await apiFetch(`${API}api/providers`);
renderProviders(providers);
}
/* ═══════════════════════════════════════
CRUD ACTIONS
═══════════════════════════════════════ */
function showRestartBanner() {
const row = document.getElementById('restart-banner-row');
if (row) row.style.display = '';
}
function toggleProviderField(name, field, value) {
// Switch change is local-only until Save is pressed.
}
async function saveProviderEdit(name) {
const rows = document.querySelectorAll(`.provider-edit[data-provider="${CSS.escape(name)}"]`);
const body = { name: name };
rows.forEach(row => {
const input = row.querySelector('[data-field]');
if (input) {
const field = input.dataset.field;
body[field] = input.value;
}
const sw = row.querySelector('.form-check-input[type="checkbox"]');
if (sw && sw.id && sw.id.startsWith('edit-')) {
const field = sw.id.replace(`edit-`, '').replace(new RegExp(`-${name}$`), '');
// map switch field names to provider schema keys
const keyMap = {
energy: 'has_energy_telemetry',
enabled: 'enabled',
};
const key = keyMap[field];
if (key) body[key] = sw.checked;
}
});
try {
const result = await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
toast(result && result.message ? 'Provider updated' : 'Provider saved', 'success');
showRestartBanner();
if (result && result.message) {
toast(result.message, 'warning');
}
await loadProviders();
} catch (e) {
toast(e.message || 'Save failed', 'error');
}
}
function cancelProviderEdit(name) {
const viewRows = document.querySelectorAll(`.provider-view[data-provider="${CSS.escape(name)}"]`);
const editRows = document.querySelectorAll(`.provider-edit[data-provider="${CSS.escape(name)}"]`);
viewRows.forEach(r => r.style.display = '');
editRows.forEach(r => r.style.display = 'none');
}
function startProviderEdit(name) {
const viewRows = document.querySelectorAll(`.provider-view[data-provider="${CSS.escape(name)}"]`);
const editRows = document.querySelectorAll(`.provider-edit[data-provider="${CSS.escape(name)}"]`);
viewRows.forEach(r => r.style.display = 'none');
editRows.forEach(r => r.style.display = '');
}
let _deleteModal = null;
function getDeleteModal() {
if (!_deleteModal) {
_deleteModal = new tabler.Modal(document.getElementById('delete-modal'));
}
return _deleteModal;
}
function promptDelete(name) {
document.getElementById('delete-modal-name').textContent = name;
const btn = document.getElementById('delete-confirm-btn');
btn.onclick = () => doDelete(name);
getDeleteModal().show();
}
async function doDelete(name) {
getDeleteModal().hide();
try {
const result = await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' });
toast('Provider deleted', 'success');
showRestartBanner();
if (result && result.message) {
toast(result.message, 'warning');
}
await loadProviders();
} catch (e) {
toast(e.message || 'Delete failed', 'error');
}
}
/* ═══════════════════════════════════════
ALLOWLIST
═══════════════════════════════════════ */
const _allowlistState = {
providers: [],
allowlists: {}, // name -> [{model_id, note}]
catalogCache: {}, // name -> [row, ...]
expandedName: null,
catalogSearch: '',
catalogPage: 1,
catalogLoading: false,
// Which folds are open. Allowed starts open because "what is allowed" is
// the question the panel is for; the 425-row catalog starts folded so
// opening the manager does not dump a wall of rows you did not ask for.
open: { allowed: true, catalog: false },
// "<provider>::<model_id>" -> note typed but not yet submitted. Held in
// state rather than in the DOM so it survives a filter keystroke, a page
// change, and the body re-render both of those cause.
notes: {},
};
const CATALOG_PAGE_SIZE = 25;
function allowlistHeader(name, count, requireAllowlist) {
const status = requireAllowlist
? '<span class="badge bg-warning">require_allowlist</span>'
: '<span class="badge bg-secondary">allowlist not required</span>';
return `<div class="provider-form-row allowlist-header" data-provider="${escapeHtml(name)}" style="cursor:pointer">
<span class="provider-form-key">
<span data-icon="package" style="display:inline-flex;vertical-align:middle;margin-right:6px"></span>
${escapeHtml(name)}
<span class="provider-source ms-2">${status}</span>
</span>
<span class="provider-form-meta">${count} allowed</span>
<span class="provider-form-control">
<button class="btn btn-primary btn-sm" data-action="manage" data-provider-name="${escapeHtml(name)}">
<span data-icon="edit" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>
Manage
</button>
</span>
</div>`;
}
function allowlistTableRows(name) {
const all = _allowlistState.allowlists[name] || [];
// The filter sits at the top of the panel, so it filters the panel. A bar
// above two lists that narrows only the lower one reads as broken.
const q = _allowlistState.catalogSearch.trim().toLowerCase();
const rows = q
? all.filter(r => `${String(r.model_id || '')} ${String(r.note || '')}`
.toLowerCase().includes(q))
: all;
const keyBase = `allowlist-${escapeHtml(name)}`;
// No count here any more -- the fold header above carries it, and two
// counts a few pixels apart is how the old layout got confusing.
let html = `<div class="provider-form-row allowlist-table-head" data-provider="${escapeHtml(name)}" style="padding-top:2px;">
<span class="provider-form-key text-muted" style="font-size:.72rem">model_id</span>
<span class="provider-form-meta text-muted" style="font-size:.72rem">note</span>
<span class="provider-form-control"></span>
</div>`;
if (!all.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No models on the allowlist.</div>`;
return html;
}
if (!rows.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No allowed models match this filter.</div>`;
return html;
}
// Scrolls rather than paginates. The allowed set is bounded by what the
// operator chose (30 here) where the catalog is 425, and two pagers in one
// panel would be a puzzle rather than a control. Capping it is what keeps
// the catalog below reachable without scrolling past everything allowed.
// 7 rows exactly. Rows are 38px, so an arbitrary max-height (16rem = 6.7
// rows) leaves a half-row sliced at the bottom edge, which reads as a
// rendering fault rather than "there is more below".
html += `<div class="allowlist-allowed-list" style="max-height:266px;overflow:auto">`;
html += rows.map(r => {
const mid = escapeHtml(String(r.model_id || ''));
const note = escapeHtml(String(r.note || ''));
return `<div class="provider-form-row allowlist-row" data-provider="${escapeHtml(name)}" data-model-id="${mid}">
<span class="provider-form-key" title="${mid}">${mid}</span>
<span class="provider-form-meta">${note || '—'}</span>
<span class="provider-form-control">
<button class="btn btn-danger btn-sm" data-action="remove" data-provider-name="${escapeHtml(name)}" data-model-id="${mid}">
<span data-icon="trash" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>
Remove
</button>
</span>
</div>`;
}).join('');
html += `</div>`;
return html;
}
// The rows a provider's catalog contributes, after the filter. Split out from
// the markup so the pager and the list agree on one filtered set.
function filteredCatalog(name) {
const cached = _allowlistState.catalogCache[name];
if (!Array.isArray(cached)) return cached;
const q = _allowlistState.catalogSearch.trim().toLowerCase();
if (!q) return cached;
return cached.filter(r =>
`${String(r.model_id || '')} ${String(r.display_name || '')}`.toLowerCase().includes(q));
}
function allowlistCatalogRows(name) {
const keyBase = `allowlist-${escapeHtml(name)}-catalog`;
let html = '';
const cached = _allowlistState.catalogCache[name];
if (_allowlistState.catalogLoading && cached === undefined) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">Loading upstream catalog...</div>`;
} else if (cached === undefined) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">Upstream catalog unavailable.</div>`;
} else if (cached === null) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">Could not load the upstream catalog.</div>`;
} else if (!cached.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No upstream models to add.</div>`;
} else {
const allowed = new Set((_allowlistState.allowlists[name] || []).map(r => String(r.model_id)));
const filtered = filteredCatalog(name);
if (!filtered.length) {
html += `<div class="provider-empty" style="padding:.75rem 1rem;font-size:.8rem">No catalog rows match this filter.</div>`;
} else {
// Paginated: this list is 422 rows on OpenRouter, and a 260px scroller
// over all of them is not a list anyone can work through.
const totalPages = Math.max(1, Math.ceil(filtered.length / CATALOG_PAGE_SIZE));
const page = Math.min(Math.max(1, _allowlistState.catalogPage), totalPages);
_allowlistState.catalogPage = page;
const start = (page - 1) * CATALOG_PAGE_SIZE;
const pageRows = filtered.slice(start, start + CATALOG_PAGE_SIZE);
html += `<div class="allowlist-catalog-list">`;
html += pageRows.map((r, idx) => {
const mid = escapeHtml(String(r.model_id || ''));
const already = allowed.has(String(r.model_id));
const disabled = already ? 'disabled' : '';
const btnText = already ? 'Added' : 'Add';
const btnClass = already ? 'btn-success' : 'btn-primary';
const iconName = already ? 'check' : 'plus';
const cost = (r.cost_per_1m_completion !== undefined && r.cost_per_1m_completion !== null)
? `$${Number(r.cost_per_1m_completion).toFixed(2)}/1M`
: '—';
// One line, same three columns as an allowed row: id, note, action.
// The note input used to sit UNDER the id, stacking every catalog
// row three lines tall next to single-line allowed rows -- two lists
// in one panel that did not look like the same kind of thing. The
// note is now a button in the meta column, so it occupies the same
// slot the note text occupies on an allowed row, and shows the note
// once one is set.
const display = escapeHtml(String(r.display_name || ''));
const pendingNote = _allowlistState.notes[`${name}::${String(r.model_id)}`] || '';
return `<div class="provider-form-row allowlist-catalog-row" data-provider="${escapeHtml(name)}" data-model-id="${mid}">
<span class="provider-form-key" title="${display ? display + ' -- ' : ''}${mid}">
${mid}<span class="text-muted" style="margin-left:.5rem;font-size:.72rem">${cost}</span>
</span>
<span class="provider-form-meta">
<button class="btn btn-sm btn-ghost-secondary allowlist-note-btn${pendingNote ? ' has-note' : ''}"
data-action="note" data-provider-name="${escapeHtml(name)}" data-model-id="${mid}"
title="${pendingNote ? escapeHtml(pendingNote) : 'Add a note (optional)'}" ${disabled}>
${pendingNote ? escapeHtml(pendingNote) : 'Note'}
</button>
</span>
<span class="provider-form-control">
<button class="btn ${btnClass} btn-sm ${disabled}" data-action="add" data-provider-name="${escapeHtml(name)}" data-model-id="${mid}" ${disabled}>
<span data-icon="${iconName}" style="display:inline-flex;vertical-align:middle;margin-right:4px"></span>
${btnText}
</button>
</span>
</div>`;
}).join('');
html += `</div>`;
html += `<div class="provider-form-row allowlist-pager" data-provider="${escapeHtml(name)}">
<span class="provider-form-key text-muted" style="font-size:.75rem">
Showing ${start + 1}-${start + pageRows.length} of ${filtered.length}${
filtered.length !== _allowlistState.catalogCache[name].length
? ` (filtered from ${_allowlistState.catalogCache[name].length})` : ''}
</span>
<span class="provider-form-meta"></span>
<span class="provider-form-control">
<button class="btn btn-outline-secondary btn-sm me-2" data-action="page-prev"
data-provider-name="${escapeHtml(name)}" ${page <= 1 ? 'disabled' : ''}>Prev</button>
<span class="text-muted" style="font-size:.75rem;margin-right:.5rem">Page ${page} of ${totalPages}</span>
<button class="btn btn-outline-secondary btn-sm" data-action="page-next"
data-provider-name="${escapeHtml(name)}" ${page >= totalPages ? 'disabled' : ''}>Next</button>
</span>
</div>`;
}
}
return html;
}
function renderAllowlistPanel(name) {
const provider = _allowlistState.providers.find(p => String(p.name) === name);
const requireAllowlist = provider ? !!provider.require_allowlist : false;
const keyBase = `allowlist-${escapeHtml(name)}`;
// The filter lives in the header and is rendered ONCE. Everything the
// filter changes lives inside #<keyBase>-body, which is the only thing a
// keystroke re-renders -- see bindAllowlistSearch. Rebuilding the input
// itself is what made typing come out backwards: the element was
// destroyed on every keystroke and the replacement was focused with the
// caret at position 0, so each new character was inserted at the front.
let html = `<div class="card mt-2 allowlist-panel" data-provider="${escapeHtml(name)}">
<div class="card-header d-flex align-items-center gap-2 flex-wrap">
<h4 class="card-title mb-0 me-auto">Allowlist: ${escapeHtml(name)}</h4>
<div class="input-group input-group-sm" style="width:22rem;max-width:100%">
<span class="input-group-text"><span data-icon="search" style="display:inline-flex;vertical-align:middle"></span></span>
<input type="text" class="form-control" id="${keyBase}-search"
placeholder="Filter by model id or name"
value="${escapeHtml(_allowlistState.catalogSearch)}"
autocomplete="off">
</div>
<button class="btn btn-outline-secondary btn-sm" data-action="close" data-provider-name="${escapeHtml(name)}">Close</button>
</div>
<div class="card-body p-0" id="${keyBase}-body">${allowlistPanelBody(name)}</div>
</div>`;
return html;
}
// Everything below the header: the allowed entries and the paginated
// catalog. Re-rendered on its own so the filter input survives a keystroke.
/* Two sections, folded, each owning its own box.
Previously both lists ran together in one scroll column with identical row
styling, so the only cue for which list a row belonged to was whether its
button was red or blue -- and the allowed list's scroller cut a row in half
at the boundary, which read as a rendering fault rather than a divider.
Collapsing them separates the two questions: what is allowed, and what
could be. Counts stay in the headers so a folded section still reports
what it holds under the current filter. */
function allowlistSection(name, key, title, count, total, inner) {
const open = _allowlistState.open[key] !== false;
const filtered = count !== total;
return `<section class="allowlist-section${open ? ' is-open' : ''}" data-section="${key}">
<button type="button" class="allowlist-section-head" data-action="toggle-section"
data-provider-name="${escapeHtml(name)}" data-section-key="${key}"
aria-expanded="${open}">
<span class="allowlist-caret" aria-hidden="true">${open ? '&#9662;' : '&#9656;'}</span>
<span class="allowlist-section-title">${title}</span>
<span class="allowlist-section-count">${count}${filtered ? ` of ${total}` : ''}</span>
</button>
${open ? `<div class="allowlist-section-body">${inner}</div>` : ''}
</section>`;
}
function allowlistPanelBody(name) {
const all = _allowlistState.allowlists[name] || [];
const q = _allowlistState.catalogSearch.trim().toLowerCase();
const allowedShown = q
? all.filter(r => `${String(r.model_id || '')} ${String(r.note || '')}`
.toLowerCase().includes(q)).length
: all.length;
const cached = _allowlistState.catalogCache[name];
const cat = filteredCatalog(name);
const catShown = Array.isArray(cat) ? cat.length : 0;
const catTotal = Array.isArray(cached) ? cached.length : 0;
return allowlistSection(name, 'allowed', 'Allowed', allowedShown, all.length,
allowlistTableRows(name))
+ allowlistSection(name, 'catalog', 'Upstream catalog', catShown, catTotal,
allowlistCatalogRows(name));
}
function renderAllowlistBody(name) {
const el = document.getElementById(`allowlist-${escapeHtml(name)}-body`);
if (!el) return;
el.innerHTML = allowlistPanelBody(name);
renderStaticIcons();
}
function renderAllowlists() {
const root = document.getElementById('allowlist-root');
if (!_allowlistState.providers) {
root.innerHTML = '<div class="provider-empty">Could not load providers.</div>';
return;
}
const providers = _allowlistState.providers;
if (!providers.length) {
root.innerHTML = '<div class="provider-empty">No providers configured.</div>';
return;
}
let html = providers.map(p => {
const name = String(p.name);
const rows = _allowlistState.allowlists[name] || [];
const requireAllowlist = !!p.require_allowlist;
const expanded = _allowlistState.expandedName === name ? renderAllowlistPanel(name) : '';
return allowlistHeader(name, rows.length, requireAllowlist) + expanded;
}).join('');
root.innerHTML = html;
renderStaticIcons();
bindAllowlistSearch();
}
async function loadAllowlists() {
const providers = await apiFetch(`${API}api/providers`);
_allowlistState.providers = providers || [];
if (!_allowlistState.providers.length) {
renderAllowlists();
return;
}
const lists = {};
await Promise.all(_allowlistState.providers.map(async (p) => {
const name = String(p.name);
if (!p.require_allowlist) {
lists[name] = [];
return;
}
const rows = await apiFetch(`${API}api/providers/${encodeURIComponent(name)}/allowlist`);
lists[name] = rows || [];
}));
_allowlistState.allowlists = lists;
renderAllowlists();
}
function bindAllowlistSearch() {
const name = _allowlistState.expandedName;
if (!name) return;
const input = document.getElementById(`allowlist-${escapeHtml(name)}-search`);
if (!input || input._allowlistBound) return;
// Re-render the BODY, never the input. The previous version rebuilt the
// whole panel on every keystroke and then called .focus() on the freshly
// created input -- which restores focus but puts the caret at position 0,
// so every character after the first was inserted at the front and the
// field read backwards ("sdrawkcab txet"). Leaving the element alone means
// there is no caret to restore.
input.addEventListener('input', (e) => {
_allowlistState.catalogSearch = e.target.value;
_allowlistState.catalogPage = 1; // a new filter starts at the first page
renderAllowlistBody(name);
});
input._allowlistBound = true;
}
async function openAllowlistManager(name) {
_allowlistState.expandedName = name;
_allowlistState.catalogSearch = '';
_allowlistState.catalogPage = 1;
_allowlistState.catalogCache[name] = undefined;
await loadAllowlists();
// Load the catalog on open rather than behind a "Browse catalog preview"
// button. Managing an allowlist means choosing from the catalog, so making
// that a second deliberate step only hid the list one click deeper.
const provider = _allowlistState.providers.find(p => String(p.name) === name);
if (!provider || !provider.require_allowlist) return;
_allowlistState.catalogLoading = true;
renderAllowlistBody(name);
const rows = await apiFetch(`${API}api/providers/${encodeURIComponent(name)}/catalog-preview`);
_allowlistState.catalogLoading = false;
if (_allowlistState.expandedName !== name) return; // closed while loading
_allowlistState.catalogCache[name] = rows === null ? null : (rows || []);
renderAllowlistBody(name);
}
/* ── Note popover ──
A note is worth one line, occasionally. An always-visible input per row
cost three lines on every row whether or not anyone wanted to write one,
so it opens on demand instead. Deliberately not window.prompt(): nothing
else in this portal uses a native dialog, and one would block the page. */
let _noteOutsideHandler = null;
function closeNotePopover() {
const existing = document.getElementById('allowlist-note-pop');
if (existing) existing.remove();
if (_noteOutsideHandler) {
document.removeEventListener('mousedown', _noteOutsideHandler);
_noteOutsideHandler = null;
}
}
function openNotePopover(btn, name, modelId) {
closeNotePopover();
const key = `${name}::${modelId}`;
const pop = document.createElement('div');
pop.id = 'allowlist-note-pop';
pop.className = 'allowlist-note-pop';
pop.innerHTML = `
<input type="text" class="form-control form-control-sm" autocomplete="off"
placeholder="Note for ${escapeHtml(modelId)}">
<button class="btn btn-primary btn-sm" data-note-save>Save</button>`;
document.body.appendChild(pop);
const input = pop.querySelector('input');
input.value = _allowlistState.notes[key] || '';
// Positioned after append so offsetWidth/Height are real, and clamped so a
// row near the bottom or the right edge does not open off-screen.
const rect = btn.getBoundingClientRect();
const top = rect.bottom + 6 + pop.offsetHeight > window.innerHeight
? rect.top - pop.offsetHeight - 6
: rect.bottom + 6;
pop.style.top = `${Math.max(8, top)}px`;
pop.style.left = `${Math.max(8, Math.min(rect.right - pop.offsetWidth,
window.innerWidth - pop.offsetWidth - 8))}px`;
input.focus();
input.select();
const commit = () => {
const value = input.value.trim();
if (value) _allowlistState.notes[key] = value;
else delete _allowlistState.notes[key];
closeNotePopover();
renderAllowlistBody(name);
};
pop.querySelector('[data-note-save]').addEventListener('click', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); closeNotePopover(); }
});
_noteOutsideHandler = (e) => { if (!pop.contains(e.target)) closeNotePopover(); };
// Deferred: the click that opened this popover is still propagating, and
// binding synchronously would let it close itself immediately.
setTimeout(() => document.addEventListener('mousedown', _noteOutsideHandler), 0);
}
function setCatalogPage(name, delta) {
closeNotePopover();
_allowlistState.catalogPage = Math.max(1, _allowlistState.catalogPage + delta);
renderAllowlistBody(name);
}
function closeAllowlistManager(name) {
_allowlistState.expandedName = null;
_allowlistState.catalogSearch = '';
_allowlistState.catalogPage = 1;
delete _allowlistState.catalogCache[name];
renderAllowlists();
}
async function addAllowlistEntry(name, modelId) {
const key = `${name}::${modelId}`;
const note = _allowlistState.notes[key] || '';
try {
await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}/allowlist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId, note: note || null }),
});
toast('Model added to allowlist', 'success');
delete _allowlistState.notes[key]; // it is on the entry now, not pending
// Keep the filter and the page. Adding used to clear the search and
// refetch the whole catalog, so adding several models from one search
// meant retyping it every time -- and the upstream catalog has not
// changed just because our allowlist did.
await refreshOneAllowlist(name);
renderAllowlistBody(name);
} catch (e) {
toast(e.message || 'Add failed', 'error');
}
}
// Refresh a single provider's allowlist without touching the catalog cache,
// the filter or the page.
async function refreshOneAllowlist(name) {
const rows = await apiFetch(`${API}api/providers/${encodeURIComponent(name)}/allowlist`);
_allowlistState.allowlists[name] = rows || [];
}
async function removeAllowlistEntry(name, modelId) {
try {
await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}/allowlist/${encodeURIComponent(modelId)}`, {
method: 'DELETE',
});
toast('Model removed from allowlist', 'success');
// Same as add: keep the filter and page, and re-render the body only so
// the search input is not rebuilt out from under the operator.
await refreshOneAllowlist(name);
if (_allowlistState.expandedName === name) renderAllowlistBody(name);
else renderAllowlists();
} catch (e) {
toast(e.message || 'Remove failed', 'error');
}
}
function handleAllowlistClick(e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.providerName;
const modelId = btn.dataset.modelId;
if (action === 'note') { openNotePopover(btn, name, modelId); return; }
closeNotePopover();
if (action === 'toggle-section') {
const key = btn.dataset.sectionKey;
_allowlistState.open[key] = _allowlistState.open[key] === false;
renderAllowlistBody(name);
return;
}
if (action === 'manage') openAllowlistManager(name);
else if (action === 'close') closeAllowlistManager(name);
else if (action === 'add') addAllowlistEntry(name, modelId);
else if (action === 'remove') removeAllowlistEntry(name, modelId);
else if (action === 'page-prev') setCatalogPage(name, -1);
else if (action === 'page-next') setCatalogPage(name, 1);
}
async function createProvider() {
const name = document.getElementById('add-name').value.trim();
const baseUrl = document.getElementById('add-base-url').value.trim();
const apiKeyEnv = document.getElementById('add-api-key-env').value.trim();
const hasEnergyTelemetry = document.getElementById('add-has-energy-telemetry').checked;
const enabled = document.getElementById('add-enabled').checked;
if (!name) {
toast('Provider name is required', 'error');
return;
}
if (!baseUrl) {
toast('Base URL is required', 'error');
return;
}
const body = {
name: name,
base_url: baseUrl,
api_key_env: apiKeyEnv || null,
has_energy_telemetry: hasEnergyTelemetry,
enabled: enabled,
};
try {
const result = await apiFetchDetail(`${API}api/providers/${encodeURIComponent(name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
toast('Provider created', 'success');
showRestartBanner();
if (result && result.message) {
toast(result.message, 'warning');
}
document.getElementById('add-name').value = '';
document.getElementById('add-base-url').value = '';
document.getElementById('add-api-key-env').value = '';
document.getElementById('add-has-energy-telemetry').checked = false;
document.getElementById('add-enabled').checked = true;
await loadProviders();
} catch (e) {
toast(e.message || 'Create failed', 'error');
}
}
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
function init() {
renderStaticIcons();
document.getElementById('provider-list').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const name = btn.dataset.providerName;
if (btn.dataset.action === 'edit') startProviderEdit(name);
else if (btn.dataset.action === 'delete') promptDelete(name);
});
document.getElementById('allowlist-root').addEventListener('click', handleAllowlistClick);
loadProviders();
loadAllowlists();
connectSSE();
}
init();
</script>
</body>
</html>