Files
6krrt/admin/frontend/profiles.html

1390 lines
72 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>Profiles &mdash; LLM Router Admin</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
<link rel="stylesheet" href="navbar.css">
<script src="navbar.js"></script>
<style>
/* ── Liquid glass: soft gradient wash behind everything so the blur has
something to catch, then translucent+blurred navbar/cards on top. Kept
subtle — this is a working dashboard, not a marketing page. ── */
body{
background:
radial-gradient(900px circle at 6% -8%, rgba(245,158,11,.10), transparent 55%),
radial-gradient(800px circle at 94% -6%, rgba(59,130,246,.10), transparent 50%),
radial-gradient(760px circle at 50% 108%, rgba(139,92,246,.07), transparent 55%),
#111827;
background-attachment: fixed;
}
header.navbar{
background: rgba(31,41,55,.6)!important;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border-bottom: 1px solid rgba(255,255,255,.06);
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{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}
}
/* -- Profiles: a list plus a detail pane, NOT a grid of cards.
Cards were removed deliberately. A ~340px card had to hold a name, an
icon, admitted/interactive counts, a source badge, a read-only hint and
up to three action buttons, and no amount of spacing kept those six from
colliding -- each fix just moved the collision. The list column carries
one line per profile; the detail pane gets the full width, so nothing
competes for the same row. -- */
/* Both panes stretch to a shared height so this reads as a two-pane layout
rather than two boxes floating in an empty page -- with five profiles the
content alone would fill about a quarter of a tall viewport. */
#profiles-layout{display:grid;grid-template-columns:minmax(230px,270px) 1fr;gap:1.25rem;
align-items:stretch;max-width:1180px;margin:0 auto;min-height:min(62vh,560px)}
@media (max-width:767.98px){#profiles-layout{grid-template-columns:1fr;min-height:0}}
/* -- Master: the profile list -- */
#profile-list{display:flex;flex-direction:column;gap:1px;padding:6px;border-radius:10px;
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)}
.profile-list-item{display:flex;align-items:center;gap:.55rem;width:100%;text-align:left;
padding:.5rem .6rem;border:0;border-radius:7px;background:transparent;color:inherit;cursor:pointer;transition:background .12s ease}
.profile-list-item:hover{background:rgba(255,255,255,.05)}
.profile-list-item.active{background:rgba(255,255,255,.10);box-shadow:inset 2px 0 0 rgba(96,165,250,.85)}
.profile-list-item>[data-icon]{flex:0 0 auto;opacity:.75;display:inline-flex}
.profile-list-item.active>[data-icon]{opacity:1;color:#93c5fd}
.profile-list-text{min-width:0;display:flex;flex-direction:column;line-height:1.25}
.profile-list-name{font-weight:600;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-list-sub{font-size:.72rem;color:var(--tblr-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-list-flag{flex:0 0 auto;margin-left:auto;display:inline-flex;opacity:.55}
/* -- Detail pane -- */
#profile-detail{border-radius:10px;padding:1.35rem 1.5rem;
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)}
.profile-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap}
.profile-detail-title{display:flex;align-items:center;gap:.6rem;min-width:0}
.profile-detail-title h2{margin:0;font-size:1.35rem;font-weight:600;letter-spacing:-.01em}
.profile-detail-title>[data-icon]{display:inline-flex;opacity:.8;color:#93c5fd}
.profile-detail-meta{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;margin-top:.4rem}
.profile-detail-section{margin-top:1.5rem}
.profile-detail-section-label{font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
color:var(--tblr-secondary);opacity:.75;margin-bottom:.6rem}
/* Stats read as labelled figures, not headline numbers -- what distinguishes
a profile is its filters, so the counts stay secondary. */
.profile-figures{display:flex;gap:2.25rem;flex-wrap:wrap}
.profile-figure-val{font-size:1.5rem;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
.profile-figure-label{font-size:.72rem;color:var(--tblr-secondary);margin-top:.15rem}
/* Definition rows: fixed label column, so long snake_case keys never push
into the value. <wbr> from breakableLabel() handles the wrap points. */
.profile-def-row{display:grid;grid-template-columns:minmax(0,15rem) 1fr;gap:.75rem;
padding:.5rem 0;border-top:1px solid rgba(255,255,255,.05)}
.profile-def-row:first-of-type{border-top:0}
.profile-field-label{color:var(--tblr-secondary);font-size:.82rem;font-weight:600;letter-spacing:.01em;overflow-wrap:break-word}
.profile-field-val{color:rgba(255,255,255,.88);font-size:.92rem;overflow-wrap:break-word}
/* Inline editor: same label column as the read-only rows, so switching into
edit mode does not shift the fields sideways. */
.profile-edit-grid{display:grid;grid-template-columns:minmax(0,15rem) 1fr;gap:.6rem .75rem;align-items:center}
.profile-edit-grid>label{margin:0;align-self:center}
/* The picker is the tallest control in the grid; its label belongs at the top
of it rather than floating in the middle. */
.profile-edit-grid>label:last-of-type{align-self:start;padding-top:.4rem}
.profile-edit-grid .profile-picker{align-self:start}
@media (max-width:575.98px){.profile-edit-grid{grid-template-columns:1fr;gap:.25rem}}
/* Admitted models, rendered rather than hidden in a title= tooltip. The API
already returns the whole array; truncating it to 8 names on hover was the
only way to read it, which is not a way to read it. */
.profile-model-chips{display:flex;flex-wrap:wrap;gap:.35rem;max-height:13rem;overflow-y:auto;
padding:.15rem .15rem .35rem 0}
.profile-model-chip{font-size:.76rem;line-height:1.2;padding:.2rem .45rem;border-radius:4px;
background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.07);
color:rgba(255,255,255,.82);font-variant-numeric:tabular-nums;white-space:nowrap}
.profile-model-chip.is-local{border-color:rgba(147,197,253,.35);color:#bfdbfe}
.profile-model-empty{color:var(--tblr-secondary);font-size:.82rem}
/* Dual-pane picker. Selected is its own list, so what is chosen is always on
screen -- the single 446-option select put a real selection at index 72
and showed six rows, which read as nothing selected at all. */
.profile-picker-bar{display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem}
.profile-picker-bar input{flex:1 1 auto;min-width:0}
.profile-picker-panes{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}
@media (max-width:767.98px){.profile-picker-panes{grid-template-columns:1fr}}
.profile-pick-pane{border:1px solid rgba(255,255,255,.08);border-radius:6px;
background:rgba(0,0,0,.16);overflow:hidden;display:flex;flex-direction:column}
.profile-pick-head{display:flex;align-items:center;gap:.4rem;padding:.35rem .55rem;
font-size:.7rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
color:var(--tblr-secondary);background:rgba(255,255,255,.035);
border-bottom:1px solid rgba(255,255,255,.06)}
.profile-pick-head span{font-variant-numeric:tabular-nums;font-weight:600;opacity:.8;
letter-spacing:normal;text-transform:none}
.profile-pick-clear{margin-left:auto;padding:0;font-size:.7rem;text-transform:none;
letter-spacing:normal;text-decoration:none}
.profile-pick-clear:disabled{opacity:.35}
.profile-pick-list{list-style:none;margin:0;padding:.25rem;max-height:15rem;overflow-y:auto}
.profile-pick-item{display:flex;align-items:baseline;gap:.5rem;width:100%;text-align:left;
background:none;border:0;border-radius:4px;padding:.22rem .4rem;
color:rgba(255,255,255,.86);font-size:.78rem;line-height:1.25}
.profile-pick-item:hover{background:rgba(255,255,255,.07)}
.profile-pick-id{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}
.profile-pick-meta{flex:0 0 auto;font-size:.68rem;color:var(--tblr-secondary);opacity:.8}
.profile-pick-item-stale .profile-pick-id,
.profile-pick-item-stale .profile-pick-meta{color:#fbbf24}
.profile-pick-empty{padding:.5rem .45rem;font-size:.74rem;color:var(--tblr-secondary)}
/* The "default" tag in the list. Quiet enough not to compete with the
zero-admit warning, loud enough to answer "which one is live?" at a
glance -- that being the only fact on this page every client depends on. */
.profile-list-default{
margin-left:.4rem;font-size:.62rem;font-weight:600;letter-spacing:.04em;
text-transform:uppercase;padding:.05rem .3rem;border-radius:3px;
background:rgba(59,130,246,.18);color:#93c5fd;vertical-align:middle
}
.profile-admit-badge{font-variant-numeric:tabular-nums;white-space:nowrap;font-size:.82rem}
.profile-coverage-info{display:inline-flex;align-items:center;margin-left:4px;opacity:.6;cursor:help;vertical-align:middle}
.profile-coverage-info:hover{opacity:1}
.empty{color:var(--tblr-secondary);text-align:center;padding:2rem 1rem}
.profile-readonly-hint{color:var(--tblr-secondary);font-size:.72rem;display:inline-flex;align-items:center;gap:4px}
.profile-actions{display:flex;align-items:center;gap:6px;flex:0 0 auto}
/* Icon + label inside an action button need their own gap -- the inline SVG
sits flush against the text otherwise ("[icon]Duplicate"). */
.profile-actions .btn{display:inline-flex;align-items:center;gap:.4rem}
.profile-actions .btn>[data-icon]{display:inline-flex}
/* ── Modal glass treatment ── */
.modal-content{
background: rgba(31,41,55,.75);
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
border: 1px solid rgba(255,255,255,.09);
box-shadow: 0 20px 50px -20px rgba(0,0,0,.6);
}
.modal-backdrop{background:#000}
.modal-backdrop.show{opacity:.6}
/* ── Model multi-select compact height ── */
#profile-allowed-models .profile-pick-list{max-height:17rem}
/* 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}
</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">Profiles</h2>
<div class="text-muted mt-1">Routing profiles and what each admits under the live catalog</div>
<div class="text-muted mt-1" style="font-size:.75rem">Creating, editing, or deleting a config profile writes to config.local.yaml and applies to the next request.</div>
</div>
<div class="col-auto">
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#profile-modal" onclick="openCreateModal()">
<span data-icon="plus"></span> New profile
</button>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div id="profiles-layout">
<aside id="profile-list" aria-label="Routing profiles"></aside>
<section id="profile-detail" aria-live="polite">
<div class="empty">Loading&hellip;</div>
</section>
</div><!-- /profiles-layout -->
</div><!-- /container-xl -->
</div><!-- /page-body -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<span class="footer-bits"><span>&copy; 2026 adLee</span><span>6krrt, local LLM model router</span><span>admin</span></span>
</div>
</footer>
</div>
</div>
<!-- ═══ Create/Edit profile modal ═══ -->
<div class="modal modal-blur fade" id="profile-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="profile-modal-title">New profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="profile-form" novalidate>
<input type="hidden" id="profile-original-name">
<div class="mb-3">
<label for="profile-name" class="form-label">Profile name</label>
<input type="text" class="form-control" id="profile-name" required autocomplete="off">
<div class="invalid-feedback">A profile name is required.</div>
</div>
<div class="mb-3">
<label for="profile-provider" class="form-label">Provider</label>
<input type="text" class="form-control" id="profile-provider" placeholder="e.g. ollama-local">
<div class="form-text text-muted">Optional. Must match a catalog provider name such as <code>ollama-local</code>.</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label for="profile-min-tier" class="form-label">Min tier</label>
<select class="form-select" id="profile-min-tier">
<option value="">any</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
<div class="col-6">
<label for="profile-max-tier" class="form-label">Max tier</label>
<select class="form-select" id="profile-max-tier">
<option value="">any</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
</div>
<div class="mb-3">
<label for="profile-latency" class="form-label">Latency tolerance</label>
<select class="form-select" id="profile-latency">
<option value="">none</option>
<option value="interactive">interactive</option>
<option value="batch">batch</option>
</select>
</div>
<div class="mb-3">
<label for="profile-max-cost" class="form-label">Max cost per 1M completion (USD)</label>
<input type="number" class="form-control" id="profile-max-cost" min="0" step="0.01" placeholder="0.00">
</div>
<div class="mb-3">
<label class="form-label">Allowed model IDs</label>
<div class="profile-picker" id="profile-allowed-models">
<div class="profile-picker-bar">
<input type="search" class="form-control form-control-sm" data-pick-search
placeholder="Filter models" autocomplete="off" aria-label="Filter available models">
<button type="button" class="btn btn-outline-secondary btn-sm" data-pick="add-all">Add all</button>
</div>
<div class="profile-picker-panes">
<div class="profile-pick-pane">
<div class="profile-pick-head">Available <span data-count="available">0</span></div>
<ul class="profile-pick-list" data-pane="available"></ul>
</div>
<div class="profile-pick-pane">
<div class="profile-pick-head">Selected <span data-count="selected">0</span>
<button type="button" class="btn btn-link btn-sm profile-pick-clear" data-pick="clear">Clear</button>
</div>
<ul class="profile-pick-list" data-pane="selected"></ul>
</div>
</div>
</div>
</div>
</form>
</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-success btn-sm" id="profile-save-btn" onclick="saveProfile()">Save profile</button>
</div>
</div>
</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 profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Delete profile <strong id="delete-modal-name"></strong>?</p>
<p class="text-muted small">This writes to config.local.yaml and takes effect on the next request.</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/profiles.html — routing profiles page
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 = {
profile: `<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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></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>`,
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>`,
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>`,
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>`,
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>`,
copy: `<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="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
clock: `<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="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
'map-pin': `<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="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`,
'dollar-sign': `<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="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>`,
shield: `<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
};
return svgs[name] || svgs.default;
}
/* -- Select a per-profile card-header icon based on definition filters.
Precedence (first match wins):
1. latency_tolerance set -> clock (time/latency)
2. max_cost_per_1m_completion set -> dollar-sign (cost)
3. min_tier / max_tier set -> shield (tier/scope)
4. provider set OR category-gated -> map-pin (provider/locality)
5. no non-null filters -> profile (generic default) -- */
function profileIcon(def, cov) {
def = def || {};
if (def.latency_tolerance != null) return 'clock';
if (def.max_cost_per_1m_completion != null) return 'dollar-sign';
if (def.min_tier != null || def.max_tier != null) return 'shield';
if (def.provider != null) return 'map-pin';
if (cov && cov.admitting > 0 && cov.admitting < cov.total) return 'map-pin';
return 'profile';
}
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 Wrapper ── */
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;
}
}
/* ── Fetch with explicit error detail (does not swallow) ── */
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');
}
}
/* ═══════════════════════════════════════
PROFILE CARDS
═══════════════════════════════════════ */
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function sourceBadge(source) {
if (source === 'builtin') return '<span class="badge bg-secondary">builtin</span>';
if (source === 'config') return '<span class="badge bg-purple">config</span>';
return `<span class="badge bg-secondary">${escapeHtml(String(source))}</span>`;
}
// The admitted set is the answer to "what does this profile actually get?",
// so it is rendered. It used to live only in a title= tooltip truncated to
// eight names, which meant the other 32 of `default`'s 40 were unreadable
// even though /admin/api/profiles returns every one of them.
function renderAdmittedModels(models) {
if (!models || !models.length) {
return `<div class="profile-model-empty">No models admitted under the live catalog.</div>`;
}
const chips = models
.map(m => `<span class="profile-model-chip">${escapeHtml(String(m))}</span>`)
.join('');
return `<div class="profile-model-chips">${chips}</div>`;
}
// Long snake_case keys (max_cost_per_1m_completion) have no natural CSS
// break point, so the browser's default overflow-wrap splits mid-word. <wbr>
// after each underscore gives it a real break opportunity at a boundary
// that still reads correctly, without disabling wrapping (which would just
// let the text overflow the column instead).
function breakableLabel(key) {
return escapeHtml(key).split('_').join('_<wbr>');
}
function formatDefinition(def) {
if (!def || typeof def !== 'object' || Array.isArray(def)) {
return `<div class="text-muted small">No definition</div>`;
}
const SKIP = new Set(['name']);
const keys = Object.keys(def).filter(k => def[k] != null && !SKIP.has(k));
if (!keys.length) return `<div class="text-muted small">No filters set</div>`;
const rows = keys.map(key => {
let val = def[key];
if (Array.isArray(val)) {
val = val.length ? val.join(', ') : '-';
} else if (typeof val === 'object') {
try { val = JSON.stringify(val); } catch (_) { val = String(val); }
}
return `<div class="profile-def-row">
<div class="profile-field-label">${breakableLabel(key)}</div>
<div class="profile-field-val">${escapeHtml(String(val))}</div>
</div>`;
}).join('');
return rows;
}
/* -- Master/detail state. The list is the index, the pane is the record;
selection survives a reload (SSE refreshes call renderProfiles again) as
long as the selected profile still exists. -- */
let _profilesCache = [];
let _selectedProfile = null;
// Inline edit lives in the detail pane. _editingOriginalName is the name the
// profile had when editing started -- the update endpoint is keyed on it, so
// renaming a profile still targets the right record.
let _editing = false;
let _editingOriginalName = null;
// Whatever the server said about the last write, verbatim.
//
// This used to be a boolean the page set to true after every save, rendering
// "A restart is required before dispatch sees this change." Both write paths
// stopped needing one: `_persist_profile` mirrors a saved definition into the
// running `cfg.profiles`, and POST routing.default_profile applies the new
// default to the live `cfg` -- and each endpoint returns a message saying so.
// The page was overriding both with a claim of its own, so it told operators
// to bounce a service that had already picked the change up.
//
// Reporting the server's own message means the two cannot drift again: if a
// future write genuinely does need a restart, the endpoint says so and this
// renders it, without a second rule here to keep in step.
let _saveNotice = null;
// One muted line under each list name: the filter that actually distinguishes
// this profile, matching profileIcon()'s precedence so the icon and the line
// never describe two different things.
function profileSummaryLine(def, cov) {
def = def || {};
// An explicit model list is the most specific thing a profile can say, so
// it leads. "1 filter" was technically true and told the operator nothing.
if (Array.isArray(def.allowed_model_ids) && def.allowed_model_ids.length) {
const n = def.allowed_model_ids.length;
return n === 1 ? def.allowed_model_ids[0] : `${n} models`;
}
if (def.latency_tolerance != null) return `latency ${def.latency_tolerance}`;
if (def.max_cost_per_1m_completion != null) return `max $${def.max_cost_per_1m_completion}/1M out`;
if (def.min_tier != null || def.max_tier != null) {
if (def.min_tier != null && def.max_tier != null) return `tier ${def.min_tier}-${def.max_tier}`;
return def.min_tier != null ? `tier ${def.min_tier}+` : `tier up to ${def.max_tier}`;
}
if (def.provider != null) return String(def.provider);
if (cov && cov.admitting > 0 && cov.admitting < cov.total) return `${cov.admitting} of ${cov.total} categories`;
const keys = Object.keys(def).filter(k => k !== 'name' && def[k] != null);
return keys.length ? `${keys.length} filter${keys.length === 1 ? '' : 's'}` : 'no filters';
}
function renderProfileList() {
const listEl = document.getElementById('profile-list');
listEl.innerHTML = _profilesCache.map(p => {
const rawName = String(p.name || '');
const name = escapeHtml(rawName);
const cov = p.category_coverage || null;
const zeroAdmit = p.zero_admit === true || p.zero_admit === 'true';
const active = rawName === _selectedProfile ? ' active' : '';
// The zero-admit warning has to survive into the list: it is the one
// condition an operator needs to see without selecting the row first.
const flag = zeroAdmit
? `<span class="profile-list-flag text-warning" title="This profile admits zero models for every task category under the live catalog"><span data-icon="alert"></span></span>`
: (p.source === 'builtin'
? `<span class="profile-list-flag" title="Built-in, read-only"><span data-icon="lock"></span></span>`
: '');
// Which profile bare `auto` resolves to is the single most consequential
// fact on this page -- it is what every client that does not name a
// profile actually gets -- so it is marked in the list rather than only
// in the detail pane.
const defaultTag = p.is_default === true
? `<span class="profile-list-default" title="Bare 'auto' resolves to this profile">default</span>`
: '';
return `<button type="button" class="profile-list-item${active}" data-select-profile="${name}">
<span data-icon="${profileIcon(p.definition, cov)}"></span>
<span class="profile-list-text">
<span class="profile-list-name">${name}${defaultTag}</span>
<span class="profile-list-sub">${escapeHtml(profileSummaryLine(p.definition, cov))}</span>
</span>
${flag}
</button>`;
}).join('');
}
/* Point bare `auto` at this profile.
Writes routing.default_profile through the same allowlisted config
endpoint the Controls page uses -- so the server-side validation is the
one that already exists (422 naming the valid profiles if the name is
not one), rather than a second rule that could drift from it.
That endpoint applies the new default to the live `cfg` as well as writing
it, so this takes effect on the very next request -- see `_saveNotice`. */
async function setDefaultProfile(name) {
try {
const result = await apiFetchDetail(`${API}api/config/routing.default_profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: name }),
});
toast(`'${name}' is now the routing default`, 'success');
_saveNotice = (result && result.message) || null;
await loadProfiles();
} catch (e) {
toast(e.message || 'Could not set the default profile', 'error');
}
}
function saveNoticeHtml() {
if (!_saveNotice) return '';
// Warning styling is earned by the message actually asking for a restart,
// not by the fact that something was saved.
const needsRestart = /restart/i.test(_saveNotice) && !/no restart/i.test(_saveNotice);
const kind = needsRestart ? 'warning' : 'success';
return `<div class="alert alert-${kind} py-2 px-3 mb-3" role="alert"
style="font-size:.82rem">${escapeHtml(_saveNotice)}</div>`;
}
// The inline editor: the same field set as the modal, rendered into the
// detail pane so editing happens where the profile is being read rather than
// in a dialog stacked on top of it.
function renderProfileEditor(p) {
const detailEl = document.getElementById('profile-detail');
const def = p.definition || {};
const name = escapeHtml(String(p.name || ''));
const val = v => (v != null ? escapeHtml(String(v)) : '');
const tierOptions = (selected) => ['', '1', '2', '3'].map(t =>
`<option value="${t}"${String(selected != null ? selected : '') === t ? ' selected' : ''}>${t === '' ? 'any' : t}</option>`
).join('');
const latencyOptions = ['', 'interactive', 'batch'].map(l =>
`<option value="${l}"${String(def.latency_tolerance || '') === l ? ' selected' : ''}>${l === '' ? 'none' : l}</option>`
).join('');
detailEl.innerHTML = `
<div class="profile-detail-head">
<div class="profile-detail-title">
<span data-icon="edit"></span>
<h2>Editing ${name}</h2>
</div>
<div class="profile-actions">
<button class="btn btn-outline-secondary btn-sm" data-action="cancel-edit">Cancel</button>
<button class="btn btn-success btn-sm" data-action="save-edit">Save</button>
</div>
</div>
<div class="profile-detail-section">
<div class="profile-edit-grid">
<label class="profile-field-label" for="inline-profile-name">name</label>
<div>
<input type="text" class="form-control form-control-sm" id="inline-profile-name" value="${name}" autocomplete="off" readonly>
<div class="form-text text-muted" style="font-size:.72rem">To rename: duplicate, then delete.</div>
</div>
<label class="profile-field-label" for="inline-profile-provider">provider</label>
<div><input type="text" class="form-control form-control-sm" id="inline-profile-provider" value="${val(def.provider)}" placeholder="any" autocomplete="off"></div>
<label class="profile-field-label" for="inline-profile-min-tier">min_tier</label>
<div><select class="form-select form-select-sm" id="inline-profile-min-tier">${tierOptions(def.min_tier)}</select></div>
<label class="profile-field-label" for="inline-profile-max-tier">max_tier</label>
<div><select class="form-select form-select-sm" id="inline-profile-max-tier">${tierOptions(def.max_tier)}</select></div>
<label class="profile-field-label" for="inline-profile-latency">latency_tolerance</label>
<div><select class="form-select form-select-sm" id="inline-profile-latency">${latencyOptions}</select></div>
<label class="profile-field-label" for="inline-profile-max-cost">max_cost_per_1m_completion</label>
<div><input type="number" class="form-control form-control-sm" id="inline-profile-max-cost" min="0" step="0.01" placeholder="none" value="${val(def.max_cost_per_1m_completion)}"></div>
<label class="profile-field-label">allowed_model_ids</label>
<div>
${pickerHtml('inline-profile-allowed-models')}
</div>
</div>
</div>`;
// Populated after the markup exists so the select is in the DOM to fill.
loadCatalogModels('inline-profile-allowed-models', def.allowed_model_ids || []);
}
function startInlineEdit(name) {
_editing = true;
_editingOriginalName = name;
renderProfileList();
renderProfileDetail();
renderStaticIcons();
}
function cancelInlineEdit() {
_editing = false;
_editingOriginalName = null;
renderProfileDetail();
renderStaticIcons();
}
async function saveProfileInline() {
const body = collectProfileBody('inline-profile-');
if (!body.name) {
toast('A profile name is required', 'error');
return;
}
// See saveProfile(): the update endpoint takes the name from the path and
// has no field for it in the body, which is now enforced rather than
// ignored. The check above still runs first -- an empty name means the
// form is not in a state worth submitting.
delete body.name;
try {
const result = await apiFetchDetail(
`${API}api/profiles/${encodeURIComponent(_editingOriginalName)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
toast('Profile saved', 'success');
if (result && result.warning) {
setTimeout(() => toast(result.warning, 'warning'), 600);
}
_editing = false;
_saveNotice = (result && result.message) || null;
// Selection stays on the original name on purpose: the update endpoint
// cannot rename (_ProfileUpdateBody has no name field and
// _profile_block_dict excludes it), so trusting a submitted name here
// would land the pane on a profile that does not exist.
_selectedProfile = _editingOriginalName;
_editingOriginalName = null;
await loadProfiles();
} catch (e) {
toast(e.message || 'Save failed', 'error');
}
}
function renderProfileDetail() {
const detailEl = document.getElementById('profile-detail');
const p = _profilesCache.find(x => String(x.name || '') === _selectedProfile);
if (!p) {
detailEl.innerHTML = '<div class="empty">Select a profile.</div>';
return;
}
if (_editing) {
renderProfileEditor(p);
return;
}
const admitted = Number(p.admitted_count || 0);
const interactive = Number(p.interactive_count || 0);
const zeroAdmit = p.zero_admit === true || p.zero_admit === 'true';
const isBuiltin = p.source === 'builtin';
const name = escapeHtml(String(p.name || ''));
const cov = p.category_coverage || null;
// A profile whose models are gated by eligible_categories admits nothing
// for a category-less probe and plenty for the categories it serves. The
// headline has to be the number an operator would recognise as true.
const gated = Boolean(cov) && cov.admitting > 0 && cov.admitting < cov.total;
const headline = gated ? Number(cov.max_admitted || 0) : admitted;
// Both stats come from the same probe, or the pane contradicts itself: a
// gated profile showing "1 admitted / 0 interactive" invites exactly the
// question the coverage line exists to answer.
const headlineInteractive = gated ? Number(cov.max_interactive_admitted || 0) : interactive;
const headlineModels = gated ? cov.models : p.admitted_models;
const zeroBadge = zeroAdmit
? `<span class="badge bg-warning" title="This profile admits zero models for every task category under the live catalog">admits 0 models</span>`
: '';
const readonlyHint = isBuiltin
? `<span class="profile-readonly-hint" title="Built-in profiles cannot be edited or deleted -- duplicate one to get an editable copy"><span data-icon="lock"></span> read-only</span>`
: '';
// Built-ins get Duplicate INSTEAD of edit/delete, not as well. This adds a
// path around the read-only rule (a new config profile, pre-filled from
// the built-in's definition) rather than weakening it.
// "Set as default" is a config write, not a profile edit, so it is offered
// for built-ins too -- routing.default_profile names one of these, and
// onlycheaps (a built-in) is exactly the kind of profile an operator wants
// to switch to. Every agent that sends bare `auto` follows this setting,
// so it is the one-switch answer rather than a per-client change.
const isDefault = p.is_default === true;
const setDefaultBtn = isDefault
? ''
: `<button class="btn btn-outline-secondary btn-sm" title="Make bare 'auto' resolve to ${name}"
data-action="set-default" data-profile-name="${name}"><span data-icon="target"></span> Set as default</button>`;
// The delete endpoint already refuses the current default with a 422; this
// says so before the click rather than after it.
const deleteBtn = isDefault
? `<button class="btn btn-danger btn-sm" disabled title="This is the current routing.default_profile. Point that at another profile first."><span data-icon="trash"></span></button>`
: `<button class="btn btn-danger btn-sm" title="Delete profile" data-action="delete" data-profile-name="${name}"><span data-icon="trash"></span></button>`;
const actions = isBuiltin
? `<div class="profile-actions">
${setDefaultBtn}
<button class="btn btn-outline-secondary btn-sm" title="Duplicate as an editable config profile" data-action="duplicate" data-profile-name="${name}"><span data-icon="copy"></span> Duplicate</button>
</div>`
: `<div class="profile-actions">
${setDefaultBtn}
<button class="btn btn-outline-secondary btn-sm" title="Duplicate profile" data-action="duplicate" data-profile-name="${name}"><span data-icon="copy"></span> Duplicate</button>
<button class="btn btn-outline-secondary btn-sm" title="Edit profile" data-action="edit" data-profile-name="${name}"><span data-icon="edit"></span> Edit</button>
${deleteBtn}
</div>`;
const coverageRow = gated
? `<div class="profile-def-row">
<div class="profile-field-label">categories</div>
<div class="profile-field-val">${cov.admitting} of ${cov.total}
<span class="profile-coverage-info" title="Category-gated: admits ${headline.toLocaleString()} ${headline === 1 ? 'model' : 'models'} for ${cov.admitting} of ${cov.total} task categories. ${admitted.toLocaleString()} for an unspecified category -- its models name the categories they serve, so a category-less probe admits none of them.">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</span>
</div>
</div>`
: '';
detailEl.innerHTML = `
${saveNoticeHtml()}
<div class="profile-detail-head">
<div>
<div class="profile-detail-title">
<span data-icon="${profileIcon(p.definition, cov)}"></span>
<h2>${name}</h2>
</div>
<div class="profile-detail-meta">
${sourceBadge(p.source)}${isDefault ? '<span class="badge bg-info">routing default</span>' : ''}${readonlyHint}${zeroBadge}
</div>
</div>
${actions}
</div>
<div class="profile-detail-section">
<div class="profile-detail-section-label">Admits under the live catalog</div>
<div class="profile-figures">
<div>
<div class="profile-figure-val">${headline.toLocaleString()}</div>
<div class="profile-figure-label">admitted models</div>
</div>
<div>
<div class="profile-figure-val">${headlineInteractive.toLocaleString()}</div>
<div class="profile-figure-label">interactive models</div>
</div>
</div>
<div style="margin-top:.9rem">${renderAdmittedModels(headlineModels)}</div>
</div>
<div class="profile-detail-section">
<div class="profile-detail-section-label">Filters</div>
${formatDefinition(p.definition)}
${coverageRow}
</div>`;
}
function selectProfile(name) {
// Moving to another profile abandons an open edit rather than carrying a
// half-filled form onto a different record.
_editing = false;
_editingOriginalName = null;
_selectedProfile = name;
renderProfileList();
renderProfileDetail();
renderStaticIcons();
}
function renderProfiles(profiles) {
const listEl = document.getElementById('profile-list');
const detailEl = document.getElementById('profile-detail');
if (!profiles) {
listEl.innerHTML = '';
detailEl.innerHTML = '<div class="empty">Could not load profiles. Check the API.</div>';
return;
}
if (!profiles.length) {
listEl.innerHTML = '';
detailEl.innerHTML = '<div class="empty">No routing profiles configured.</div>';
return;
}
_profilesCache = profiles;
// Hold the selection across refreshes; fall back to the first profile when
// the selected one was just deleted or renamed out from under us.
if (!_selectedProfile || !profiles.some(p => String(p.name || '') === _selectedProfile)) {
_selectedProfile = String(profiles[0].name || '');
_editing = false;
}
renderProfileList();
// A refresh must never wipe a half-filled edit form out from under the
// operator, so the pane is left alone while editing.
if (!_editing) renderProfileDetail();
renderStaticIcons();
}
async function loadProfiles() {
const profiles = await apiFetch(`${API}api/profiles`);
renderProfiles(profiles);
}
/* ═══════════════════════════════════════
CATALOG MODELS (for multi-select)
═══════════════════════════════════════ */
let _catalogModels = [];
/* Two panes, Available and Selected, rather than one 450-row multi-select.
The single select was unreadable for a reason that is structural, not
cosmetic: it showed six rows out of 446, so a profile pinned to kimi-k3
put its first selected option at index 72 and the browser's own scroll
restore landed several hundred pixels short of it. The selection was
correct and invisible, which is indistinguishable from no selection.
A Selected pane cannot have that problem -- what is chosen is the only
thing in it. Filtering then becomes useful rather than dangerous: it
narrows Available while Selected stays put, so "filter to kimi, add all
shown" is one gesture instead of a ctrl-click hunt through 446 rows.
`_selectedIds` on the root element is the source of truth. Both panes are
rendered from it, `collectProfileBody` reads it, and an id that is not in
the catalog at all still lives there -- which is what stops the silent
drop the old picker had, where a stored id with no matching <option> was
simply not selected and the next save posted the selection without it. A
profile locked to one model became a profile that admits everything. */
function selectedIdsOf(root) {
if (root && root._selectedIds instanceof Set) return root._selectedIds;
return new Set();
}
/* _catalogModels holds the ROUTABLE catalog: /admin/api/models filters to
effective_availability='active' unless asked otherwise, and for an
allowlisted provider that filter is the allowlist -- the poller deprecates
every OpenRouter row not on provider_model_allowlist (393 of 446 here). So
a model becomes selectable by being allowlisted and polled, rather than
being offered and then silently admitting nothing. */
function pickerRows(root) {
const chosen = selectedIdsOf(root);
const byId = new Map(_catalogModels.map(m => [String(m.model_id || ''), m]));
const available = _catalogModels
.map(m => ({ id: String(m.model_id || ''), provider: String(m.provider || ''), stale: false }))
.filter(r => !chosen.has(r.id));
// Selected keeps catalog order for the ids it knows and appends the rest,
// so a model that has left the catalog stays visible and flagged instead
// of disappearing from the only view that would show it.
const selected = Array.from(chosen).sort().map(id => {
const m = byId.get(id);
return {
id,
provider: m ? String(m.provider || '') : '',
stale: !m,
};
});
return { available, selected };
}
function pickerItemHtml(row, action) {
const meta = row.stale ? 'not routable' : row.provider;
const cls = row.stale ? ' profile-pick-item-stale' : '';
const title = row.stale
? `${row.id}: deprecated, stale, or off the provider allowlist. Kept so saving does not drop it.`
: row.id;
return `<li><button type="button" class="profile-pick-item${cls}" data-pick="${action}"
data-id="${escapeHtml(row.id)}" title="${escapeHtml(title)}">
<span class="profile-pick-id">${escapeHtml(row.id)}</span>
<span class="profile-pick-meta">${escapeHtml(meta)}</span>
</button></li>`;
}
function renderPicker(root) {
if (!root) return;
const query = (root._filterQuery || '').trim().toLowerCase();
const { available, selected } = pickerRows(root);
const shown = query
? available.filter(r => r.id.toLowerCase().includes(query))
: available;
root._shownIds = shown.map(r => r.id);
const availList = shown.length
? shown.map(r => pickerItemHtml(r, 'add')).join('')
: `<li class="profile-pick-empty">${query
? `No routable model matches "${escapeHtml(query)}"`
: 'Every routable model is already selected'}</li>`;
// The empty state carries the one rule the layout cannot: none selected is
// not "nothing allowed", it is "no restriction". It says so where the
// answer is needed and nowhere else.
const selList = selected.length
? selected.map(r => pickerItemHtml(r, 'remove')).join('')
: `<li class="profile-pick-empty">none = all allowed</li>`;
root.querySelector('[data-pane="available"]').innerHTML = availList;
root.querySelector('[data-pane="selected"]').innerHTML = selList;
root.querySelector('[data-count="available"]').textContent =
`${shown.length}${query ? ` of ${available.length}` : ''}`;
root.querySelector('[data-count="selected"]').textContent = String(selected.length);
const addAll = root.querySelector('[data-pick="add-all"]');
if (addAll) {
addAll.disabled = shown.length === 0;
addAll.textContent = query ? `Add ${shown.length} shown` : 'Add all';
}
const clear = root.querySelector('[data-pick="clear"]');
if (clear) clear.disabled = selected.length === 0;
}
function bindPicker(root) {
if (root._pickerBound) return;
root.addEventListener('click', ev => {
const btn = ev.target.closest('[data-pick]');
if (!btn || btn.disabled) return;
ev.preventDefault();
const chosen = selectedIdsOf(root);
const action = btn.getAttribute('data-pick');
if (action === 'add') chosen.add(btn.getAttribute('data-id'));
else if (action === 'remove') chosen.delete(btn.getAttribute('data-id'));
else if (action === 'add-all') (root._shownIds || []).forEach(id => chosen.add(id));
else if (action === 'clear') chosen.clear();
else return;
root._selectedIds = chosen;
renderPicker(root);
});
const search = root.querySelector('[data-pick-search]');
if (search) {
search.addEventListener('input', () => {
root._filterQuery = search.value;
renderPicker(root);
});
// Enter in the filter box means "take what I filtered to", not "submit
// the form" -- a stray Enter used to save the profile from here.
search.addEventListener('keydown', ev => {
if (ev.key !== 'Enter') return;
ev.preventDefault();
const chosen = selectedIdsOf(root);
(root._shownIds || []).forEach(id => chosen.add(id));
root._selectedIds = chosen;
renderPicker(root);
});
}
root._pickerBound = true;
}
// `targetId` names the picker root to populate, so the modal and the inline
// editor can each fill their own without a second copy of this fetch.
async function loadCatalogModels(targetId = 'profile-allowed-models', selectedIds = []) {
const models = await apiFetch(`${API}api/models`);
_catalogModels = Array.isArray(models) ? models : [];
const root = document.getElementById(targetId);
if (!root) return;
root._selectedIds = new Set(selectedIds || []);
root._filterQuery = '';
const search = root.querySelector('[data-pick-search]');
if (search) search.value = '';
bindPicker(root);
renderPicker(root);
}
// Both editors render the same picker markup; only the root id differs, so
// the two forms cannot drift apart.
function pickerHtml(targetId) {
return `<div class="profile-picker" id="${targetId}">
<div class="profile-picker-bar">
<input type="search" class="form-control form-control-sm" data-pick-search
placeholder="Filter models" autocomplete="off" aria-label="Filter available models">
<button type="button" class="btn btn-outline-secondary btn-sm" data-pick="add-all">Add all</button>
</div>
<div class="profile-picker-panes">
<div class="profile-pick-pane">
<div class="profile-pick-head">Available <span data-count="available">0</span></div>
<ul class="profile-pick-list" data-pane="available"></ul>
</div>
<div class="profile-pick-pane">
<div class="profile-pick-head">Selected <span data-count="selected">0</span>
<button type="button" class="btn btn-link btn-sm profile-pick-clear" data-pick="clear">Clear</button>
</div>
<ul class="profile-pick-list" data-pane="selected"></ul>
</div>
</div>
</div>`;
}
/* ═══════════════════════════════════════
PROFILE MODAL / CRUD
═══════════════════════════════════════ */
let _profileModal = null;
let _deleteModal = null;
function getProfileModal() {
if (!_profileModal) {
_profileModal = new tabler.Modal(document.getElementById('profile-modal'));
}
return _profileModal;
}
function closeModal(id) {
const el = document.getElementById(id);
const modal = tabler.Modal.getInstance ? tabler.Modal.getInstance(el) : (el._tablerModal || null);
if (modal) {
try { modal.hide(); } catch (_) {}
}
el.classList.remove('show');
el.setAttribute('aria-hidden', 'true');
el.style.display = 'none';
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.querySelectorAll('.modal-backdrop').forEach(bd => bd.remove());
}
function getDeleteModal() {
if (!_deleteModal) {
_deleteModal = new tabler.Modal(document.getElementById('delete-modal'));
}
return _deleteModal;
}
function resetProfileForm() {
document.getElementById('profile-form').classList.remove('was-validated');
document.getElementById('profile-original-name').value = '';
document.getElementById('profile-name').value = '';
document.getElementById('profile-name').disabled = false;
document.getElementById('profile-provider').value = '';
document.getElementById('profile-min-tier').value = '';
document.getElementById('profile-max-tier').value = '';
document.getElementById('profile-latency').value = '';
document.getElementById('profile-max-cost').value = '';
// Clear the Set here as well as in loadCatalogModels: openCreateModal does
// not await the catalog fetch, so between reset and that resolving the root
// would still be holding the previous open's selection.
const picker = document.getElementById('profile-allowed-models');
picker._selectedIds = new Set();
picker._filterQuery = '';
const search = picker.querySelector('[data-pick-search]');
if (search) search.value = '';
renderPicker(picker);
}
function openCreateModal() {
resetProfileForm();
document.getElementById('profile-modal-title').textContent = 'New profile';
document.getElementById('profile-save-btn').textContent = 'Create profile';
loadCatalogModels();
getProfileModal().show();
}
async function openEditModal(name) {
resetProfileForm();
document.getElementById('profile-modal-title').textContent = 'Edit profile';
document.getElementById('profile-save-btn').textContent = 'Save profile';
const profiles = await apiFetch(`${API}api/profiles`);
const p = (profiles || []).find(x => String(x.name) === String(name));
if (!p) {
toast('Profile not found', 'error');
return;
}
const def = p.definition || {};
document.getElementById('profile-original-name').value = String(p.name || '');
document.getElementById('profile-name').value = String(p.name || '');
document.getElementById('profile-name').disabled = true;
document.getElementById('profile-provider').value = def.provider != null ? String(def.provider) : '';
document.getElementById('profile-min-tier').value = def.min_tier != null ? String(def.min_tier) : '';
document.getElementById('profile-max-tier').value = def.max_tier != null ? String(def.max_tier) : '';
document.getElementById('profile-latency').value = def.latency_tolerance != null ? String(def.latency_tolerance) : '';
document.getElementById('profile-max-cost').value = def.max_cost_per_1m_completion != null ? String(def.max_cost_per_1m_completion) : '';
// The selection goes in through loadCatalogModels, not by walking options
// afterwards: an id with no option (dropped from the catalog since the
// profile was written) has nothing to walk, and used to vanish here.
await loadCatalogModels('profile-allowed-models', def.allowed_model_ids || []);
getProfileModal().show();
}
/* Duplicate: the way an operator edits a built-in without the built-in
becoming editable. It opens the create modal pre-filled from the source
profile's definition under a NEW name, so the result is an ordinary config
profile in config.local.yaml. No backend change — POST /api/profiles/
already does everything this needs.
The name MUST differ from the built-in: a config profile that collides with
a built-in name is rejected at config load, deliberately. */
function duplicateName(base, taken) {
let candidate = `${base}-copy`;
let n = 2;
while (taken.has(candidate)) {
candidate = `${base}-copy-${n}`;
n += 1;
}
return candidate;
}
async function openDuplicateModal(name) {
const profiles = await apiFetch(`${API}api/profiles`);
const p = (profiles || []).find(x => String(x.name) === String(name));
if (!p) {
toast('Profile not found', 'error');
return;
}
resetProfileForm();
document.getElementById('profile-modal-title').textContent = `Duplicate ${name}`;
document.getElementById('profile-save-btn').textContent = 'Create profile';
const taken = new Set((profiles || []).map(x => String(x.name)));
const def = p.definition || {};
// No original-name: an empty #profile-original-name is what makes
// saveProfile() POST to the create endpoint rather than the update one.
document.getElementById('profile-name').value = duplicateName(String(name), taken);
document.getElementById('profile-provider').value = def.provider != null ? String(def.provider) : '';
document.getElementById('profile-min-tier').value = def.min_tier != null ? String(def.min_tier) : '';
document.getElementById('profile-max-tier').value = def.max_tier != null ? String(def.max_tier) : '';
document.getElementById('profile-latency').value = def.latency_tolerance != null ? String(def.latency_tolerance) : '';
document.getElementById('profile-max-cost').value = def.max_cost_per_1m_completion != null ? String(def.max_cost_per_1m_completion) : '';
await loadCatalogModels('profile-allowed-models', def.allowed_model_ids || []);
getProfileModal().show();
}
// `prefix` selects which form is being read: the modal's fields are
// "profile-*", the detail pane's inline editor uses "inline-profile-*". Both
// forms carry the same field set, so one collector serves both and the
// request body can never drift between them.
function collectProfileBody(prefix = 'profile-') {
const id = suffix => document.getElementById(prefix + suffix);
const allowedSelect = id('allowed-models');
// selectedIdsOf, not selectedOptions: the picker filters by re-rendering,
// so a selection outside the active filter has no option element to read.
const allowed = Array.from(selectedIdsOf(allowedSelect)).filter(Boolean).sort();
const body = {};
const name = id('name').value.trim();
if (name) body.name = name;
const provider = id('provider').value.trim();
if (provider) body.provider = provider;
const minTier = id('min-tier').value;
if (minTier !== '') body.min_tier = parseInt(minTier, 10);
const maxTier = id('max-tier').value;
if (maxTier !== '') body.max_tier = parseInt(maxTier, 10);
const latency = id('latency').value;
if (latency) body.latency_tolerance = latency;
const maxCost = id('max-cost').value;
if (maxCost !== '') body.max_cost_per_1m_completion = parseFloat(maxCost);
if (allowed.length) body.allowed_model_ids = allowed;
return body;
}
async function saveProfile() {
const form = document.getElementById('profile-form');
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
const originalName = document.getElementById('profile-original-name').value.trim();
const isEdit = Boolean(originalName);
const body = collectProfileBody();
// The update endpoint has no `name` field -- it cannot rename, the name is
// in the path. Sending it used to be harmless because the body model
// ignored unknown keys; it now 422s, which is the point: an unknown key is
// an error rather than a silent no-op.
if (isEdit) delete body.name;
const url = isEdit
? `${API}api/profiles/${encodeURIComponent(originalName)}`
: `${API}api/profiles/`;
try {
const result = await apiFetchDetail(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal('profile-modal');
await new Promise(r => setTimeout(r, 350));
toast('Profile saved', 'success');
if (result && result.warning) {
setTimeout(() => toast(result.warning, 'warning'), 600);
}
_saveNotice = (result && result.message) || null;
await loadProfiles();
} catch (e) {
toast(e.message || 'Save failed', 'error');
}
}
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/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' });
toast('Profile deleted', 'success');
await loadProfiles();
} catch (e) {
toast(e.message || 'Delete failed', 'error');
}
}
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
function init() {
renderStaticIcons();
// Selection lives on the list, actions live in the detail pane -- one
// delegated listener per container, both bound to elements that outlive
// every re-render.
document.getElementById('profile-list').addEventListener('click', (e) => {
const row = e.target.closest('button[data-select-profile]');
if (!row) return;
selectProfile(row.dataset.selectProfile);
});
document.getElementById('profile-detail').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const name = btn.dataset.profileName;
// Edit happens inline in this pane; duplicate and delete still use their
// dialogs, because both act on a record other than the one on screen.
if (btn.dataset.action === 'edit') startInlineEdit(name);
else if (btn.dataset.action === 'save-edit') saveProfileInline();
else if (btn.dataset.action === 'cancel-edit') cancelInlineEdit();
else if (btn.dataset.action === 'duplicate') openDuplicateModal(name);
else if (btn.dataset.action === 'delete') promptDelete(name);
else if (btn.dataset.action === 'set-default') setDefaultProfile(name);
});
loadProfiles();
connectSSE();
}
init();
</script>
</body>
</html>