fix(admin): replace the profiles card grid with a list + detail pane #58
@@ -86,27 +86,78 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.navbar-brand a:hover .brand-speedlines line{animation:none}
|
||||
}
|
||||
/* ── Profile card specifics ── */
|
||||
/* Admitted/interactive counts -- compact badge in card header */
|
||||
/* -- 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 multi-select is six rows tall; its label belongs at the top of that
|
||||
control rather than floating in the middle of it. */
|
||||
.profile-edit-grid>label:last-of-type{align-self:start;padding-top:.4rem}
|
||||
.profile-edit-grid select[multiple]{align-self:start}
|
||||
@media (max-width:575.98px){.profile-edit-grid{grid-template-columns:1fr;gap:.25rem}}
|
||||
.profile-admit-badge{font-variant-numeric:tabular-nums;white-space:nowrap;font-size:.82rem}
|
||||
/* Promoted definition rows -- larger/brighter than the old .78rem muted styling */
|
||||
.profile-field-label{color:var(--tblr-secondary);font-size:.8rem;font-weight:600;letter-spacing:.01em}
|
||||
.profile-field-val{color:rgba(255,255,255,.8);font-size:.92rem}
|
||||
.profile-zero-alert{margin-left:auto}
|
||||
/* Coverage note -- compact key:value row */
|
||||
.profile-coverage{font-size:.78rem;color:var(--tblr-secondary);margin-bottom:.5rem;line-height:1.3}
|
||||
.profile-coverage .profile-field-label{font-size:.76rem;font-weight:500;letter-spacing:normal}
|
||||
.profile-coverage .profile-field-val{font-size:.8rem;color:var(--tblr-secondary)}
|
||||
.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}
|
||||
/* ── Read-only lock hint on built-in cards ── */
|
||||
.profile-readonly-hint{color:var(--tblr-secondary);font-size:.72rem;display:inline-flex;align-items:center;gap:4px}
|
||||
/* ── Card header actions ── */
|
||||
.profile-actions{display:flex;align-items:center;gap:6px}
|
||||
/* ââ Profile grid: CSS auto-fill so 5 cards don't leave orphans ââ */
|
||||
#profile-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1rem;max-width:1200px;margin-left:auto!important;margin-right:auto!important}
|
||||
#profile-grid>.col-12{grid-column:1/-1}
|
||||
.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);
|
||||
@@ -191,15 +242,12 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
<div class="row row-cards" id="profile-grid">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /row row-cards -->
|
||||
</section>
|
||||
</div><!-- /profiles-layout -->
|
||||
</div><!-- /container-xl -->
|
||||
</div><!-- /page-body -->
|
||||
<footer class="footer footer-transparent d-print-none">
|
||||
@@ -334,13 +382,13 @@ function icon(name, size) {
|
||||
return svgs[name] || svgs.default;
|
||||
}
|
||||
|
||||
/* âÂÂâ Select a per-profile card-header icon based on definition filters.
|
||||
/* -- 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) âÂÂâ */
|
||||
5. no non-null filters -> profile (generic default) -- */
|
||||
function profileIcon(def, cov) {
|
||||
def = def || {};
|
||||
if (def.latency_tolerance != null) return 'clock';
|
||||
@@ -455,6 +503,15 @@ function tooltipForAdmitted(models) {
|
||||
return `${preview} +${models.length - 8} more`;
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
@@ -465,29 +522,201 @@ function formatDefinition(def) {
|
||||
const rows = keys.map(key => {
|
||||
let val = def[key];
|
||||
if (Array.isArray(val)) {
|
||||
val = val.length ? val.join(', ') : '—';
|
||||
val = val.length ? val.join(', ') : '-';
|
||||
} else if (typeof val === 'object') {
|
||||
try { val = JSON.stringify(val); } catch (_) { val = String(val); }
|
||||
}
|
||||
return `<div class="row g-0 mb-1">
|
||||
<div class="col-5 profile-field-label">${escapeHtml(key)}</div>
|
||||
<div class="col-7 profile-field-val">${escapeHtml(String(val))}</div>
|
||||
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;
|
||||
}
|
||||
|
||||
function renderProfiles(profiles) {
|
||||
const grid = document.getElementById('profile-grid');
|
||||
if (!profiles) {
|
||||
grid.innerHTML = '<div class="col-12"><div class="card"><div class="card-body"><div class="empty">Could not load profiles. Check the API.</div></div></div></div>';
|
||||
/* -- 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;
|
||||
// Sticky once a save lands, matching the modal's own restart hint: the change
|
||||
// is in config.local.yaml but dispatch will not see it until a reload.
|
||||
let _showRestartHint = false;
|
||||
|
||||
// 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 || {};
|
||||
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>`
|
||||
: '');
|
||||
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}</span>
|
||||
<span class="profile-list-sub">${escapeHtml(profileSummaryLine(p.definition, cov))}</span>
|
||||
</span>
|
||||
${flag}
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function restartHintHtml() {
|
||||
return _showRestartHint
|
||||
? `<div class="alert alert-warning py-2 px-3 mb-3" role="alert" style="font-size:.82rem">A restart is required before dispatch sees this change.</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">Renaming is not supported here -- POST /api/profiles/{name} drops the name field. Duplicate under the new name, then delete this one.</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" for="inline-profile-allowed-models">allowed_model_ids</label>
|
||||
<div>
|
||||
<select class="form-select form-select-sm" id="inline-profile-allowed-models" multiple size="6"></select>
|
||||
<div class="form-text text-muted" style="font-size:.72rem">Select none to allow every model in the catalog.</div>
|
||||
</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;
|
||||
}
|
||||
if (!profiles.length) {
|
||||
grid.innerHTML = '<div class="col-12"><div class="card"><div class="card-body"><div class="empty">No routing profiles configured.</div></div></div></div>';
|
||||
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;
|
||||
_showRestartHint = true;
|
||||
// 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;
|
||||
}
|
||||
grid.innerHTML = profiles.map(p => {
|
||||
const admitted = Number(p.admitted_count || 0);
|
||||
const interactive = Number(p.interactive_count || 0);
|
||||
const zeroAdmit = p.zero_admit === true || p.zero_admit === 'true';
|
||||
@@ -499,65 +728,112 @@ function renderProfiles(profiles) {
|
||||
// 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 card contradicts itself: a
|
||||
// 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 admitBadge = `<span class="badge bg-secondary profile-admit-badge" title="${escapeHtml(tooltipForAdmitted(headlineModels))}">${headline.toLocaleString()} admitted · ${headlineInteractive.toLocaleString()} interactive</span>`;
|
||||
|
||||
const zeroBadge = zeroAdmit
|
||||
? `<span class="badge bg-warning profile-zero-alert" title="This profile admits zero models for every task category under the live catalog">admits 0 models</span>`
|
||||
: '';
|
||||
const coverageNote = gated
|
||||
? `<div class="profile-coverage row g-0 mb-1">
|
||||
<div class="col-5 profile-field-label">categories</div>
|
||||
<div class="col-7 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>`
|
||||
? `<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>`
|
||||
? `<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.
|
||||
const actions = isBuiltin
|
||||
? `<div class="profile-actions">
|
||||
<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></button>
|
||||
<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">
|
||||
<button class="btn btn-outline-secondary btn-sm" title="Duplicate profile" data-action="duplicate" data-profile-name="${name}"><span data-icon="copy"></span></button>
|
||||
<button class="btn btn-outline-secondary btn-sm" title="Edit profile" data-action="edit" data-profile-name="${name}"><span data-icon="edit"></span></button>
|
||||
<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>
|
||||
<button class="btn btn-danger btn-sm" title="Delete profile" data-action="delete" data-profile-name="${name}"><span data-icon="trash"></span></button>
|
||||
</div>`;
|
||||
const cardIcon = profileIcon(p.definition, cov);
|
||||
return `<div data-profile-name="${name}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span data-icon="${cardIcon}"></span>
|
||||
<h3 class="card-title mb-0">${escapeHtml(String(p.name || '—'))}</h3>
|
||||
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 = `
|
||||
${restartHintHtml()}
|
||||
<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)}${readonlyHint}${zeroBadge}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">${admitBadge}${zeroBadge}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||
<div class="card-subtitle text-muted">${sourceBadge(p.source)} ${readonlyHint}</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 title="${escapeHtml(tooltipForAdmitted(headlineModels))}">
|
||||
<div class="profile-figure-val">${headline.toLocaleString()}</div>
|
||||
<div class="profile-figure-label">admitted models</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<div class="profile-figure-val">${headlineInteractive.toLocaleString()}</div>
|
||||
<div class="profile-figure-label">interactive models</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-detail-section">
|
||||
<div class="profile-detail-section-label">Filters</div>
|
||||
${formatDefinition(p.definition)}
|
||||
${coverageNote}
|
||||
</div>
|
||||
</div>
|
||||
${coverageRow}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -572,13 +848,18 @@ async function loadProfiles() {
|
||||
|
||||
let _catalogModels = [];
|
||||
|
||||
async function loadCatalogModels() {
|
||||
// `targetId` names the multi-select 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 select = document.getElementById('profile-allowed-models');
|
||||
select.innerHTML = _catalogModels.map(m =>
|
||||
`<option value="${escapeHtml(String(m.model_id || ''))}">${escapeHtml(String(m.model_id || ''))} <span class="text-muted">(${escapeHtml(String(m.provider || ''))})</span></option>`
|
||||
).join('');
|
||||
const selected = new Set(selectedIds || []);
|
||||
const select = document.getElementById(targetId);
|
||||
if (!select) return;
|
||||
select.innerHTML = _catalogModels.map(m => {
|
||||
const mid = String(m.model_id || '');
|
||||
return `<option value="${escapeHtml(mid)}"${selected.has(mid) ? ' selected' : ''}>${escapeHtml(mid)} <span class="text-muted">(${escapeHtml(String(m.provider || ''))})</span></option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
@@ -713,21 +994,26 @@ async function openDuplicateModal(name) {
|
||||
getProfileModal().show();
|
||||
}
|
||||
|
||||
function collectProfileBody() {
|
||||
const allowedSelect = document.getElementById('profile-allowed-models');
|
||||
// `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');
|
||||
const allowed = Array.from(allowedSelect.selectedOptions).map(o => o.value).filter(Boolean);
|
||||
const body = {};
|
||||
const name = document.getElementById('profile-name').value.trim();
|
||||
const name = id('name').value.trim();
|
||||
if (name) body.name = name;
|
||||
const provider = document.getElementById('profile-provider').value.trim();
|
||||
const provider = id('provider').value.trim();
|
||||
if (provider) body.provider = provider;
|
||||
const minTier = document.getElementById('profile-min-tier').value;
|
||||
const minTier = id('min-tier').value;
|
||||
if (minTier !== '') body.min_tier = parseInt(minTier, 10);
|
||||
const maxTier = document.getElementById('profile-max-tier').value;
|
||||
const maxTier = id('max-tier').value;
|
||||
if (maxTier !== '') body.max_tier = parseInt(maxTier, 10);
|
||||
const latency = document.getElementById('profile-latency').value;
|
||||
const latency = id('latency').value;
|
||||
if (latency) body.latency_tolerance = latency;
|
||||
const maxCost = document.getElementById('profile-max-cost').value;
|
||||
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;
|
||||
@@ -788,11 +1074,23 @@ async function doDelete(name) {
|
||||
|
||||
function init() {
|
||||
renderStaticIcons();
|
||||
document.getElementById('profile-grid').addEventListener('click', (e) => {
|
||||
// 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;
|
||||
if (btn.dataset.action === 'edit') openEditModal(name);
|
||||
// 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user