Changeset 0.10.5 (#61)
This commit is contained in:
@@ -41,7 +41,12 @@ Object.assign(UI, {
|
||||
closeTeamAdmin() { closeModal('teamAdminModal'); },
|
||||
|
||||
switchTeamTab(tab) {
|
||||
document.querySelectorAll('#teamAdminTabs .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.ttab === tab));
|
||||
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
|
||||
console.log('switchTeamTab:', tab, 'found tabs:', tabs.length);
|
||||
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 = '';
|
||||
@@ -56,7 +61,7 @@ Object.assign(UI, {
|
||||
},
|
||||
|
||||
async switchAdminTab(tab) {
|
||||
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
|
||||
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||||
if (panel) panel.style.display = '';
|
||||
@@ -134,131 +139,88 @@ Object.assign(UI, {
|
||||
async loadAdminRoles() {
|
||||
const el = document.getElementById('adminRolesContent');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const roles = await API.adminListRoles();
|
||||
const configs = await API.adminListGlobalConfigs();
|
||||
const providers = configs.configs || configs || [];
|
||||
const models = await API.adminListModels();
|
||||
const modelList = models.models || models.data || models || [];
|
||||
|
||||
const roleNames = ['utility', 'embedding'];
|
||||
el.innerHTML = roleNames.map(role => {
|
||||
const cfg = roles[role] || { primary: null, fallback: null };
|
||||
const typeForRole = role === 'embedding' ? 'embedding' : 'chat';
|
||||
const typeFilter = m => (m.model_type || 'chat') === typeForRole;
|
||||
return `
|
||||
<div class="settings-section" style="margin-bottom:16px">
|
||||
<h3 style="font-size:14px;margin:0 0 8px;text-transform:capitalize">${role}</h3>
|
||||
<div class="form-row" style="gap:8px;align-items:center">
|
||||
<label style="min-width:60px;font-size:12px">Primary</label>
|
||||
<select id="role-${role}-primary-provider" onchange="adminRoleProviderChanged('${role}','primary')" style="min-width:140px">
|
||||
<option value="">— none —</option>
|
||||
${providers.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
|
||||
</select>
|
||||
<select id="role-${role}-primary-model" style="min-width:200px">
|
||||
<option value="">— select model —</option>
|
||||
${cfg.primary ? modelList.filter(m => m.provider_config_id === cfg.primary.provider_config_id && typeFilter(m)).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.primary.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="gap:8px;align-items:center;margin-top:6px">
|
||||
<label style="min-width:60px;font-size:12px">Fallback</label>
|
||||
<select id="role-${role}-fallback-provider" onchange="adminRoleProviderChanged('${role}','fallback')" style="min-width:140px">
|
||||
<option value="">— none —</option>
|
||||
${providers.map(p => `<option value="${p.id}" ${cfg.fallback?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
|
||||
</select>
|
||||
<select id="role-${role}-fallback-model" style="min-width:200px">
|
||||
<option value="">— select model —</option>
|
||||
${cfg.fallback ? modelList.filter(m => m.provider_config_id === cfg.fallback.provider_config_id && typeFilter(m)).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.fallback.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="gap:8px;margin-top:8px">
|
||||
<button class="btn-primary btn-small" onclick="adminSaveRole('${role}')">Save</button>
|
||||
<button class="btn-secondary btn-small" onclick="adminTestRole('${role}')">Test</button>
|
||||
<span id="role-${role}-status" style="font-size:12px"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
// Store models list for provider change handler
|
||||
window._adminModelList = modelList;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
await UI._adminRoleConfig.refresh();
|
||||
},
|
||||
|
||||
// ── Admin: Usage ────────────────────────
|
||||
|
||||
async loadAdminUsage() {
|
||||
const period = document.getElementById('usagePeriod')?.value || '30d';
|
||||
const groupBy = document.getElementById('usageGroupBy')?.value || 'model';
|
||||
|
||||
// Usage stats via primitive
|
||||
const totalsEl = document.getElementById('adminUsageTotals');
|
||||
const resultsEl = document.getElementById('adminUsageResults');
|
||||
const pricingEl = document.getElementById('adminPricingTable');
|
||||
if (!totalsEl) return;
|
||||
// Wrap totals+results in a container for the primitive (first time only)
|
||||
let usageContainer = document.getElementById('_adminUsageContainer');
|
||||
if (!usageContainer) {
|
||||
usageContainer = document.createElement('div');
|
||||
usageContainer.id = '_adminUsageContainer';
|
||||
const resultsEl = document.getElementById('adminUsageResults');
|
||||
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
|
||||
usageContainer.appendChild(totalsEl);
|
||||
if (resultsEl) usageContainer.appendChild(resultsEl);
|
||||
}
|
||||
|
||||
totalsEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
if (!UI._adminUsageDash) {
|
||||
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
|
||||
apiFetch: (p) => API.adminGetUsage(p),
|
||||
periodElId: 'usagePeriod',
|
||||
groupByElId: 'usageGroupBy',
|
||||
compact: false,
|
||||
showUserColumn: true,
|
||||
});
|
||||
}
|
||||
await UI._adminUsageDash.refresh();
|
||||
|
||||
try {
|
||||
const data = await API.adminGetUsage({ period, group_by: groupBy });
|
||||
const t = data.totals || {};
|
||||
|
||||
totalsEl.innerHTML = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-value">${(t.requests || 0).toLocaleString()}</div><div class="stat-label">Requests</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label">Input Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label">Output Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label">Est. Cost</div></div>
|
||||
</div>`;
|
||||
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
|
||||
} else {
|
||||
resultsEl.innerHTML = `
|
||||
<table class="admin-table" style="margin-top:12px">
|
||||
<thead><tr>
|
||||
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
|
||||
<th style="text-align:right">Requests</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Cost</th>
|
||||
</tr></thead>
|
||||
<tbody>${results.map(r => `<tr>
|
||||
<td>${esc(r.label || r.group_key)}</td>
|
||||
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
|
||||
// Load pricing table
|
||||
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>`; }
|
||||
// Pricing table (admin-only, separate from usage primitive)
|
||||
const pricingEl = document.getElementById('adminPricingTable');
|
||||
if (pricingEl) {
|
||||
try {
|
||||
const pricing = await API.adminListPricing();
|
||||
const entries = pricing || [];
|
||||
if (entries.length === 0) {
|
||||
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
|
||||
} else {
|
||||
pricingEl.innerHTML = `
|
||||
<table class="admin-table" style="margin-top:8px">
|
||||
<thead><tr>
|
||||
<th>Model</th>
|
||||
<th style="text-align:right">Input $/M</th>
|
||||
<th style="text-align:right">Output $/M</th>
|
||||
<th>Source</th>
|
||||
</tr></thead>
|
||||
<tbody>${entries.map(p => `<tr>
|
||||
<td>${esc(p.model_id)}</td>
|
||||
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
|
||||
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
|
||||
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
}
|
||||
},
|
||||
|
||||
_auditPage: 1,
|
||||
@@ -342,24 +304,67 @@ Object.assign(UI, {
|
||||
|
||||
async loadAdminProviders(quiet) {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListGlobalConfigs();
|
||||
const list = data.configs || data.data || data || [];
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
UI._providerCache = {};
|
||||
el.innerHTML = arr.map(c => {
|
||||
UI._providerCache[c.id] = c;
|
||||
return `<div class="admin-provider-row">
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${esc(c.provider)}</span>
|
||||
${c.is_private ? '<span class="badge-private">private</span>' : ''}
|
||||
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
||||
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
|
||||
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
const formEl = document.getElementById('adminAddProviderForm');
|
||||
|
||||
// Initialize admin provider form primitive (once)
|
||||
if (!UI._adminProvForm && formEl) {
|
||||
UI._adminProvForm = renderProviderForm(formEl, {
|
||||
prefix: 'adminProv',
|
||||
showPrivate: true,
|
||||
showDefaultModel: true,
|
||||
onSubmit: async (vals, isEdit) => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
const updates = {};
|
||||
if (vals.name) updates.name = vals.name;
|
||||
if (vals.endpoint) updates.endpoint = vals.endpoint;
|
||||
if (vals.api_key) updates.api_key = vals.api_key;
|
||||
updates.model_default = vals.model_default;
|
||||
updates.is_private = vals.is_private || false;
|
||||
await API.adminUpdateGlobalConfig(vals._editId, updates);
|
||||
UI.toast('Provider updated', 'success');
|
||||
} else {
|
||||
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
|
||||
UI.toast('Provider added', 'success');
|
||||
}
|
||||
formEl.style.display = 'none';
|
||||
UI._adminProvForm.setCreateMode();
|
||||
UI._adminProvList.refresh();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => {
|
||||
formEl.style.display = 'none';
|
||||
UI._adminProvForm.setCreateMode();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize admin provider list primitive (once)
|
||||
if (!UI._adminProvList) {
|
||||
UI._adminProvList = renderProviderList(el, {
|
||||
apiFetch: () => API.adminListGlobalConfigs(),
|
||||
showEndpoint: true,
|
||||
showPrivate: true,
|
||||
emptyMsg: 'No global providers — add one above',
|
||||
onEdit: (prov) => {
|
||||
if (UI._adminProvForm) {
|
||||
UI._adminProvForm.setEditMode(prov.id, prov);
|
||||
formEl.style.display = '';
|
||||
}
|
||||
},
|
||||
onDelete: async (prov) => {
|
||||
if (!await showConfirm('Delete this global provider?')) return;
|
||||
try {
|
||||
await API.adminDeleteGlobalConfig(prov.id);
|
||||
UI.toast('Provider deleted', 'success');
|
||||
UI._adminProvList.refresh();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await UI._adminProvList.refresh(quiet);
|
||||
},
|
||||
|
||||
async loadAdminModels(quiet) {
|
||||
@@ -371,18 +376,14 @@ Object.assign(UI, {
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
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.join('')}</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>`;
|
||||
|
||||
Reference in New Issue
Block a user