// static/js/admin.js — Admin panel module (ES6)
// Admin-only: users, endpoints, MCP, RAG, embeddings, tokens, webhooks, features
import uiModule from './ui.js';
import settingsModule from './settings.js';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
let initialized = false;
let modalEl = null;
// When the user adds an endpoint, store its id so the next render of
// the endpoints list can flash a glow on that row. Cleared once the
// animation fires.
let _recentlyAddedEpId = null;
let _authPolicy = { password_min_length: 8, reserved_usernames: [] };
function el(id) { return document.getElementById(id); }
function esc(s) { return uiModule.esc(s); }
/* ═══════════════════════════════════════════
USERS TAB
═══════════════════════════════════════════ */
const PRIV_LABELS = {
can_use_agent: 'Agent mode',
can_use_browser: 'Browser automation',
can_use_bash: 'Shell / Python / Files',
can_use_documents: 'Document editor',
can_use_research: 'Deep research',
can_generate_images: 'Image generation',
can_manage_memory: 'Memory & skills',
};
async function loadUsers() {
const list = el('adm-userList');
try {
const res = await fetch('/api/auth/users', { credentials: 'same-origin' });
if (res.status === 401 || res.status === 403) { list.innerHTML = '
Access denied
'; return; }
const data = await res.json();
if (!data.users || data.users.length === 0) { list.innerHTML = 'No users found
'; return; }
list.innerHTML = '';
data.users.forEach(u => {
const row = document.createElement('div');
row.className = 'admin-user-row';
// Header: name + badges + delete
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:4px 0;';
const initial = u.username.charAt(0).toUpperCase();
header.innerHTML = `
${esc(initial)}
${esc(u.username)}
${u.is_admin ? 'ADMIN' : 'Click to manage privileges'}
${u.is_admin ? '' : `
`}
${u.is_admin ? '' : '
'}
`;
row.appendChild(header);
// Privileges panel (hidden by default, not for admins)
if (!u.is_admin) {
const privPanel = document.createElement('div');
privPanel.className = 'admin-priv-panel hidden';
privPanel.style.cssText = 'padding:8px 0 4px;border-top:1px solid var(--border);margin-top:8px;';
// Boolean toggles
let html = 'Features
';
for (const [key, label] of Object.entries(PRIV_LABELS)) {
const checked = u.privileges && u.privileges[key] ? 'checked' : '';
html += `
${label}
`;
}
// Rate limit
html += 'Limits
';
const maxMsg = (u.privileges && u.privileges.max_messages_per_day) || 0;
html += ``;
// Allowed models — checkbox list
const allowedModels = Array.isArray(u.privileges && u.privileges.allowed_models)
? u.privileges.allowed_models
: [];
const allowedSet = new Set(allowedModels);
const modelsRestricted = !!(u.privileges && u.privileges.allowed_models_restricted);
const blockAllModels = !!(u.privileges && u.privileges.block_all_models);
html += `
${blockAllModels ? 'No models allowed' : (!modelsRestricted ? 'All models allowed (no restrictions)' : (allowedSet.size === 0 ? 'No models allowed' : allowedSet.size + ' model(s) allowed'))}
Loading models...
`;
privPanel.innerHTML = html;
row.appendChild(privPanel);
// Toggle panel visibility + rotate chevron + load models
let _modelsLoaded = false;
header.addEventListener('click', (e) => {
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin]')) return;
privPanel.classList.toggle('hidden');
const chevron = header.querySelector('.admin-user-chevron');
if (chevron) {
const isOpen = !privPanel.classList.contains('hidden');
chevron.style.transform = isOpen ? 'rotate(180deg)' : '';
chevron.style.opacity = isOpen ? '0.7' : '0.3';
}
// Load models list on first expand
if (!_modelsLoaded && !privPanel.classList.contains('hidden')) {
_modelsLoaded = true;
_loadModelsForUser(u.username, allowedSet, modelsRestricted, blockAllModels, privPanel);
}
});
// Wire privilege changes (boolean + number inputs, not model checkboxes)
privPanel.querySelectorAll('[data-priv]').forEach(input => {
const handler = async () => {
const username = input.dataset.user;
const key = input.dataset.priv;
let value;
if (input.type === 'checkbox') value = input.checked;
else if (input.type === 'number') value = parseInt(input.value) || 0;
else value = input.value;
try {
await fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
});
} catch (e) { uiModule.showError('Failed to update privilege'); }
};
if (input.type === 'checkbox') input.addEventListener('change', handler);
else input.addEventListener('change', handler);
});
}
// Rename button
const renameBtn = row.querySelector('[data-adm-rename-user]');
if (renameBtn) {
renameBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const oldUsername = renameBtn.dataset.admRenameUser;
const next = await uiModule.styledPrompt(`Rename "${oldUsername}"`, {
defaultValue: oldUsername,
placeholder: 'New username',
confirmText: 'Rename',
});
const username = (next || '').trim();
if (!username || username === oldUsername) return;
try {
const res = await fetch(`/api/auth/users/${encodeURIComponent(oldUsername)}/rename`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
uiModule.showError(data.detail || 'Failed to rename user');
return;
}
if (data.renamed_self) {
window.location.reload();
return;
}
loadUsers();
} catch (err) {
uiModule.showError('Failed to rename user');
}
});
}
// Delete button
const delBtn = row.querySelector('[data-adm-del-user]');
if (delBtn) {
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const username = delBtn.dataset.admDelUser;
if (!await uiModule.styledConfirm(`Remove user "${username}"?`, { confirmText: 'Remove', danger: true })) return;
const res = await fetch('/api/auth/users', { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) });
if (res.ok) loadUsers();
else uiModule.showError('Failed to delete user');
});
}
// Promote / demote (admin toggle) — present on every row
const adminToggleBtn = row.querySelector('[data-adm-toggle-admin]');
if (adminToggleBtn) {
adminToggleBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const username = adminToggleBtn.dataset.admToggleAdmin;
const makeAdmin = adminToggleBtn.dataset.makeAdmin === '1';
const confirmMsg = makeAdmin
? `Grant admin rights to "${username}"? They'll get full access to all settings and users — including the power to demote or remove other admins (you included).`
: `Revoke admin rights from "${username}"? They'll lose access to the admin panel.`;
if (!await uiModule.styledConfirm(confirmMsg, { confirmText: makeAdmin ? 'Make admin' : 'Revoke admin', danger: !makeAdmin })) return;
adminToggleBtn.disabled = true;
try {
const res = await fetch(`/api/auth/users/${encodeURIComponent(username)}/admin`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: makeAdmin }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
uiModule.showError(data.detail || 'Failed to change admin status');
adminToggleBtn.disabled = false;
return;
}
// Demoting yourself drops your own admin access — reload into the
// normal-user view (mirrors the rename-self reload above).
if (data.self) { window.location.reload(); return; }
loadUsers();
} catch (err) {
uiModule.showError('Failed to change admin status');
adminToggleBtn.disabled = false;
}
});
}
list.appendChild(row);
});
} catch (e) { list.innerHTML = 'Failed to load users
'; }
}
async function _loadModelsForUser(username, allowedSet, modelsRestricted, blockAllModels, privPanel) {
const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`);
if (!listEl) return;
try {
// Use /api/model-endpoints rather than /api/models — the latter is
// backed by `cached_models`, so endpoints that haven't been probed yet
// (e.g. a freshly-added cloud API like DeepSeek) simply don't show up
// until some other endpoint happens to trigger a cache refresh. The
// endpoints listing always reflects every configured endpoint.
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const data = await res.json();
const allModels = [];
(Array.isArray(data) ? data : []).forEach(ep => {
if (!ep.online) return;
(ep.models || []).forEach(mid => {
allModels.push({ mid, epName: ep.name || '', display: mid.split('/').pop() });
});
});
if (!allModels.length) {
listEl.innerHTML = 'No models available';
return;
}
let restricted = modelsRestricted;
let blockAll = blockAllModels;
listEl.innerHTML = sortModelObjects(allModels).map(m => {
const checked = !blockAll && (!restricted || allowedSet.has(m.mid)) ? 'checked' : '';
return ``;
}).join('');
// Save on change
function _saveModels() {
const checked = [];
listEl.querySelectorAll('.priv-model-cb').forEach(cb => {
if (cb.checked) checked.push(cb.dataset.mid);
});
// Three distinct states the backend must be able to tell apart:
// - all checked -> no restriction (allowed_models: [], block_all_models: false)
// - none checked -> block everything (allowed_models: [], block_all_models: true)
// - some checked -> allowlist (allowed_models: checked, block_all_models: false)
let value, hintText;
if (checked.length === allModels.length) {
restricted = false;
blockAll = false;
value = [];
hintText = 'All models allowed (no restrictions)';
} else if (checked.length === 0) {
restricted = true;
blockAll = true;
value = [];
hintText = 'No models allowed';
} else {
restricted = true;
blockAll = false;
value = checked;
hintText = value.length + ' model(s) allowed';
}
const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]');
if (hint) hint.textContent = hintText;
fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowed_models: value, allowed_models_restricted: restricted, block_all_models: blockAll }),
}).catch(() => {});
}
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels));
// All / None buttons
privPanel.querySelector(`.priv-models-all[data-user="${username}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = true);
_saveModels();
});
privPanel.querySelector(`.priv-models-none[data-user="${username}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = false);
_saveModels();
});
} catch (e) {
listEl.innerHTML = 'Failed to load models';
}
}
function initSignupToggle() {
const toggle = el('adm-signupToggle');
fetch('/api/auth/status', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { toggle.checked = !!d.signup_enabled; })
.catch(e => console.warn('Auth status fetch failed:', e));
toggle.addEventListener('change', async () => {
try {
const res = await fetch('/api/auth/signup-toggle', { method: 'POST', credentials: 'same-origin' });
const data = await res.json();
toggle.checked = data.signup_enabled;
} catch (e) { toggle.checked = !toggle.checked; }
});
}
function initAddUser() {
fetch('/api/auth/policy', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(policy => {
if (!policy) return;
_authPolicy = policy;
const admPw = el('adm-newPassword');
if (admPw) admPw.placeholder = `Password (min ${policy.password_min_length})`;
})
.catch(() => {});
el('adm-addBtn').addEventListener('click', async () => {
const msg = el('adm-addMsg');
msg.textContent = ''; msg.className = '';
const username = el('adm-newUsername').value.trim();
const password = el('adm-newPassword').value;
const is_admin = el('adm-newIsAdmin').checked;
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
if (password.length < _authPolicy.password_min_length) { msg.textContent = `Password must be at least ${_authPolicy.password_min_length} characters`; msg.className = 'admin-error'; return; }
if (_authPolicy.reserved_usernames.includes(username.toLowerCase())) { msg.textContent = 'This username is reserved'; msg.className = 'admin-error'; return; }
el('adm-addBtn').disabled = true;
try {
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
const data = await res.json();
if (res.ok) { msg.textContent = 'User created'; msg.className = 'admin-success'; el('adm-newUsername').value = ''; el('adm-newPassword').value = ''; el('adm-newIsAdmin').checked = false; loadUsers(); }
else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
el('adm-addBtn').disabled = false;
});
}
/* ═══════════════════════════════════════════
SERVICES TAB — Endpoints
═══════════════════════════════════════════ */
function _isLocalEndpoint(url) {
if (!url) return false;
try {
const u = new URL(url);
const h = u.hostname.toLowerCase();
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') return true;
if (h.endsWith('.local')) return true;
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true;
// Tailscale CGNAT range (100.64.0.0/10 → 100.64.x–100.127.x). Servers
// found via "Scan for Servers" come back as tailnet IPs, which are still
// your own machines, so group them under Local rather than API.
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true;
// Single-label hostnames are LAN by convention.
if (!h.includes('.')) return true;
return false;
} catch { return false; }
}
async function _refreshAfterEndpointChange(deletedEndpointId) {
try {
const sm = window.sessionModule;
const pending = sm && sm.getPendingChat ? sm.getPendingChat() : null;
if (deletedEndpointId && pending && String(pending.endpointId || '') === String(deletedEndpointId)) {
if (sm.setPendingChat) sm.setPendingChat(null);
}
} catch (_) {}
try {
if (window.modelsModule && window.modelsModule.refreshModels) {
await window.modelsModule.refreshModels(true);
}
} catch (_) {}
try {
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', {
detail: { deletedEndpointId: deletedEndpointId || null }
}));
} catch (_) {}
try {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
}
} catch (_) {}
}
async function _selectAddedModelInChat(endpoint) {
const modelId = endpoint && Array.isArray(endpoint.models) ? endpoint.models[0] : '';
if (!modelId) return;
try {
if (window.modelsModule && window.modelsModule.refreshModels) {
await window.modelsModule.refreshModels(true);
}
} catch (_) {}
try {
document.dispatchEvent(new CustomEvent('odysseus:auto-select-model', {
detail: {
endpointId: endpoint.id || '',
endpointName: endpoint.name || '',
modelId,
url: endpoint.base_url || '',
}
}));
} catch (_) {}
}
async function loadEndpoints() {
const listLocal = el('adm-epList-local');
const listApi = el('adm-epList-api');
// Fallback to the legacy single list if the split containers don't exist
// (older HTML or third-party embedding).
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels();
setTimeout(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
}
}, 1500);
}
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
settingsModule.refreshAiModelEndpoints();
}
try {
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
// returning an error envelope) the same as "no endpoints yet": show the
// empty state, not "Failed to load". The user just installed the app —
// there's literally nothing to load, so the error read as broken UI.
let data = [];
if (res.ok) {
try { data = await res.json(); } catch { data = []; }
}
if (!Array.isArray(data) || data.length === 0) {
const empty = 'None
';
if (listLocal) listLocal.innerHTML = empty;
if (listApi) listApi.innerHTML = 'None
';
if (listLegacy) listLegacy.innerHTML = empty;
return;
}
const rowHtml = data.map(ep => {
const visibleCount = ep.models.length;
const totalCount = visibleCount + (ep.hidden_count || 0);
// `ep.models` is the *visible* set — when every model is hidden it's
// empty, but we still need to render the expand panel so the user can
// un-hide them. Gate on the total instead.
const hasModels = ep.online && totalCount > 0;
const statusBadge = ep.status === 'empty'
? 'no models'
: ep.online
? `${visibleCount}/${totalCount} models enabled`
: 'offline';
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
const keyLabel = ep.has_key
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
return `
${providerLogoFromUrl(ep.base_url) || ''}
${esc(ep.name)}
${ep.model_type === 'image' ? 'Image' : ''}
${kindLabel ? `${esc(kindLabel)}` : ''}
${statusBadge}
${ep.is_enabled ? '' : 'disabled'}
${hasModels ? `Click to manage models` : ''}
${hasModels ? '
' : ''}
${esc(ep.base_url)}${category === 'local' ? `
` : ''}${keyLabel}
${hasModels ? `
` : ''}
`;
});
// Partition rows into Local vs API for the split sections.
// Subsections without any rows are hidden entirely (heading + all)
// so empty groups don't take up vertical real estate.
const _renderInto = (container, indices) => {
if (!container) return;
const section = container.closest('.adm-ep-section');
if (!indices.length) {
if (section) section.style.display = 'none';
container.innerHTML = '';
return;
}
if (section) section.style.display = '';
container.innerHTML = indices.map(i => rowHtml[i]).join('');
};
const localIdx = [], apiIdx = [];
data.forEach((ep, i) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? localIdx : apiIdx).push(i));
// Sort each section: enabled endpoints first, disabled at the bottom.
// Preserve original order within each group via stable sort.
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
localIdx.sort(_sortByEnabled);
apiIdx.sort(_sortByEnabled);
_renderInto(listLocal, localIdx);
_renderInto(listApi, apiIdx);
if (listLegacy) listLegacy.innerHTML = rowHtml.join('');
// Iterate matching nodes across both containers.
const queryAll = (sel) => {
const out = [];
[listLocal, listApi, listLegacy].forEach(c => {
if (c) c.querySelectorAll(sel).forEach(n => out.push(n));
});
return out;
};
queryAll('[data-adm-toggle-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
});
queryAll('[data-adm-copy-url]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = btn.dataset.admCopyUrl || '';
if (!url) return;
uiModule.copyToClipboard(url).then(() => {
// Brief icon swap to a checkmark so the user gets feedback that
// the copy actually happened. Reverts after ~1.4s.
const prev = btn.innerHTML;
btn.innerHTML = '';
btn.style.opacity = '1';
setTimeout(() => { btn.innerHTML = prev; btn.style.opacity = ''; }, 1400);
}).catch(() => {});
});
});
queryAll('[data-adm-del-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
var epId = btn.dataset.admDelEp;
var isOffline = btn.dataset.admEpOnline === '0';
// Offline endpoints are already broken — skip the confirm dialog
// entirely and delete immediately. The optimistic UI removal makes
// the action feel instant.
if (!isOffline) {
var deps = [];
try {
var depRes = await fetch('/api/model-endpoints/' + epId + '/dependents', { credentials: 'same-origin' });
var depData = await depRes.json();
deps = depData.dependents || [];
} catch (e) { /* proceed without warning */ }
var msg = 'Delete this endpoint?';
if (deps.length) {
msg += '\n\nThe following settings use this endpoint and will be reset:\n— ' + deps.join('\n— ');
}
if (!await uiModule.styledConfirm(msg, { confirmText: 'Delete', danger: true })) return;
}
// Optimistic: remove from UI immediately
const row = btn.closest('[data-adm-ep-id]');
if (row) row.remove();
fetch('/api/model-endpoints/' + epId, { method: 'DELETE' })
.then(() => _refreshAfterEndpointChange(epId))
.then(() => loadEndpoints())
.catch(() => loadEndpoints());
});
});
// Clear the just-added marker now that the row has been rendered
// with the animation class — keeps the glow from re-firing on every
// subsequent loadEndpoints() call (e.g. when toggling a model).
if (_recentlyAddedEpId) _recentlyAddedEpId = null;
// Models expand/collapse (click anywhere on card)
queryAll('[data-adm-ep-id]').forEach(row => {
const header = row.querySelector('[data-adm-ep-header]');
if (!header) return;
let _modelsLoaded = false;
row.style.cursor = 'pointer';
row.addEventListener('click', async (e) => {
// Don't let interactions inside the expanded panel re-fire the
// expand/collapse handler — the search box was getting closed
// because clicking it bubbled up to here.
if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, label')) return;
const epId = header.dataset.admEpHeader;
const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`);
if (!panel) return;
panel.classList.toggle('hidden');
const chevron = row.querySelector('.admin-user-chevron');
const isOpen = !panel.classList.contains('hidden');
if (chevron) {
chevron.style.transform = isOpen ? 'rotate(180deg)' : '';
chevron.style.opacity = isOpen ? '0.7' : '0.3';
}
if (!_modelsLoaded && isOpen) {
_modelsLoaded = true;
// Our shared whirlpool spinner (consistent with the rest of the app).
panel.innerHTML = '';
let _modelsSpin = null;
const _ld = document.createElement('span');
_ld.style.cssText = 'opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;';
_ld.appendChild(document.createTextNode('Loading models…'));
try {
const _sp = (await import('./spinner.js')).default;
_modelsSpin = _sp.createWhirlpool(14);
_modelsSpin.element.style.cssText = 'width:14px;height:14px;margin:0;display:inline-block;';
_ld.appendChild(_modelsSpin.element);
} catch (_) {}
panel.appendChild(_ld);
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
const _loadingHtml = (label) => `${esc(label)}`;
const renderModels = (models, warning = '') => {
const sortedModels = sortModelObjects(models);
const warningHtml = warning ? `${esc(warning)}
` : '';
const attachRefresh = () => {
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
e.preventDefault();
panel.innerHTML = _loadingHtml('Refreshing models...');
try {
const res = await fetch(`/api/model-endpoints/${epId}/models?refresh=true&refresh_timeout=60`, { credentials: 'same-origin' });
const refreshWarning = res.headers.get('X-Model-Refresh-Warning') || '';
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const refreshedModels = await res.json();
renderModels(refreshedModels, refreshWarning);
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
} catch (_) {
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
}
});
};
if (!sortedModels.length) {
panel.innerHTML = `${warningHtml}No models`;
attachRefresh();
return;
}
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
const showSearch = sortedModels.length >= 8;
panel.innerHTML = `${warningHtml}${showSearch ? `` : ''}` + sortedModels.map(m =>
``
).join('') + '
';
const filterRows = (q) => {
const needle = q.trim().toLowerCase();
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none';
});
};
attachRefresh();
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = true;
});
_saveEpModelState(epId, panel);
});
panel.querySelector(`[data-ep-select-none="${epId}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = false;
});
_saveEpModelState(epId, panel);
});
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
});
};
try {
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const models = await res.json();
_stopSpin();
renderModels(models);
} catch (e) { _stopSpin(); panel.innerHTML = 'Failed to load models'; }
}
});
});
} catch (e) {
const err = 'Failed to load
';
[listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; });
}
}
async function _saveEpModelState(epId, panel) {
const hidden = [];
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
if (!cb.checked) hidden.push(cb.dataset.epModelId);
});
const total = panel.querySelectorAll('input[type=checkbox]').length;
try {
await fetch(`/api/model-endpoints/${epId}/models`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ hidden }),
});
const countLabel = panel.querySelector('.mcp-tools-count');
if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`;
const row = panel.closest('[data-adm-ep-id]');
if (row) {
const badge = row.querySelector('.admin-badge');
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
}
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
settingsModule.refreshAiModelEndpoints();
}
} catch (e) { /* silent */ }
}
function initEndpointForm() {
const provider = el('adm-epProvider');
const urlInput = el('adm-epUrl');
const kindSel = el('adm-epKind');
// Custom provider picker — mirrors the (now hidden)