1390 lines
72 KiB
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 — 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…</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>© 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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>
|