fix(admin): replace the profiles card grid with a list + detail pane #58

Merged
alee merged 2 commits from fix/admin-profiles-header-cleanup into main 2026-09-08 03:56:50 +00:00

View File

@@ -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&hellip;</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 &middot; ${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 &mdash; 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);
});