1231 lines
60 KiB
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 — 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…</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…</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>© 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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 || '—'}</span><span>key from <b>${apiKeyEnv || '—'}</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 ? '▾' : '▸'}</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>
|