1313 lines
69 KiB
JavaScript
1313 lines
69 KiB
JavaScript
// ==========================================
|
||
// 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="${p.id}"]')" 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;
|
||
},
|
||
|
||
});
|