Changeset 0.22.10 (#152)

This commit is contained in:
2026-03-04 22:37:57 +00:00
parent 6ed76fb2d3
commit 40d9834f64
15 changed files with 1014 additions and 515 deletions

View File

@@ -188,6 +188,7 @@ Object.assign(UI, {
if (tab === 'personas') UI.loadTeamManagePersonas(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'settings') UI.loadTeamManageSettings(teamId);
if (tab === 'knowledge') {
if (typeof KnowledgeUI === 'undefined') {
console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js');
@@ -206,37 +207,50 @@ Object.assign(UI, {
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(' ');
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;
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">
const statusCls = isPending ? 'badge-warning' : 'badge-active';
const statusLabel = isPending ? 'pending' : 'active';
const teams = (u.teams || []).map(t => esc(t.team_name)).join(', ') || '\u2014';
const lastSeen = u.last_login_at ? _relativeTime(u.last_login_at) : '\u2014';
return `<tr data-name="${esc(u.username)}">
<td><div style="display:flex;align-items:center;gap:10px">
<div class="admin-avatar">${initial}</div>
<div><div class="admin-user-name">${name}</div><div class="admin-user-handle">${handle}</div></div>
</div></td>
<td><span class="${roleCls}">${u.role}</span></td>
<td><span class="${statusCls}">${statusLabel}</span></td>
<td style="font-size:12px;color:var(--text-2)">${teams}</td>
<td class="admin-user-time">${lastSeen}</td>
<td class="admin-actions-cell">
${isPending
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
? `<button class="btn-icon" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button class="btn-icon" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
}
<button 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>
<button class="btn-icon" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button class="btn-icon" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="btn-icon btn-icon-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Teams</th><th>Last Seen</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="admin-table-footer">
<span>${users.length} user${users.length !== 1 ? 's' : ''}</span>
</div>`;
}).join('') || '<div class="empty-hint">No users</div>';
// Approve form placeholder (injected per-user when needed)
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -245,14 +259,12 @@ Object.assign(UI, {
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>';
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>`; }
},
@@ -291,10 +303,64 @@ Object.assign(UI, {
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
// Usage stats via primitive
// 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;
// Wrap totals+results in a container for the primitive (first time only)
let usageContainer = document.getElementById('_adminUsageContainer');
if (!usageContainer) {
usageContainer = document.createElement('div');
@@ -316,7 +382,7 @@ Object.assign(UI, {
}
await UI._adminUsageDash.refresh();
// Pricing table (admin-only, separate from usage primitive)
// Pricing table
const pricingEl = document.getElementById('adminPricingTable');
if (pricingEl) {
try {
@@ -351,47 +417,36 @@ Object.assign(UI, {
try {
const resp = await API._get('/api/v1/admin/extensions');
const exts = resp.data || [];
this._adminExtensions = exts; // store for edit access
this._adminExtensions = exts;
if (exts.length === 0) {
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
el.innerHTML = '<div class="empty-hint">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('');
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Extension</th><th>Status</th><th>Tier</th><th>Version</th><th></th></tr></thead>
<tbody>${exts.map(ext => {
const statusBadge = ext.is_enabled ? '<span class="badge-active">enabled</span>' : '<span class="badge-disabled">disabled</span>';
const systemBadge = ext.is_system ? ' <span class="badge-user">system</span>' : '';
return `<tr>
<td>
<div style="font-weight:500">${esc(ext.name)}${systemBadge}</div>
<div class="admin-user-handle">${esc(ext.description || 'No description')}${ext.author ? ' · by ' + esc(ext.author) : ''}</div>
</td>
<td>${statusBadge}</td>
<td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${exts.length} extension${exts.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) {
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
el.innerHTML = '<div class="empty-hint">Failed to load extensions</div>';
}
},
@@ -433,24 +488,24 @@ Object.assign(UI, {
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('');
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
@@ -478,7 +533,7 @@ Object.assign(UI, {
const el = document.getElementById('adminProviderList');
const formEl = document.getElementById('adminAddProviderForm');
// Initialize admin provider form primitive (once)
// Initialize admin provider form primitive (once) — now renders inside modal body
if (!UI._adminProvForm && formEl) {
UI._adminProvForm = renderProviderForm(formEl, {
prefix: 'adminProv',
@@ -500,43 +555,90 @@ Object.assign(UI, {
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';
closeModal('providerFormModal');
UI._adminProvForm.setCreateMode();
UI._adminProvList.refresh();
UI.loadAdminProviders(true);
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
closeModal('providerFormModal');
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'); }
},
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');
});
}
await UI._adminProvList.refresh(quiet);
},
async loadAdminModels(quiet) {
@@ -546,20 +648,26 @@ Object.assign(UI, {
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>';
if (!arr.length) { el.innerHTML = '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Model</th><th>Provider</th><th>Capabilities</th><th>Visibility</th></tr></thead>
<tbody>${arr.map(m => {
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<tr>
<td style="font-weight:500;font-family:var(--font-mono,monospace);font-size:12px">${esc(m.model_id || m.id)}</td>
<td style="font-size:12px;color:var(--text-3)">${esc(m.provider_name || '')}</td>
<td>${badges}</td>
<td><button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}','${vis}')">${visLabel}</button></td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${arr.length} model${arr.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -607,36 +715,44 @@ Object.assign(UI, {
}
UI._personaCache = {};
el.innerHTML = list.map(p => {
UI._personaCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="persona-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-persona-row" data-grant-persona="${p.id}">
<div class="persona-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="persona-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="persona-actions">
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona=&quot;${p.id}&quot;]')" title="Manage access">🔒 Access</button>
<button class="btn-edit" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPersona('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>';
if (!list.length) { el.innerHTML = '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Persona</th><th>Scope</th><th>Model</th><th>Status</th><th></th></tr></thead>
<tbody>${list.map(p => {
UI._personaCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" style="width:24px;height:24px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:6px" alt="">`
: '';
let scopeBadge = '';
if (p.scope === 'global') scopeBadge = '<span class="badge-admin">global</span>';
else if (p.scope === 'team' && p.team_name) scopeBadge = `<span class="badge-team">${esc(p.team_name)}</span>`;
else if (p.scope === 'personal') scopeBadge = '<span class="badge-user">personal</span>';
const creator = p.creator_name ? `<div class="admin-user-handle">by ${esc(p.creator_name)}</div>` : '';
return `<tr data-grant-persona="${p.id}">
<td>
<div style="display:flex;align-items:center;gap:8px">
${avatarEl}
<div>
<div style="font-weight:500">${esc(p.name)}</div>
${p.description ? `<div class="admin-user-handle" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.description)}</div>` : creator}
</div>
</div>
</td>
<td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona=&quot;${p.id}&quot;]')" title="Access">🔒</button>
<button class="btn-icon" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="btn-icon" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${list.length} persona${list.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -650,33 +766,40 @@ Object.assign(UI, {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none';
closeModal('createTeamModal');
}
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>';
if (!teams.length) { el.innerHTML = '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Team</th><th>Status</th><th>Members</th><th></th></tr></thead>
<tbody>${teams.map(t => `<tr>
<td>
<div style="font-weight:500">${esc(t.name)}</div>
<div class="admin-user-handle">${esc(t.description || 'No description')}</div>
</td>
<td>
${t.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-warning">inactive</span>'}
</td>
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
<button class="btn-icon" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${teams.length} team${teams.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none';
closeModal('createTeamModal');
const detail = document.getElementById('adminTeamDetail');
detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName;
@@ -700,25 +823,29 @@ Object.assign(UI, {
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 = 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>';
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
<tbody>${members.map(m => `<tr>
<td>
<div style="font-weight:500">${esc(m.display_name || m.email)}</div>
<div class="admin-user-handle">${esc(m.email)}</div>
</td>
<td>
<select onchange="updateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -747,33 +874,42 @@ Object.assign(UI, {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddGroupForm').style.display = 'none';
closeModal('createGroupModal');
}
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>';
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Group</th><th>Scope</th><th>Members</th><th></th></tr></thead>
<tbody>${groups.map(g => {
const scopeBadge = g.scope === 'global'
? '<span class="badge-admin">global</span>'
: '<span class="badge-team">team</span>';
return `<tr>
<td>
<div style="font-weight:500">${esc(g.name)}</div>
<div class="admin-user-handle">${esc(g.description || 'No description')}</div>
</td>
<td>${scopeBadge}</td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
<button class="btn-icon btn-icon-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${groups.length} group${groups.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openGroupDetail(groupId, groupName) {
this._groupEditId = groupId;
document.getElementById('adminGroupList').style.display = 'none';
document.getElementById('adminAddGroupForm').style.display = 'none';
closeModal('createGroupModal');
const detail = document.getElementById('adminGroupDetail');
detail.style.display = '';
document.getElementById('adminGroupDetailName').textContent = groupName;
@@ -795,17 +931,22 @@ Object.assign(UI, {
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>';
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members — add users to this group</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Member</th><th>Added</th><th></th></tr></thead>
<tbody>${members.map(m => `<tr>
<td>
<div style="font-weight:500">${esc(m.display_name || m.username || m.email)}</div>
<div class="admin-user-handle">${esc(m.email)}</div>
</td>
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -879,11 +1020,11 @@ Object.assign(UI, {
// ── 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>';
document.getElementById('adminHealthRefreshBtn')?.addEventListener('click', () => UI.loadAdminHealth(), { once: true });
try {
const data = await API.adminGetAllProviderHealth();
const providers = data.data || [];
@@ -893,54 +1034,48 @@ Object.assign(UI, {
return;
}
el.innerHTML = '<div class="admin-health-grid"></div>';
const grid = el.querySelector('.admin-health-grid');
// 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' : '—';
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">Rate Limits</span>
<span class="admin-storage-value" style="color:${(p.rate_limit_count || 0) > 0 ? 'var(--warning)' : 'inherit'}">${p.rate_limit_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>`;
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>`;
}
@@ -971,26 +1106,23 @@ Object.assign(UI, {
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('');
listEl.innerHTML = `
<table class="admin-table">
<thead><tr><th>Policy</th><th>Type</th><th>Scope</th><th>Priority</th><th>Status</th><th></th></tr></thead>
<tbody>${policies.map(p => `<tr data-id="${esc(p.id)}">
<td style="font-weight:500">${esc(p.name)}</td>
<td><span class="badge-provider-type">${esc(typeLabels[p.policy_type] || p.policy_type)}</span></td>
<td style="font-size:12px;color:var(--text-3)">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</td>
<td class="admin-user-time">${p.priority}</td>
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<td class="admin-actions-cell">
<button class="btn-icon" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
<button class="btn-icon" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
<button class="btn-icon btn-icon-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${policies.length} polic${policies.length !== 1 ? 'ies' : 'y'}</span></div>`;
} catch (e) {
listEl.innerHTML = `<div class="empty-hint">Failed to load routing policies: ${esc(e.message)}</div>`;
}