This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/ui-admin.js
2026-03-02 12:02:16 +00:00

1313 lines
69 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard UI Admin
// ==========================================
// Fullscreen admin panel with category + section navigation.
// All loadAdmin*() functions unchanged — same endpoints, same DOM IDs.
// ── Category → Section mapping ────────────
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
};
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
// Section → loader mapping (reuses all existing loadAdmin* functions)
const ADMIN_LOADERS = {
users: () => UI.loadAdminUsers(),
teams: () => UI.loadAdminTeams(),
groups: () => UI.loadAdminGroups(),
roles: () => UI.loadAdminRoles(),
providers: () => UI.loadAdminProviders(),
models: () => UI.loadAdminModels(),
presets: () => UI.loadAdminPresets(),
knowledgeBases: () => {
if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; }
KnowledgeUI.openAdminPanel();
},
memory: () => {
if (typeof MemoryUI !== 'undefined') { MemoryUI.openAdminPanel(); }
else { console.error('[Admin] MemoryUI not loaded — check js/memory-ui.js'); }
},
settings: () => UI.loadAdminSettings(),
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
extensions: () => UI.loadAdminExtensions(),
health: () => UI.loadAdminHealth(),
routing: () => UI.loadAdminRouting(),
capabilities: () => UI.loadAdminCapabilities(),
usage: () => UI.loadAdminUsage(),
audit: () => UI.loadAuditLog(),
stats: () => UI.loadAdminStats(),
};
// Find which category a section belongs to
function _adminCatForSection(section) {
for (const [cat, sections] of Object.entries(ADMIN_SECTIONS)) {
if (sections.includes(section)) return cat;
}
return 'people';
}
Object.assign(UI, {
// ── Admin Panel State ─────────────────────
_adminCat: 'people',
_adminSection: 'users',
// ── Open / Close ──────────────────────────
openAdmin(cat, section) {
UI._adminCat = cat || 'people';
UI._adminSection = section || ADMIN_SECTIONS[UI._adminCat][0];
const panel = document.getElementById('adminPanel');
panel.style.display = 'flex';
// Show current user email
const userEl = document.getElementById('adminCurrentUser');
if (userEl && API.user) userEl.textContent = API.user.email || API.user.username || '';
UI._renderAdminNav();
UI._switchAdminSection(UI._adminSection);
},
closeAdmin() {
document.getElementById('adminPanel').style.display = 'none';
},
// ── Navigate to specific section ──────────
// Called from command palette, deep links, etc.
openAdminSection(section) {
const cat = _adminCatForSection(section);
UI.openAdmin(cat, section);
},
// ── Category switch ───────────────────────
switchAdminCategory(cat) {
UI._adminCat = cat;
UI._adminSection = ADMIN_SECTIONS[cat][0];
UI._renderAdminNav();
UI._switchAdminSection(UI._adminSection);
},
// ── Render navigation state ───────────────
_renderAdminNav() {
// Highlight active category
document.querySelectorAll('.admin-cat').forEach(btn => {
btn.classList.toggle('active', btn.dataset.cat === UI._adminCat);
});
// Populate sidebar
const sidebar = document.getElementById('adminSidebar');
sidebar.innerHTML = ADMIN_SECTIONS[UI._adminCat].map(sec =>
`<button class="${sec === UI._adminSection ? 'active' : ''}" data-section="${sec}">${ADMIN_LABELS[sec]}</button>`
).join('');
// Wire sidebar clicks
sidebar.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => UI._switchAdminSection(btn.dataset.section));
});
},
// ── Section switch (shows content + lazy-loads) ──
async _switchAdminSection(section) {
UI._adminSection = section;
// Update sidebar highlight
document.querySelectorAll('.admin-sidebar button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.section === section);
});
// Hide all, show target
document.querySelectorAll('.admin-section-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${section.charAt(0).toUpperCase() + section.slice(1)}Tab`);
if (panel) panel.style.display = '';
// Lazy-load data
await ADMIN_LOADERS[section]?.();
},
// ── Backward compat: switchAdminTab still works ──
// (called by some inline onclick handlers and admin-handlers.js)
async switchAdminTab(tab) {
const cat = _adminCatForSection(tab);
if (document.getElementById('adminPanel').style.display !== 'flex') {
UI.openAdmin(cat, tab);
} else {
if (cat !== UI._adminCat) {
UI._adminCat = cat;
UI._renderAdminNav();
}
await UI._switchAdminSection(tab);
}
},
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
if (adminTeams.length === 0) {
UI.toast('You are not an admin of any teams', 'error');
return;
}
openModal('teamAdminModal');
if (adminTeams.length === 1) {
// Skip picker, go directly to the team
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
} else {
// Show team picker
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminPicker').style.display = '';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span>
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
}
},
closeTeamAdmin() { closeModal('teamAdminModal'); },
switchTeamTab(tab) {
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
tabs.forEach(t => {
t.classList.remove('active');
if (t.dataset.ttab === tab) t.classList.add('active');
});
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
if (panel) panel.style.display = '';
// Lazy-load tab data
const teamId = UI._managingTeamId;
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'knowledge') {
if (typeof KnowledgeUI === 'undefined') {
console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js');
const c = document.getElementById('teamKBContent');
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Knowledge UI failed to load. Check browser console.</div>';
} else {
KnowledgeUI.openTeamPanel(teamId);
}
}
if (tab === 'groups') UI.loadTeamManageGroups(teamId);
},
async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.users || resp.data || [];
el.innerHTML = users.map(u => {
const teamBadges = (u.teams || []).map(t =>
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
).join(' ');
const isPending = !u.is_active;
return `
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
<div class="admin-user-info">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${isPending ? '<span class="badge-pending">pending</span>' : ''}
${teamBadges}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
<div class="admin-user-actions">
${isPending
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
}
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
<div class="approve-teams" id="approveTeams-${u.id}"></div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const s = await API.adminGetStats();
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
el.innerHTML = '<div class="stats-grid">' +
Object.entries(s).map(([k, v]) => `
<div class="stat-card">
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
</div>`).join('') +
'</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Roles ────────────────────────
async loadAdminRoles() {
const el = document.getElementById('adminRolesContent');
if (!el) return;
if (!UI._adminRoleConfig) {
UI._adminRoleConfig = renderRoleConfig(el, {
scope: 'admin',
showFallback: true,
modelIdField: 'model_id',
modelNameField: 'display_name',
providerIdField: 'provider_config_id',
fetchProviders: async () => {
const configs = await API.adminListGlobalConfigs();
return configs.configs || configs || [];
},
fetchModels: async () => {
const models = await API.adminListModels();
return models.models || models.data || models || [];
},
fetchRoles: () => API.adminListRoles(),
onSave: async (roleId, config) => {
await API.adminUpdateRole(roleId, config);
},
onTest: (roleId) => API.adminTestRole(roleId),
});
}
await UI._adminRoleConfig.refresh();
},
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
// Usage stats via primitive
const totalsEl = document.getElementById('adminUsageTotals');
if (!totalsEl) return;
// Wrap totals+results in a container for the primitive (first time only)
let usageContainer = document.getElementById('_adminUsageContainer');
if (!usageContainer) {
usageContainer = document.createElement('div');
usageContainer.id = '_adminUsageContainer';
const resultsEl = document.getElementById('adminUsageResults');
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
usageContainer.appendChild(totalsEl);
if (resultsEl) usageContainer.appendChild(resultsEl);
}
if (!UI._adminUsageDash) {
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
apiFetch: (p) => API.adminGetUsage(p),
periodElId: 'usagePeriod',
groupByElId: 'usageGroupBy',
compact: false,
showUserColumn: true,
});
}
await UI._adminUsageDash.refresh();
// Pricing table (admin-only, separate from usage primitive)
const pricingEl = document.getElementById('adminPricingTable');
if (pricingEl) {
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}
},
async loadAdminExtensions() {
const el = document.getElementById('adminExtensionsList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API._get('/api/v1/admin/extensions');
const exts = resp.data || [];
this._adminExtensions = exts; // store for edit access
if (exts.length === 0) {
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
return;
}
el.innerHTML = exts.map(ext => {
const statusBadge = ext.is_enabled
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
const systemBadge = ext.is_system
? ' <span class="badge-user" style="font-size:11px">system</span>'
: '';
return `
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
<div class="admin-user-info" style="flex:1">
<div style="display:flex;align-items:center;gap:8px">
<strong>${esc(ext.name)}</strong>
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
${statusBadge}${systemBadge}
</div>
<div class="text-muted" style="font-size:12px;margin-top:2px">
${esc(ext.description || 'No description')}
${ext.author ? ` · by ${esc(ext.author)}` : ''}
· v${esc(ext.version)} · ${ext.tier}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
Edit
</button>
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
${ext.is_enabled ? 'Disable' : 'Enable'}
</button>
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
Uninstall
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
}
},
_auditPage: 1,
_auditPerPage: 30,
async loadAuditLog(page) {
if (page) UI._auditPage = page;
const el = document.getElementById('adminAuditList');
el.innerHTML = '<div class="loading">Loading...</div>';
// Populate action filter on first load
const actionSel = document.getElementById('auditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.adminListAuditActions();
(resp.actions || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
actionSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
const params = {
page: UI._auditPage,
per_page: UI._auditPerPage,
action: document.getElementById('auditFilterAction')?.value || '',
resource_type: document.getElementById('auditFilterResource')?.value || '',
};
try {
const resp = await API.adminListAudit(params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._auditPerPage);
if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
} else {
el.innerHTML = entries.map(e => {
const ts = new Date(e.created_at);
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const actor = e.actor_name || 'system';
const meta = UI._formatAuditMeta(e.metadata);
return `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
</div>
</div>`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('auditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_formatAuditMeta(metaStr) {
try {
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
if (!m || Object.keys(m).length === 0) return '';
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
} catch { return ''; }
},
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
const formEl = document.getElementById('adminAddProviderForm');
// Initialize admin provider form primitive (once)
if (!UI._adminProvForm && formEl) {
UI._adminProvForm = renderProviderForm(formEl, {
prefix: 'adminProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.adminUpdateGlobalConfig(vals._editId, updates);
UI.toast('Provider updated', 'success');
} else {
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
UI.toast('Provider added', 'success');
}
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
},
});
}
// Initialize admin provider list primitive (once)
if (!UI._adminProvList) {
UI._adminProvList = renderProviderList(el, {
apiFetch: () => API.adminListGlobalConfigs(),
showEndpoint: true,
showPrivate: true,
emptyMsg: 'No global providers — add one above',
onEdit: (prov) => {
if (UI._adminProvForm) {
UI._adminProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm('Delete this global provider?')) return;
try {
await API.adminDeleteGlobalConfig(prov.id);
UI.toast('Provider deleted', 'success');
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._adminProvList.refresh(quiet);
},
async loadAdminModels(quiet) {
const el = document.getElementById('adminModelList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
// Support both old is_enabled (bool) and new visibility (string)
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`;
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminPresets(quiet) {
const el = document.getElementById('adminPresetList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPresets();
const list = data.presets || [];
// Ensure form is initialized for dropdown population
ensureAdminPresetForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = _adminPresetForm?.getModelSelect();
if (modelSel) {
modelSel.innerHTML = '<option value="">Select base model...</option>';
try {
const modelData = await API.adminListModels();
const allModels = modelData.models || modelData.data || [];
const arr = Array.isArray(allModels) ? allModels : [];
arr.forEach(m => {
const opt = document.createElement('option');
const mid = m.model_id || m.id;
opt.value = mid;
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
}
// Populate config dropdown
const cfgSel = _adminPresetForm?.getConfigSelect();
if (cfgSel && cfgSel.options.length <= 1) {
try {
const cfgData = await API.adminListGlobalConfigs();
const cfgs = cfgData.configs || cfgData.data || [];
(Array.isArray(cfgs) ? cfgs : []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name + ' (' + c.provider + ')';
cfgSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
UI._presetCache = {};
el.innerHTML = list.map(p => {
UI._presetCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
: '';
// Scope badge with attribution
let scopeBadge = '';
if (p.scope === 'global') {
scopeBadge = '<span class="badge-admin">global</span>';
} else if (p.scope === 'team' && p.team_name) {
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
} else if (p.scope === 'personal') {
scopeBadge = '<span class="badge-user">personal</span>';
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row" data-grant-persona="${p.id}">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona=&quot;${p.id}&quot;]')" title="Manage access">🔒 Access</button>
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Teams Admin ─────────────────────────
_teamEditId: null,
async loadAdminTeams(quiet) {
const el = document.getElementById('adminTeamList');
const detail = document.getElementById('adminTeamDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none';
}
try {
const resp = await API.adminListTeams();
const teams = resp.data || [];
el.innerHTML = teams.map(t => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(t.name)}</strong>
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
</div>
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none';
const detail = document.getElementById('adminTeamDetail');
detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
// Load team settings for policy checkboxes
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
// allow_team_providers defaults to true if not explicitly set
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
} catch (e) { /* proceed with defaults */ }
await this.loadTeamMembers(teamId);
},
async loadTeamMembers(teamId) {
const el = document.getElementById('adminMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
</div>
<div class="text-muted">${esc(m.email)}</div>
</div>
<div class="admin-user-actions">
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMemberUserDropdown(teamId) {
const sel = document.getElementById('adminMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Also get existing members to exclude
const memberResp = await API.adminListMembers(teamId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
// ── Groups Admin ────────────────────────
_groupEditId: null,
async loadAdminGroups(quiet) {
const el = document.getElementById('adminGroupList');
const detail = document.getElementById('adminGroupDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddGroupForm').style.display = 'none';
}
try {
const resp = await API.adminListGroups();
const groups = resp.data || [];
el.innerHTML = groups.map(g => {
const scopeBadge = g.scope === 'global'
? '<span class="badge-admin">global</span>'
: `<span class="badge-team">team</span>`;
return `<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(g.name)}</strong> ${scopeBadge}</div>
<div class="text-muted">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openGroupDetail('${g.id}', '${esc(g.name)}')">Members</button>
<button class="btn-delete" onclick="deleteGroup('${g.id}', '${esc(g.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openGroupDetail(groupId, groupName) {
this._groupEditId = groupId;
document.getElementById('adminGroupList').style.display = 'none';
document.getElementById('adminAddGroupForm').style.display = 'none';
const detail = document.getElementById('adminGroupDetail');
detail.style.display = '';
document.getElementById('adminGroupDetailName').textContent = groupName;
document.getElementById('adminAddGroupMemberForm').style.display = 'none';
// Load group details for meta line
try {
const g = await API.adminGetGroup(groupId);
const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group';
document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`;
} catch (e) { /* proceed */ }
await this.loadGroupMembers(groupId);
},
async loadGroupMembers(groupId) {
const el = document.getElementById('adminGroupMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListGroupMembers(groupId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.username || m.email)}</strong></div>
<div class="text-muted">${esc(m.email)} · added ${new Date(m.added_at).toLocaleDateString()}</div>
</div>
<div class="admin-user-actions">
<button class="btn-delete" onclick="removeGroupMember('${groupId}', '${m.user_id}', '${esc(m.username || m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this group</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadGroupMemberDropdown(groupId) {
const sel = document.getElementById('adminGroupMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Exclude existing members
const memberResp = await API.adminListGroupMembers(groupId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
// ── Grant Picker (shared for Personas + KBs) ──
async openGrantPicker(resourceType, resourceId, resourceName, anchorSelector) {
// Fetch current grant + all groups for the picker
const [grantResp, groupsResp] = await Promise.all([
API.adminGetGrant(resourceType, resourceId),
API.adminListGroups(),
]);
const grant = grantResp;
const groups = (groupsResp.data || []);
const currentScope = grant.grant_scope || 'team_only';
const currentGroups = new Set(grant.granted_groups || []);
const html = `<div class="admin-inline-form" style="margin:8px 0;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
<div style="font-size:13px;font-weight:600;margin-bottom:8px">Access: ${esc(resourceName)}</div>
<div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px">Scope</label>
<select id="grantScope_${resourceId}" class="inline-select" style="width:auto">
<option value="team_only" ${currentScope === 'team_only' ? 'selected' : ''}>Team Only (default)</option>
<option value="global" ${currentScope === 'global' ? 'selected' : ''}>Global (all users)</option>
<option value="groups" ${currentScope === 'groups' ? 'selected' : ''}>Specific Groups</option>
</select>
</div>
<div id="grantGroups_${resourceId}" style="${currentScope === 'groups' ? '' : 'display:none'}">
${groups.length ? groups.map(g => `<label class="checkbox-label" style="font-size:12px">
<input type="checkbox" class="grant-group-cb" value="${g.id}" ${currentGroups.has(g.id) ? 'checked' : ''}> ${esc(g.name)}
<span class="text-muted" style="font-size:11px">${g.scope === 'global' ? '(global)' : '(team)'}</span>
</label>`).join('') : '<span class="text-muted" style="font-size:12px">No groups exist yet — create one in People → Groups</span>'}
</div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="saveGrant('${resourceType}', '${resourceId}')">Save</button>
<button class="btn-small" onclick="this.closest('.admin-inline-form').remove()">Cancel</button>
</div>
</div>`;
// Remove any existing picker, then insert after the anchor row
document.querySelectorAll('.grant-picker-active').forEach(el => el.remove());
const row = document.querySelector(anchorSelector);
if (row) {
const wrapper = document.createElement('div');
wrapper.className = 'grant-picker-active';
wrapper.innerHTML = html;
row.after(wrapper);
// Wire scope toggle
const scopeSel = document.getElementById(`grantScope_${resourceId}`);
const groupsDiv = document.getElementById(`grantGroups_${resourceId}`);
scopeSel?.addEventListener('change', () => {
groupsDiv.style.display = scopeSel.value === 'groups' ? '' : 'none';
});
}
},
// ── Provider Health Dashboard (v0.22.3) ──────
async loadAdminHealth() {
const el = document.getElementById('adminHealthContent');
el.innerHTML = '<div class="loading">Loading health data...</div>';
document.getElementById('adminHealthRefreshBtn')?.addEventListener('click', () => UI.loadAdminHealth(), { once: true });
try {
const data = await API.adminGetAllProviderHealth();
const providers = data.data || [];
if (providers.length === 0) {
el.innerHTML = '<div class="empty-hint">No health data yet — health metrics are collected after the first completion request.</div>';
return;
}
el.innerHTML = '<div class="admin-health-grid"></div>';
const grid = el.querySelector('.admin-health-grid');
for (const p of providers) {
const statusClass = p.status === 'healthy' ? 'badge-success' : p.status === 'degraded' ? 'badge-warning' : p.status === 'down' ? 'badge-danger' : '';
const errorPct = p.error_rate !== undefined ? (p.error_rate * 100).toFixed(1) + '%' : '—';
const avgLatency = p.avg_latency_ms !== undefined ? p.avg_latency_ms + 'ms' : '—';
const configName = p.provider_config_id?.slice(0, 8) || '—';
grid.innerHTML += `
<div class="admin-health-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>${esc(p.provider_name || configName)}</strong>
<span class="badge ${statusClass}">${esc(p.status || 'unknown')}</span>
</div>
<div class="admin-storage-grid" style="gap:4px">
<div class="admin-storage-item">
<span class="admin-storage-label">Requests</span>
<span class="admin-storage-value">${p.request_count ?? 0}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Error Rate</span>
<span class="admin-storage-value" style="color:${p.error_rate > 0.05 ? 'var(--error)' : 'inherit'}">${errorPct}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Avg Latency</span>
<span class="admin-storage-value">${avgLatency}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Errors</span>
<span class="admin-storage-value">${p.error_count ?? 0}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Timeouts</span>
<span class="admin-storage-value">${p.timeout_count ?? 0}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Max Latency</span>
<span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span>
</div>
</div>
${p.last_error ? `<div class="text-muted" style="font-size:11px;margin-top:6px;word-break:break-all">Last error: ${esc(p.last_error)}</div>` : ''}
</div>`;
}
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load health data: ${esc(e.message)}</div>`;
}
},
// ── Routing Policies (v0.22.3) ───────────────
async loadAdminRouting() {
const listEl = document.getElementById('adminRoutingPolicyList');
const formEl = document.getElementById('adminRoutingForm');
listEl.innerHTML = '<div class="loading">Loading policies...</div>';
// Wire add button
const addBtn = document.getElementById('adminAddRoutingPolicyBtn');
addBtn?.addEventListener('click', () => UI._showRoutingForm(null), { once: true });
// Wire test button
const testBtn = document.getElementById('routingTestBtn');
testBtn?.addEventListener('click', () => UI._runRoutingTest(), { once: true });
try {
const data = await API.adminListRoutingPolicies();
const policies = data.data || [];
if (policies.length === 0) {
listEl.innerHTML = '<div class="empty-hint">No routing policies — requests go to the user\'s default provider config.</div>';
return;
}
const typeLabels = { provider_prefer: 'Provider Prefer', team_route: 'Team Route', cost_limit: 'Cost Limit', model_alias: 'Model Alias' };
listEl.innerHTML = policies.map(p => `
<div class="list-card" data-id="${esc(p.id)}">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<strong>${esc(p.name)}</strong>
<span class="badge" style="margin-left:6px">${esc(typeLabels[p.policy_type] || p.policy_type)}</span>
<span class="badge ${p.is_active ? 'badge-success' : ''}" style="margin-left:4px">${p.is_active ? 'Active' : 'Inactive'}</span>
</div>
<div>
<span class="text-muted" style="font-size:11px;margin-right:8px">Priority: ${p.priority}</span>
<span class="badge">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</span>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn-small" onclick="UI._showRoutingForm('${esc(p.id)}')">Edit</button>
<button class="btn-small" onclick="UI._toggleRoutingPolicy('${esc(p.id)}', ${!p.is_active})">${p.is_active ? 'Disable' : 'Enable'}</button>
<button class="btn-small btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}', '${esc(p.name)}')">Delete</button>
</div>
</div>
`).join('');
} catch (e) {
listEl.innerHTML = `<div class="empty-hint">Failed to load routing policies: ${esc(e.message)}</div>`;
}
},
async _showRoutingForm(policyId) {
const formEl = document.getElementById('adminRoutingForm');
const isEdit = !!policyId;
let policy = { name: '', scope: 'global', priority: 100, policy_type: 'provider_prefer', is_active: true, config: {} };
if (isEdit) {
try { policy = await API.adminGetRoutingPolicy(policyId); } catch (e) { UI.toast(e.message, 'error'); return; }
}
const cfg = policy.config || {};
formEl.style.display = '';
formEl.innerHTML = `
<h4 style="margin:0 0 12px">${isEdit ? 'Edit' : 'New'} Routing Policy</h4>
<div class="form-row">
<div class="form-group"><label>Name</label><input type="text" id="rpName" value="${esc(policy.name)}"></div>
<div class="form-group"><label>Priority</label><input type="number" id="rpPriority" value="${policy.priority}" min="0" max="999"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Type</label>
<select id="rpType" ${isEdit ? 'disabled' : ''}>
<option value="provider_prefer" ${policy.policy_type === 'provider_prefer' ? 'selected' : ''}>Provider Prefer</option>
<option value="team_route" ${policy.policy_type === 'team_route' ? 'selected' : ''}>Team Route</option>
<option value="cost_limit" ${policy.policy_type === 'cost_limit' ? 'selected' : ''}>Cost Limit</option>
<option value="model_alias" ${policy.policy_type === 'model_alias' ? 'selected' : ''}>Model Alias</option>
</select>
</div>
<div class="form-group"><label>Scope</label>
<select id="rpScope">
<option value="global" ${policy.scope === 'global' ? 'selected' : ''}>Global</option>
<option value="team" ${policy.scope === 'team' ? 'selected' : ''}>Team</option>
</select>
</div>
</div>
<div class="form-group" id="rpTeamGroup" style="display:${policy.scope === 'team' ? '' : 'none'}">
<label>Team ID</label><input type="text" id="rpTeamId" value="${esc(policy.team_id || '')}">
</div>
<div class="form-group"><label>Config (JSON)</label>
<textarea id="rpConfig" rows="4" style="font-family:monospace;font-size:12px">${esc(JSON.stringify(cfg, null, 2))}</textarea>
</div>
<label class="checkbox-label"><input type="checkbox" id="rpActive" ${policy.is_active ? 'checked' : ''}> Active</label>
<div class="form-row" style="margin-top:12px">
<button class="btn-small btn-primary" id="rpSaveBtn">${isEdit ? 'Update' : 'Create'}</button>
<button class="btn-small" id="rpCancelBtn">Cancel</button>
</div>`;
document.getElementById('rpScope').addEventListener('change', (e) => {
document.getElementById('rpTeamGroup').style.display = e.target.value === 'team' ? '' : 'none';
});
document.getElementById('rpCancelBtn').addEventListener('click', () => { formEl.style.display = 'none'; });
document.getElementById('rpSaveBtn').addEventListener('click', async () => {
let config;
try { config = JSON.parse(document.getElementById('rpConfig').value || '{}'); }
catch (e) { UI.toast('Invalid JSON in config field', 'error'); return; }
const payload = {
name: document.getElementById('rpName').value,
priority: parseInt(document.getElementById('rpPriority').value) || 100,
policy_type: document.getElementById('rpType').value,
scope: document.getElementById('rpScope').value,
team_id: document.getElementById('rpScope').value === 'team' ? document.getElementById('rpTeamId').value : null,
is_active: document.getElementById('rpActive').checked,
config,
};
try {
if (isEdit) { await API.adminUpdateRoutingPolicy(policyId, payload); }
else { await API.adminCreateRoutingPolicy(payload); }
UI.toast(isEdit ? 'Policy updated' : 'Policy created', 'success');
formEl.style.display = 'none';
UI.loadAdminRouting();
} catch (e) { UI.toast(e.message, 'error'); }
});
},
async _toggleRoutingPolicy(id, active) {
try {
const policy = await API.adminGetRoutingPolicy(id);
policy.is_active = active;
await API.adminUpdateRoutingPolicy(id, policy);
UI.toast(active ? 'Policy enabled' : 'Policy disabled', 'success');
UI.loadAdminRouting();
} catch (e) { UI.toast(e.message, 'error'); }
},
async _deleteRoutingPolicy(id, name) {
if (!await showConfirm(`Delete routing policy "${name}"?`)) return;
try {
await API.adminDeleteRoutingPolicy(id);
UI.toast('Policy deleted', 'success');
UI.loadAdminRouting();
} catch (e) { UI.toast(e.message, 'error'); }
},
async _runRoutingTest() {
const model = document.getElementById('routingTestModel').value;
const userId = document.getElementById('routingTestUser').value || API.user?.id;
const resultEl = document.getElementById('routingTestResult');
if (!model) { UI.toast('Model is required', 'warning'); return; }
resultEl.style.display = '';
resultEl.innerHTML = '<div class="loading">Evaluating...</div>';
try {
const data = await API.adminTestRouting(model, userId);
const dec = data.decision;
const candidates = data.candidates || [];
resultEl.innerHTML = `
<div style="margin-top:8px">
<strong>Decision:</strong> ${dec ? `${esc(dec.policy_name || 'no policy')}${esc(dec.selected?.slice(0, 8) || '—')}` : 'No routing applied'}
<span class="text-muted" style="margin-left:8px">(${candidates.length} candidates, ${data.policies || 0} policies evaluated)</span>
</div>
<div style="margin-top:8px;font-size:12px">
${candidates.map((c, i) => `
<div style="padding:4px 0;border-bottom:1px solid var(--border)">
${i + 1}. <strong>${esc(c.provider_id)}</strong> / ${esc(c.config_id?.slice(0, 8) || '—')}
<span class="badge ${c.status === 'healthy' ? 'badge-success' : c.status === 'down' ? 'badge-danger' : ''}" style="margin-left:4px">${esc(c.status || 'unknown')}</span>
${c.reason ? `<span class="text-muted" style="margin-left:4px">${esc(c.reason)}</span>` : ''}
</div>
`).join('')}
</div>`;
} catch (e) {
resultEl.innerHTML = `<div class="empty-hint">Test failed: ${esc(e.message)}</div>`;
}
},
// ── Capability Overrides (v0.22.3) ───────────
async loadAdminCapabilities() {
const el = document.getElementById('adminCapabilityList');
el.innerHTML = '<div class="loading">Loading overrides...</div>';
try {
const data = await API.adminListCapabilityOverrides();
const overrides = data.data || [];
if (overrides.length === 0) {
el.innerHTML = '<div class="empty-hint">No capability overrides — all models use auto-detected capabilities from catalog sync or heuristic inference.</div>';
return;
}
el.innerHTML = `<table class="admin-table" style="width:100%;font-size:13px">
<thead><tr><th>Model</th><th>Provider Config</th><th>Field</th><th>Value</th><th></th></tr></thead>
<tbody>${overrides.map(o => `<tr>
<td><code>${esc(o.model_id)}</code></td>
<td>${esc(o.provider_config_id?.slice(0, 8) || 'any')}</td>
<td>${esc(o.field)}</td>
<td><strong>${esc(String(o.value))}</strong></td>
<td><button class="btn-small btn-danger" onclick="UI._deleteCapOverride('${esc(o.model_id)}', '${esc(o.id)}')">×</button></td>
</tr>`).join('')}</tbody>
</table>`;
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load overrides: ${esc(e.message)}</div>`;
}
},
async _deleteCapOverride(modelId, overrideId) {
try {
await API.adminDeleteModelCapability(modelId, overrideId);
UI.toast('Override deleted', 'success');
UI.loadAdminCapabilities();
} catch (e) { UI.toast(e.message, 'error'); }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
const settings = data.settings || {};
const policies = data.policies || {};
// Helper to read from settings map (JSONB values)
const getSetting = (key, fallback) => {
const v = settings[key];
if (v === undefined || v === null) return fallback;
// Unwrap {value: X} wrapper if present
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration (policy: allow_registration)
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
// Admin system prompt (global_settings)
const sysPrompt = getSetting('system_prompt', {}) || {};
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
// Registration default state (policy: default_user_active → 'true' means auto-active)
const defaultActive = policies.default_user_active === 'true';
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// KB direct access (policy: kb_direct_access — v0.17.0)
const kbDirectEl = document.getElementById('adminKBDirectAccessToggle');
if (kbDirectEl) kbDirectEl.checked = policies.kb_direct_access === 'true';
// Default model (policy: default_model) — populate from current model list
const defModelSel = document.getElementById('adminDefaultModel');
if (defModelSel) {
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
defModelSel.appendChild(opt);
});
defModelSel.value = policies.default_model || '';
}
// Banner (global_settings)
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load banner presets (global_settings)
const presets = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
// Vault / Encryption status
const vaultEl = document.getElementById('adminVaultStatus');
// Web Search config (global_settings)
const searchCfg = getSetting('search_config', {}) || {};
document.getElementById('adminSearchProvider').value = searchCfg.provider || 'duckduckgo';
document.getElementById('adminSearchEndpoint').value = searchCfg.endpoint || '';
document.getElementById('adminSearchApiKey').value = searchCfg.api_key || '';
document.getElementById('adminSearchMaxResults').value = String(searchCfg.max_results || 5);
document.getElementById('searxngConfigFields').style.display =
(searchCfg.provider === 'searxng') ? '' : 'none';
// Auto-Compaction (global_settings)
const compactionCfg = getSetting('auto_compaction', {}) || {};
const compactionEnabled = document.getElementById('adminCompactionEnabled');
if (compactionEnabled) {
compactionEnabled.checked = !!compactionCfg.enabled;
document.getElementById('compactionConfigFields').style.display =
compactionCfg.enabled ? '' : 'none';
}
const compThreshold = document.getElementById('adminCompactionThreshold');
if (compThreshold) compThreshold.value = String(compactionCfg.threshold || 70);
const compCooldown = document.getElementById('adminCompactionCooldown');
if (compCooldown) compCooldown.value = String(compactionCfg.cooldown || 30);
// Memory extraction config (v0.18.0)
const memExtractionCfg = getSetting('memory_extraction', {}) || {};
const memEnabled = document.getElementById('adminMemoryExtractionEnabled');
if (memEnabled) {
memEnabled.checked = !!memExtractionCfg.enabled;
const memFields = document.getElementById('memoryExtractionConfigFields');
if (memFields) memFields.style.display = memExtractionCfg.enabled ? '' : 'none';
memEnabled.addEventListener('change', () => {
if (memFields) memFields.style.display = memEnabled.checked ? '' : 'none';
});
}
const memAutoApprove = document.getElementById('adminMemoryAutoApprove');
if (memAutoApprove) memAutoApprove.checked = !!memExtractionCfg.auto_approve;
// Email / SMTP config (v0.20.0 Phase 3)
const notifCfg = getSetting('notifications', {}) || {};
if (typeof NotifPrefs !== 'undefined') {
NotifPrefs._loadAdminSmtp(notifCfg);
NotifPrefs._initAdminSmtp();
}
if (vaultEl) {
try {
const vault = await API.adminGetVaultStatus();
const keyBadge = vault.encryption_key_set
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-warning">Not Set</span>';
vaultEl.innerHTML = `
<div class="admin-storage-grid" style="margin-top:6px">
<div class="admin-storage-item">
<span class="admin-storage-label">Encryption</span>
${keyBadge}
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Encrypted Keys</span>
<span class="admin-storage-value">${vault.encrypted_keys}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Vault Users</span>
<span class="admin-storage-value">${vault.vault_users}</span>
</div>
</div>`;
} catch (e) {
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
}
}
} catch (e) { console.debug('Failed to load admin settings:', e); }
},
_bannerPresets: {},
updateBannerPreview() {
const prev = document.getElementById('bannerPreview');
if (!prev) return;
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
const bg = document.getElementById('adminBannerBg').value;
const fg = document.getElementById('adminBannerFg').value;
prev.textContent = text;
prev.style.background = bg;
prev.style.color = fg;
},
});