1691 lines
94 KiB
JavaScript
1691 lines
94 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', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||
workflows: ['workflows', 'tasks'],
|
||
routing: ['health', 'routing', 'capabilities'],
|
||
system: ['settings', 'storage', 'packages', 'channels', 'broadcast'],
|
||
monitoring: ['dashboard', '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',
|
||
workflows: 'Workflows', tasks: 'Tasks',
|
||
settings: 'Settings', storage: 'Storage', packages: 'Packages', channels: 'Channels', broadcast: 'Broadcast',
|
||
dashboard: 'Dashboard', 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,
|
||
packages: () => typeof _loadAdminPackages === 'function' ? _loadAdminPackages() : null,
|
||
health: () => UI.loadAdminHealth(),
|
||
routing: () => UI.loadAdminRouting(),
|
||
capabilities: () => UI.loadAdminCapabilities(),
|
||
dashboard: () => UI.loadAdminDashboard(),
|
||
usage: () => UI.loadAdminUsage(),
|
||
audit: () => UI.loadAuditLog(),
|
||
stats: () => UI.loadAdminStats(),
|
||
channels: () => UI.loadAdminChannels(),
|
||
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
|
||
tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null,
|
||
broadcast: () => typeof _loadAdminBroadcast === 'function' ? _loadAdminBroadcast() : null,
|
||
};
|
||
|
||
// 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) {
|
||
const base = window.__BASE__ || '';
|
||
window.location.href = base + '/admin/' + (section || 'users');
|
||
},
|
||
|
||
closeAdmin() {
|
||
const base = window.__BASE__ || '';
|
||
const returnURL = sessionStorage.getItem('sb_admin_return');
|
||
sessionStorage.removeItem('sb_admin_return');
|
||
window.location.href = returnURL || (base + '/');
|
||
},
|
||
|
||
// ── Navigate to specific section ──────────
|
||
openAdminSection(section) {
|
||
const base = window.__BASE__ || '';
|
||
location.replace(base + '/admin/' + (section || 'users'));
|
||
},
|
||
|
||
// ── 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]?.();
|
||
},
|
||
|
||
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" data-action="UI.openTeamManage" data-args='${JSON.stringify([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 === 'workflows') UI.loadTeamWorkflows(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="icon-btn" data-action="showApproveForm" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Approve">✓</button>`
|
||
: `<button class="icon-btn" data-action="toggleUserActive" data-args='${JSON.stringify([u.id, false])}' title="Disable">⏸</button>`
|
||
}
|
||
<button class="icon-btn" data-action="toggleUserRole" data-args='${JSON.stringify([u.id, u.role === "admin" ? "user" : "admin"])}' title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
|
||
<button class="icon-btn" data-action="adminResetUserPassword" data-args='${JSON.stringify([u.id, esc(u.username)])}' title="Reset PW">🔑</button>
|
||
<button class="icon-btn icon-btn-danger" data-action="deleteUser" data-args='${JSON.stringify([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>`; }
|
||
},
|
||
|
||
// ── Admin: Dashboard (v0.33.0) ────────
|
||
async loadAdminDashboard() {
|
||
const el = document.getElementById('adminDashboardContent');
|
||
if (!el) return;
|
||
el.innerHTML = '<div class="loading">Loading dashboard...</div>';
|
||
|
||
try {
|
||
const d = await API.adminGetDashboard();
|
||
|
||
// Stat cards row
|
||
const providers = d.provider_health || [];
|
||
const healthy = providers.filter(p => p.status === 'healthy').length;
|
||
const degraded = providers.filter(p => p.status === 'degraded' || p.status === 'down' || p.status === 'unhealthy').length;
|
||
const usage = d.usage_24h;
|
||
const tokenStr = usage && usage.total_tokens ? Number(usage.total_tokens).toLocaleString() : '0';
|
||
const reqStr = usage && usage.total_requests ? Number(usage.total_requests).toLocaleString() : '0';
|
||
|
||
const rt = d.runtime || {};
|
||
|
||
const cardsEl = document.getElementById('adminDashboardCards');
|
||
if (cardsEl) {
|
||
cardsEl.innerHTML = `
|
||
<div class="stat-card"><div class="stat-card-label">Uptime</div><div class="stat-card-value">${esc(d.uptime || '—')}</div></div>
|
||
<div class="stat-card"><div class="stat-card-label">WS Connections</div><div class="stat-card-value">${d.ws_connections || 0}</div></div>
|
||
<div class="stat-card"><div class="stat-card-label">Providers</div><div class="stat-card-value" style="color:var(--success)">${healthy}</div><div class="stat-card-sub">${degraded ? degraded + ' degraded' : 'all healthy'}</div></div>
|
||
<div class="stat-card"><div class="stat-card-label">Tokens (24h)</div><div class="stat-card-value">${tokenStr}</div><div class="stat-card-sub">${reqStr} requests</div></div>
|
||
<div class="stat-card"><div class="stat-card-label">Goroutines</div><div class="stat-card-value">${rt.goroutines || '—'}</div></div>
|
||
<div class="stat-card"><div class="stat-card-label">Heap</div><div class="stat-card-value">${rt.heap_mb != null ? rt.heap_mb + ' MB' : '—'}</div><div class="stat-card-sub">Sys: ${rt.sys_mb || '—'} MB</div></div>`;
|
||
}
|
||
|
||
// Provider health cards
|
||
let html = '';
|
||
if (providers.length > 0) {
|
||
html += '<h4 style="margin:0 0 12px">Provider Health</h4>';
|
||
html += '<div class="dashboard-provider-grid">';
|
||
providers.forEach(p => {
|
||
const name = esc(p.provider_name || p.provider_config_id?.slice(0, 12) || '—');
|
||
const statusCls = p.status === 'healthy' ? 'badge-healthy' : p.status === 'degraded' ? 'badge-degraded' : 'badge-unhealthy';
|
||
const latency = p.avg_latency_ms != null ? Math.round(p.avg_latency_ms) + 'ms' : '—';
|
||
html += `<div class="dashboard-provider-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<span style="font-weight:600;font-size:13px">${name}</span>
|
||
<span class="${statusCls}">${esc(p.status || 'unknown')}</span>
|
||
</div>
|
||
<div style="display:flex;gap:16px;font-size:12px;color:var(--text-3)">
|
||
<span>Latency: ${latency}</span>
|
||
<span>Reqs: ${p.request_count || 0}</span>
|
||
<span>Errors: ${p.error_count || 0}</span>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// DB pool stats
|
||
if (d.db_pool) {
|
||
const pool = d.db_pool;
|
||
const pct = pool.OpenConnections > 0 ? Math.round(pool.InUse / pool.OpenConnections * 100) : 0;
|
||
html += '<h4 style="margin:20px 0 12px">DB Connection Pool</h4>';
|
||
html += `<div class="dashboard-db-pool">
|
||
<div class="dashboard-pool-bar"><div class="dashboard-pool-fill" style="width:${pct}%"></div></div>
|
||
<div style="display:flex;gap:16px;font-size:12px;color:var(--text-3);margin-top:6px">
|
||
<span>Open: ${pool.OpenConnections}</span>
|
||
<span>In Use: ${pool.InUse}</span>
|
||
<span>Idle: ${pool.Idle}</span>
|
||
<span>Wait Count: ${pool.WaitCount}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Storage status (fetched in parallel)
|
||
try {
|
||
const storage = await API._get('/api/v1/admin/storage/status');
|
||
if (storage && storage.configured) {
|
||
html += '<h4 style="margin:20px 0 12px">Storage</h4>';
|
||
html += '<div class="dashboard-db-pool">';
|
||
html += `<div style="display:flex;gap:16px;font-size:12px;color:var(--text-3)">`;
|
||
html += `<span>Backend: ${esc(storage.backend || '—')}</span>`;
|
||
if (storage.total_bytes != null) html += `<span>Total: ${(storage.total_bytes / 1024 / 1024).toFixed(1)} MB</span>`;
|
||
if (storage.file_count != null) html += `<span>Files: ${storage.file_count}</span>`;
|
||
html += `<span style="color:${storage.healthy ? 'var(--success)' : 'var(--danger)'}">${storage.healthy ? 'Healthy' : 'Unhealthy'}</span>`;
|
||
html += `</div></div>`;
|
||
}
|
||
} catch (_) { /* storage status optional */ }
|
||
|
||
// Recent errors
|
||
const errors = d.recent_errors || [];
|
||
if (errors.length > 0) {
|
||
html += '<h4 style="margin:20px 0 12px">Recent Errors</h4>';
|
||
html += '<div class="dashboard-errors">';
|
||
errors.forEach(e => {
|
||
const time = e.created_at ? new Date(e.created_at).toLocaleString() : '—';
|
||
html += `<div class="dashboard-error-row">
|
||
<span class="text-muted" style="font-size:11px;min-width:140px">${time}</span>
|
||
<span style="font-size:12px">${esc(e.action || '')} ${esc(e.resource_type || '')} ${esc(e.resource_id || '')}</span>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
el.innerHTML = html || '<div class="empty-hint">No data yet.</div>';
|
||
|
||
// Auto-refresh every 30s
|
||
if (UI._dashboardRefreshTimer) clearInterval(UI._dashboardRefreshTimer);
|
||
UI._dashboardRefreshTimer = setInterval(() => {
|
||
if (document.getElementById('adminDashboardContent')) {
|
||
UI.loadAdminDashboard();
|
||
} else {
|
||
clearInterval(UI._dashboardRefreshTimer);
|
||
}
|
||
}, 30000);
|
||
} 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="icon-btn" data-action="editAdminExtension" data-args='${JSON.stringify([ext.id])}' title="Edit">✎</button>
|
||
<button class="icon-btn" data-action="toggleAdminExtension" data-args='${JSON.stringify([ext.id, !ext.is_enabled])}' title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
|
||
<button class="icon-btn icon-btn-danger" data-action="deleteAdminExtension" data-args='${JSON.stringify([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}" data-action="cycleModelVisibility" data-args='${JSON.stringify([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.data || [];
|
||
|
||
// 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="icon-btn" data-action="UI.openGrantPicker" data-args='${JSON.stringify(["persona", p.id, esc(p.name), `[data-grant-persona="${p.id}"]`])}' title="Access">🔒</button>
|
||
<button class="icon-btn" data-action="editAdminPersona" data-args='${JSON.stringify([p.id])}' title="Edit">✎</button>
|
||
<button class="icon-btn" data-action="toggleAdminPersona" data-args='${JSON.stringify([p.id, !p.is_active])}' title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
|
||
<button class="icon-btn icon-btn-danger" data-action="deleteAdminPersona" data-args='${JSON.stringify([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="icon-btn" data-action="UI.openTeamDetail" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Members">👥</button>
|
||
<button class="icon-btn" data-action="exportTeamData" data-args='${JSON.stringify([t.id, esc(t.name)])}' title="Export">📦</button>
|
||
<button class="icon-btn" data-action="toggleTeamActive" data-args='${JSON.stringify([t.id, !t.is_active])}' title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
|
||
<button class="icon-btn icon-btn-danger" data-action="deleteTeam" data-args='${JSON.stringify([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="icon-btn icon-btn-danger" data-action="removeTeamMember" data-args='${JSON.stringify([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="icon-btn" data-action="UI.openGroupDetail" data-args='${JSON.stringify([g.id, esc(g.name)])}' title="Members">👥</button>
|
||
<button class="icon-btn icon-btn-danger" data-action="deleteGroup" data-args='${JSON.stringify([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 + available permissions + enabled models in parallel
|
||
try {
|
||
const [g, permResp, modelsResp] = await Promise.all([
|
||
API.adminGetGroup(groupId),
|
||
API.adminListPermissions(),
|
||
API.listEnabledModels(),
|
||
]);
|
||
const scope = g.scope === 'global' ? 'Global group' : 'Team-scoped group';
|
||
document.getElementById('adminGroupDetailMeta').textContent = `${scope} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}`;
|
||
|
||
// ── Permission checkboxes ──
|
||
const allPerms = permResp.permissions || [];
|
||
const groupPerms = new Set(g.permissions || []);
|
||
const permDesc = {
|
||
'model.use': 'Use models for completions',
|
||
'model.select_any': 'Use any enabled model',
|
||
'kb.read': 'Search knowledge bases',
|
||
'kb.write': 'Upload / delete KB documents',
|
||
'kb.create': 'Create knowledge bases',
|
||
'channel.create': 'Create channels',
|
||
'channel.invite': 'Invite users to channels',
|
||
'persona.create': 'Create personas',
|
||
'persona.manage': 'Edit / delete team personas',
|
||
'workflow.create': 'Create workflows',
|
||
'task.create': 'Create scheduled tasks',
|
||
'task.admin': 'Manage all tasks + config',
|
||
'admin.view': 'Read-only admin access',
|
||
'token.unlimited': 'Bypass token budgets',
|
||
};
|
||
const permEl = document.getElementById('adminGroupPermissions');
|
||
permEl.innerHTML = allPerms.map(p => `<label class="checkbox-label" style="font-size:12px;display:flex;align-items:baseline;gap:6px;margin-bottom:4px">
|
||
<input type="checkbox" class="group-perm-cb" value="${esc(p)}" ${groupPerms.has(p) ? 'checked' : ''}>
|
||
<span><strong>${esc(p)}</strong> <span class="text-muted">\u2014 ${esc(permDesc[p] || p)}</span></span>
|
||
</label>`).join('');
|
||
|
||
// ── Token budget fields ──
|
||
document.getElementById('adminGroupBudgetDaily').value = g.token_budget_daily != null ? g.token_budget_daily : '';
|
||
document.getElementById('adminGroupBudgetMonthly').value = g.token_budget_monthly != null ? g.token_budget_monthly : '';
|
||
|
||
// ── Model allowlist checkboxes ──
|
||
const models = (modelsResp.data || modelsResp.models || []).filter(m => !m.is_persona);
|
||
const allowedSet = g.allowed_models ? new Set(g.allowed_models) : null;
|
||
const modelEl = document.getElementById('adminGroupModels');
|
||
const seen = new Set();
|
||
const unique = models.filter(m => { if (seen.has(m.model_id)) return false; seen.add(m.model_id); return true; });
|
||
unique.sort((a, b) => a.model_id.localeCompare(b.model_id));
|
||
modelEl.innerHTML = unique.map(m => `<label class="checkbox-label" style="font-size:12px;display:flex;align-items:baseline;gap:6px;margin-bottom:4px">
|
||
<input type="checkbox" class="group-model-cb" value="${esc(m.model_id)}" ${allowedSet === null || allowedSet.has(m.model_id) ? 'checked' : ''}>
|
||
<span>${esc(m.display_name || m.model_id)} <span class="text-muted">${esc(m.provider_name || '')}</span></span>
|
||
</label>`).join('') || '<span class="text-muted" style="font-size:12px">No models available</span>';
|
||
} catch (e) {
|
||
console.error('openGroupDetail:', e);
|
||
}
|
||
|
||
// Wire save button
|
||
document.getElementById('adminGroupSavePermsBtn').onclick = () => this._saveGroupPerms(groupId);
|
||
|
||
await this.loadGroupMembers(groupId);
|
||
},
|
||
|
||
async _saveGroupPerms(groupId) {
|
||
const status = document.getElementById('adminGroupSaveStatus');
|
||
status.textContent = 'Saving\u2026';
|
||
|
||
const perms = [...document.querySelectorAll('.group-perm-cb:checked')].map(cb => cb.value);
|
||
const dailyRaw = document.getElementById('adminGroupBudgetDaily').value.trim();
|
||
const monthlyRaw = document.getElementById('adminGroupBudgetMonthly').value.trim();
|
||
const allModelCbs = document.querySelectorAll('.group-model-cb');
|
||
const checkedModelCbs = document.querySelectorAll('.group-model-cb:checked');
|
||
const allChecked = allModelCbs.length > 0 && checkedModelCbs.length === allModelCbs.length;
|
||
|
||
const update = { permissions: perms };
|
||
|
||
if (dailyRaw === '') { update.clear_budget_daily = true; }
|
||
else { update.token_budget_daily = parseInt(dailyRaw, 10); }
|
||
if (monthlyRaw === '') { update.clear_budget_monthly = true; }
|
||
else { update.token_budget_monthly = parseInt(monthlyRaw, 10); }
|
||
|
||
if (allChecked || allModelCbs.length === 0) { update.clear_allowed_models = true; }
|
||
else { update.allowed_models = [...checkedModelCbs].map(cb => cb.value); }
|
||
|
||
try {
|
||
await API.adminUpdateGroup(groupId, update);
|
||
status.textContent = 'Saved \u2713';
|
||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||
} catch (e) {
|
||
status.textContent = 'Error: ' + e.message;
|
||
}
|
||
},
|
||
|
||
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="icon-btn icon-btn-danger" data-action="removeGroupMember" data-args='${JSON.stringify([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" data-action="saveGrant" data-args='${JSON.stringify([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="icon-btn" data-action="UI._showRoutingForm" data-args='${JSON.stringify([esc(p.id)])}' title="Edit">✎</button>
|
||
<button class="icon-btn" data-action="UI._toggleRoutingPolicy" data-args='${JSON.stringify([esc(p.id), !p.is_active])}' title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
|
||
<button class="icon-btn icon-btn-danger" data-action="UI._deleteRoutingPolicy" data-args='${JSON.stringify([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" data-action="UI._deleteCapOverride" data-args='${JSON.stringify([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" data-action="UI._purgeChannel" data-args='${JSON.stringify([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');
|
||
}
|
||
},
|
||
|
||
});
|
||
|
||
// ── Exports ─────────────────────────────────
|
||
sb.ns('ADMIN_SECTIONS', ADMIN_SECTIONS);
|
||
sb.ns('ADMIN_LABELS', ADMIN_LABELS);
|
||
sb.ns('ADMIN_LOADERS', ADMIN_LOADERS);
|