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-06 23:17:03 +00:00

1503 lines
82 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', 'personas', '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', personas: '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(),
personas: () => UI.loadAdminPersonas(),
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(),
channels: () => UI.loadAdminChannels(),
};
// 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 === 'personas') UI.loadTeamManagePersonas(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'settings') UI.loadTeamManageSettings(teamId);
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 || [];
if (!users.length) { el.innerHTML = '<div class="empty-hint">No users</div>'; return; }
const rows = users.map(u => {
const initial = (u.display_name || u.username || '?')[0].toUpperCase();
const name = esc(u.display_name || u.username);
const handle = '@' + esc(u.username);
const roleCls = u.role === 'admin' ? 'badge-admin' : u.role === 'service' ? 'badge-service' : 'badge-user';
const isPending = !u.is_active;
const statusCls = isPending ? 'badge-warning' : 'badge-active';
const statusLabel = isPending ? 'pending' : 'active';
const teams = (u.teams || []).map(t => esc(t.team_name)).join(', ') || '\u2014';
const lastSeen = u.last_login_at ? _relativeTime(u.last_login_at) : '\u2014';
return `<tr data-name="${esc(u.username)}">
<td><div style="display:flex;align-items:center;gap:10px">
<div class="admin-avatar">${initial}</div>
<div><div class="admin-user-name">${name}</div><div class="admin-user-handle">${handle}</div></div>
</div></td>
<td><span class="${roleCls}">${u.role}</span></td>
<td><span class="${statusCls}">${statusLabel}</span></td>
<td style="font-size:12px;color:var(--text-2)">${teams}</td>
<td class="admin-user-time">${lastSeen}</td>
<td class="admin-actions-cell">
${isPending
? `<button class="btn-icon" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button class="btn-icon" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
}
<button class="btn-icon" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="btn-icon" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="btn-icon btn-icon-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Teams</th><th>Last Seen</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="admin-table-footer">
<span>${users.length} user${users.length !== 1 ? 's' : ''}</span>
</div>`;
// Approve form placeholder (injected per-user when needed)
} 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', models: 'Models', providers: 'Providers', personas: 'Personas' };
el.innerHTML = Object.entries(s).map(([k, v]) => `
<div class="stat-card">
<div class="stat-card-label">${labels[k] || k.replace(/_/g, ' ')}</div>
<div class="stat-card-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
</div>`).join('');
} 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() {
// Stat cards + bar chart
const cardsEl = document.getElementById('adminUsageCards');
const chartEl = document.getElementById('adminUsageChart');
// Fetch 7-day usage for cards and chart
if (cardsEl) {
try {
const weekData = await API.adminGetUsage({ period: '7d', group_by: 'day' });
const buckets = weekData.data || weekData.buckets || [];
const totalReqs = buckets.reduce((s, b) => s + (b.request_count || 0), 0);
const totalIn = buckets.reduce((s, b) => s + (b.input_tokens || 0), 0);
const totalOut = buckets.reduce((s, b) => s + (b.output_tokens || 0), 0);
const totalTokens = totalIn + totalOut;
const tokenStr = totalTokens > 1e6 ? (totalTokens / 1e6).toFixed(1) + 'M' : totalTokens > 1e3 ? (totalTokens / 1e3).toFixed(0) + 'K' : totalTokens;
const userSet = new Set(buckets.filter(b => b.user_id).map(b => b.user_id));
const avgMs = buckets.reduce((s, b) => s + (b.avg_latency_ms || 0), 0);
const avgLatency = buckets.length ? (avgMs / buckets.length / 1000).toFixed(1) + 's' : '—';
cardsEl.innerHTML = `
<div class="stat-card"><div class="stat-card-label">Total Requests</div><div class="stat-card-value">${totalReqs.toLocaleString()}</div><div class="stat-card-sub">Last 7 days</div></div>
<div class="stat-card"><div class="stat-card-label">Tokens Used</div><div class="stat-card-value">${tokenStr}</div><div class="stat-card-sub">Input + output</div></div>
<div class="stat-card"><div class="stat-card-label">Active Users</div><div class="stat-card-value">${userSet.size || '—'}</div><div class="stat-card-sub">Last 7 days</div></div>
<div class="stat-card"><div class="stat-card-label">Avg Response</div><div class="stat-card-value">${avgLatency}</div><div class="stat-card-sub">p50 latency</div></div>`;
// Bar chart — aggregate by day label
if (chartEl && buckets.length > 0) {
const dayMap = {};
buckets.forEach(b => {
const d = new Date(b.window_start || b.date || b.bucket);
const label = d.toLocaleDateString('en', { weekday: 'short' });
dayMap[label] = (dayMap[label] || 0) + (b.request_count || 0);
});
const days = Object.entries(dayMap);
const maxVal = Math.max(...days.map(d => d[1]), 1);
chartEl.innerHTML = `
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;padding:20px">
<div style="font-size:13px;font-weight:600;margin-bottom:16px">Requests per Day</div>
<div class="bar-chart">
${days.map(([label, val]) => {
const pct = Math.round(val / maxVal * 100);
return `<div class="bar-chart-col">
<span class="bar-chart-val">${val}</span>
<div class="bar-chart-track"><div class="bar-chart-fill" style="height:${pct}%"></div></div>
<span class="bar-chart-label">${label}</span>
</div>`;
}).join('')}
</div>
</div>`;
}
} catch (e) {
cardsEl.innerHTML = '';
if (chartEl) chartEl.innerHTML = '';
}
}
// Usage breakdown via primitive (preserves existing period/group-by controls)
const totalsEl = document.getElementById('adminUsageTotals');
if (!totalsEl) return;
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
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;
if (exts.length === 0) {
el.innerHTML = '<div class="empty-hint">No extensions installed</div>';
return;
}
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Extension</th><th>Status</th><th>Tier</th><th>Version</th><th></th></tr></thead>
<tbody>${exts.map(ext => {
const statusBadge = ext.is_enabled ? '<span class="badge-active">enabled</span>' : '<span class="badge-disabled">disabled</span>';
const systemBadge = ext.is_system ? ' <span class="badge-user">system</span>' : '';
return `<tr>
<td>
<div style="font-weight:500">${esc(ext.name)}${systemBadge}</div>
<div class="admin-user-handle">${esc(ext.description || 'No description')}${ext.author ? ' · by ' + esc(ext.author) : ''}</div>
</td>
<td>${statusBadge}</td>
<td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${exts.length} extension${exts.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) {
el.innerHTML = '<div class="empty-hint">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 = `
<table class="admin-table">
<thead><tr><th>Action</th><th>Actor</th><th>Resource</th><th>Details</th><th>Time</th><th>IP</th></tr></thead>
<tbody>${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 `<tr>
<td><span style="font-weight:600;color:var(--accent);font-family:var(--font-mono,monospace);font-size:12px">${esc(e.action)}</span></td>
<td style="font-size:12px">${esc(actor)}</td>
<td style="font-size:12px;color:var(--text-3)">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</td>
<td style="font-size:11px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${meta || '—'}</td>
<td class="admin-user-time">${timeStr}</td>
<td style="font-size:11px;color:var(--text-3);font-family:var(--font-mono,monospace)">${e.ip_address ? esc(e.ip_address) : '—'}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
}
// 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) — now renders inside modal body
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');
}
closeModal('providerFormModal');
UI._adminProvForm.setCreateMode();
UI.loadAdminProviders(true);
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
closeModal('providerFormModal');
UI._adminProvForm.setCreateMode();
},
});
}
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
// Fetch providers, health, and models in parallel
const [configsRaw, healthRaw, modelsRaw] = await Promise.all([
API.adminListGlobalConfigs(),
API.adminGetAllProviderHealth().catch(() => ({ providers: [] })),
API.adminListModels().catch(() => ({ models: [] })),
]);
const configs = Array.isArray(configsRaw) ? configsRaw : (configsRaw.configs || configsRaw.providers || configsRaw.data || []);
const healthList = healthRaw.providers || healthRaw.data || healthRaw || [];
const models = modelsRaw.models || modelsRaw.data || modelsRaw || [];
// Build health lookup by provider_config_id
const healthMap = {};
(Array.isArray(healthList) ? healthList : []).forEach(h => { healthMap[h.provider_config_id] = h; });
// Count models per provider
const modelCounts = {};
(Array.isArray(models) ? models : []).forEach(m => {
const pid = m.provider_config_id;
if (pid) modelCounts[pid] = (modelCounts[pid] || 0) + 1;
});
// Cache for edit
UI._providerCache = {};
configs.forEach(c => { UI._providerCache[c.id] = c; });
if (!configs.length) {
el.innerHTML = '<div class="empty-hint">No providers configured \u2014 click + Add above</div>';
return;
}
el.innerHTML = configs.map(c => {
const health = healthMap[c.id];
const status = health ? health.status : 'unknown';
const statusCls = status === 'healthy' ? 'badge-healthy'
: status === 'degraded' ? 'badge-degraded'
: status === 'unhealthy' ? 'badge-unhealthy'
: 'badge-unknown';
const count = modelCounts[c.id] || 0;
const scope = c.is_private ? 'Private' : (c.team_id ? 'Team' : '');
return `<div class="provider-card" data-id="${c.id}">
<div class="provider-card-header">
<div class="provider-card-name">${esc(c.name)}${scope ? ' <span style="font-weight:400;color:var(--text-3);font-size:12px">(' + scope + ')</span>' : ''}</div>
<span class="${statusCls}">${status}</span>
</div>
<div class="provider-card-meta">
<span class="badge-provider-type">${esc(c.provider || '')}</span>
${count ? `<span class="badge-models">${count} model${count !== 1 ? 's' : ''}</span>` : ''}
</div>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Delegate card clicks → open edit modal
if (!el._delegated) {
el._delegated = true;
el.addEventListener('click', (e) => {
const card = e.target.closest('.provider-card');
if (!card) return;
const id = card.dataset.id;
const prov = UI._providerCache?.[id];
if (!prov || !UI._adminProvForm) return;
UI._adminProvForm.setEditMode(prov.id, prov);
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
openModal('providerFormModal');
});
}
},
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 : [];
if (!arr.length) { el.innerHTML = '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Model</th><th>Provider</th><th>Capabilities</th><th>Visibility</th></tr></thead>
<tbody>${arr.map(m => {
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
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 `<tr>
<td style="font-weight:500;font-family:var(--font-mono,monospace);font-size:12px">${esc(m.model_id || m.id)}</td>
<td style="font-size:12px;color:var(--text-3)">${esc(m.provider_name || '')}</td>
<td>${badges}</td>
<td><button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}','${vis}')">${visLabel}</button></td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${arr.length} model${arr.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminPersonas(quiet) {
const el = document.getElementById('adminPersonaList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPersonas();
const list = data.personas || [];
// Ensure form is initialized for dropdown population
ensureAdminPersonaForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = _adminPersonaForm?.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 persona form:', e.message); }
}
// Populate config dropdown
const cfgSel = _adminPersonaForm?.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._personaCache = {};
if (!list.length) { el.innerHTML = '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Persona</th><th>Scope</th><th>Model</th><th>Status</th><th></th></tr></thead>
<tbody>${list.map(p => {
UI._personaCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" style="width:24px;height:24px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:6px" alt="">`
: '';
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 ? `<div class="admin-user-handle">by ${esc(p.creator_name)}</div>` : '';
return `<tr data-grant-persona="${p.id}">
<td>
<div style="display:flex;align-items:center;gap:8px">
${avatarEl}
<div>
<div style="font-weight:500">${esc(p.name)}</div>
${p.description ? `<div class="admin-user-handle" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.description)}</div>` : creator}
</div>
</div>
</td>
<td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button>
<button class="btn-icon" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${list.length} persona${list.length !== 1 ? 's' : ''}</span></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 = '';
closeModal('createTeamModal');
}
try {
const resp = await API.adminListTeams();
const teams = resp.data || [];
if (!teams.length) { el.innerHTML = '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Team</th><th>Status</th><th>Members</th><th></th></tr></thead>
<tbody>${teams.map(t => `<tr>
<td>
<div style="font-weight:500">${esc(t.name)}</div>
<div class="admin-user-handle">${esc(t.description || 'No description')}</div>
</td>
<td>
${t.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-warning">inactive</span>'}
</td>
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
<button class="btn-icon" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${teams.length} team${teams.length !== 1 ? 's' : ''}</span></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';
closeModal('createTeamModal');
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 || [];
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members — add users to this team</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
<tbody>${members.map(m => `<tr>
<td>
<div style="font-weight:500">${esc(m.display_name || m.email)}</div>
<div class="admin-user-handle">${esc(m.email)}</div>
</td>
<td>
<select onchange="updateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></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 = '';
closeModal('createGroupModal');
}
try {
const resp = await API.adminListGroups();
const groups = resp.data || [];
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Group</th><th>Scope</th><th>Members</th><th></th></tr></thead>
<tbody>${groups.map(g => {
const scopeBadge = g.scope === 'global'
? '<span class="badge-admin">global</span>'
: '<span class="badge-team">team</span>';
return `<tr>
<td>
<div style="font-weight:500">${esc(g.name)}</div>
<div class="admin-user-handle">${esc(g.description || 'No description')}</div>
</td>
<td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
<button class="btn-icon btn-icon-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${groups.length} group${groups.length !== 1 ? 's' : ''}</span></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';
closeModal('createGroupModal');
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 || [];
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members — add users to this group</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Member</th><th>Added</th><th></th></tr></thead>
<tbody>${members.map(m => `<tr>
<td>
<div style="font-weight:500">${esc(m.display_name || m.username || m.email)}</div>
<div class="admin-user-handle">${esc(m.email)}</div>
</td>
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></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 cardsEl = document.getElementById('adminHealthCards');
const el = document.getElementById('adminHealthContent');
if (cardsEl) cardsEl.innerHTML = '';
el.innerHTML = '<div class="loading">Loading health data...</div>';
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;
}
// Stat cards
const total = providers.length;
const healthy = providers.filter(p => p.status === 'healthy').length;
const degraded = providers.filter(p => p.status === 'degraded').length;
const down = providers.filter(p => p.status === 'down' || p.status === 'unhealthy').length;
const avgLat = providers.reduce((s, p) => s + (p.avg_latency_ms || 0), 0);
const avgLatStr = total > 0 ? Math.round(avgLat / total) + 'ms' : '—';
if (cardsEl) {
cardsEl.innerHTML = `
<div class="stat-card"><div class="stat-card-label">Providers</div><div class="stat-card-value">${total}</div><div class="stat-card-sub">All configured</div></div>
<div class="stat-card"><div class="stat-card-label">Healthy</div><div class="stat-card-value" style="color:var(--success)">${healthy}</div><div class="stat-card-sub">${total ? Math.round(healthy / total * 100) : 0}%</div></div>
<div class="stat-card"><div class="stat-card-label">Degraded</div><div class="stat-card-value" style="color:var(--warning)">${degraded + down}</div><div class="stat-card-sub">${total ? Math.round((degraded + down) / total * 100) : 0}%</div></div>
<div class="stat-card"><div class="stat-card-label">Avg Latency</div><div class="stat-card-value">${avgLatStr}</div><div class="stat-card-sub">Last hour</div></div>`;
}
// Table
const rows = providers.map(p => {
const name = esc(p.provider_name || p.provider_config_id?.slice(0, 8) || '—');
const statusCls = p.status === 'healthy' ? 'badge-healthy' : p.status === 'degraded' ? 'badge-degraded' : p.status === 'down' || p.status === 'unhealthy' ? 'badge-unhealthy' : 'badge-unknown';
const latency = p.avg_latency_ms != null ? p.avg_latency_ms + 'ms' : '—';
const errorRate = p.error_rate != null ? (100 - p.error_rate * 100).toFixed(1) + '%' : '—';
const reqs = p.request_count ?? 0;
const errCount = (p.error_count || 0) + (p.timeout_count || 0);
const lastErr = p.last_error ? `<div class="text-muted" style="font-size:10px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.last_error)}">${esc(p.last_error)}</div>` : '—';
return `<tr>
<td style="font-weight:500">${name}</td>
<td><span class="${statusCls}">${esc(p.status || 'unknown')}</span></td>
<td class="admin-user-time">${latency}</td>
<td class="admin-user-time">${errorRate}</td>
<td class="admin-user-time">${reqs}</td>
<td class="admin-user-time">${errCount}</td>
<td>${lastErr}</td>
</tr>`;
}).join('');
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Provider</th><th>Status</th><th>Latency</th><th>Uptime</th><th>Requests</th><th>Errors</th><th>Last Error</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
} 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 = `
<table class="admin-table">
<thead><tr><th>Policy</th><th>Type</th><th>Scope</th><th>Priority</th><th>Status</th><th></th></tr></thead>
<tbody>${policies.map(p => `<tr data-id="${esc(p.id)}">
<td style="font-weight:500">${esc(p.name)}</td>
<td><span class="badge-provider-type">${esc(typeLabels[p.policy_type] || p.policy_type)}</span></td>
<td style="font-size:12px;color:var(--text-3)">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</td>
<td class="admin-user-time">${p.priority}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
<button class="btn-icon" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${policies.length} polic${policies.length !== 1 ? 'ies' : 'y'}</span></div>`;
} 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 personas (policy: allow_user_personas)
document.getElementById('adminUserPersonasToggle').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>';
let modelList = typeof App !== 'undefined' && App.models ? App.models : [];
// On admin surface, App isn't loaded — fetch from admin API
if (modelList.length === 0 && typeof API !== 'undefined' && API.adminListModels) {
try {
const mr = await API.adminListModels();
modelList = (mr.models || mr.data || []).map(m => ({
id: m.model_id || m.id,
baseModelId: m.model_id || m.id,
name: m.display_name || m.model_id || m.id,
provider: m.provider_name || '',
hidden: !!m.hidden,
}));
} catch (e) { /* non-critical */ }
}
modelList.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('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();
// 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); }
},
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;
},
// ── Admin Channels (v0.23.2) ──────────────
async loadAdminChannels() {
const list = document.getElementById('adminArchivedList');
const countEl = document.getElementById('adminArchivedCount');
const modeEl = document.getElementById('adminRetentionMode');
if (!list) return;
try {
const resp = await API._get('/api/v1/admin/channels/archived');
const channels = resp.channels || [];
if (countEl) countEl.textContent = channels.length;
if (modeEl) modeEl.textContent = resp.retention_mode || 'flexible';
if (channels.length === 0) {
list.innerHTML = '<div class="empty-hint">No archived channels</div>';
return;
}
let html = '<table class="admin-table"><thead><tr>' +
'<th>Title</th><th>Type</th><th>Owner</th><th>Messages</th><th>Archived</th><th></th>' +
'</tr></thead><tbody>';
for (const ch of channels) {
const age = _relativeTime(ch.updated_at);
html += `<tr>
<td>${esc(ch.title || 'Untitled')}</td>
<td>${esc(ch.type)}</td>
<td>${esc(ch.owner_name)}</td>
<td>${ch.message_count}</td>
<td>${age}</td>
<td><button class="btn-small btn-danger" onclick="UI._purgeChannel('${ch.id}')">Purge</button></td>
</tr>`;
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
},
async _purgeChannel(channelId) {
if (!await showConfirm('Permanently delete this archived channel and all its messages? This cannot be undone.')) return;
try {
await API._del(`/api/v1/admin/channels/${channelId}/purge`);
UI.toast('Channel purged', 'success');
UI.loadAdminChannels(); // refresh list
} catch (e) {
UI.toast('Purge failed: ' + e.message, 'error');
}
},
});