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