Changeset 0.10.0 (#56)

This commit is contained in:
2026-02-24 14:50:53 +00:00
parent cdfd69bad3
commit ea03f956ca
31 changed files with 3303 additions and 167 deletions

View File

@@ -310,7 +310,7 @@ const API = {
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); },
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
@@ -433,6 +433,60 @@ const API = {
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── HTTP Internals ───────────────────────
_esc(s) {

View File

@@ -1381,6 +1381,11 @@ function initListeners() {
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
// Admin — usage
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
@@ -1807,6 +1812,23 @@ async function createAdminUser() {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function adminResetUserPassword(id, username) {
const pw = prompt(
`Reset password for "${username}"?\n\n` +
`⚠️ WARNING: This will DESTROY the user's personal vault.\n` +
`All personal API keys (BYOK) will be permanently deleted.\n` +
`The user will need to re-add any personal provider keys.\n\n` +
`Enter new password (min 8 chars):`
);
if (!pw) return;
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteGlobalProvider(id) {
if (!confirm('Delete this global provider?')) return;
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
@@ -1905,6 +1927,59 @@ async function bulkSetVisibility(visibility) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Roles ────────────────────────────
async function adminRoleProviderChanged(role, slot) {
const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const modelSelect = document.getElementById(`role-${role}-${slot}-model`);
if (!modelSelect) return;
modelSelect.innerHTML = '<option value="">— select model —</option>';
if (!provId || !window._adminModelList) return;
const models = window._adminModelList.filter(m => m.provider_config_id === provId);
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id;
opt.textContent = m.display_name || m.model_id;
modelSelect.appendChild(opt);
});
}
async function adminSaveRole(role) {
const status = document.getElementById(`role-${role}-status`);
try {
const getBinding = (slot) => {
const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const model = document.getElementById(`role-${role}-${slot}-model`)?.value;
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
};
await API.adminUpdateRole(role, {
primary: getBinding('primary'),
fallback: getBinding('fallback')
});
if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; }
setTimeout(() => { if (status) status.textContent = ''; }, 3000);
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
async function adminTestRole(role) {
const status = document.getElementById(`role-${role}-status`);
if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; }
try {
const result = await API.adminTestRole(role);
if (status) {
const fb = result.used_fallback ? ' (fallback)' : '';
status.textContent = `${result.model}${fb}`;
status.style.color = 'var(--success)';
}
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) {

View File

@@ -843,6 +843,7 @@ const UI = {
UI.checkUserProvidersAllowed();
}
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
if (tab === 'usage') UI.loadMyUsage();
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'teams') UI.loadTeamsTab();
},
@@ -1000,6 +1001,7 @@ const UI = {
UI.loadTeamManageMembers(teamId),
UI.loadTeamManageProviders(teamId),
UI.loadTeamManagePresets(teamId),
UI.loadTeamUsage(),
UI.loadTeamAuditLog(1),
]);
},
@@ -1083,6 +1085,111 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMyUsage() {
const totalsEl = document.getElementById('myUsageTotals');
const resultsEl = document.getElementById('myUsageResults');
if (!totalsEl) return;
const period = document.getElementById('myUsagePeriod')?.value || '30d';
const groupBy = document.getElementById('myUsageGroupBy')?.value || 'model';
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.getMyUsage({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : 'Model'}</th>
<th style="text-align:right">Reqs</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" style="font-size:12px">${esc(e.message)}</div>`; }
},
async loadTeamUsage() {
const teamId = UI._managingTeamId;
if (!teamId) return;
const totalsEl = document.getElementById('settingsTeamUsageTotals');
const resultsEl = document.getElementById('settingsTeamUsageResults');
if (!totalsEl) return;
const period = document.getElementById('teamUsagePeriod')?.value || '30d';
const groupBy = document.getElementById('teamUsageGroupBy')?.value || 'model';
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.teamGetUsage(teamId, { period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded for team providers in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Reqs</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" style="font-size:12px">${esc(e.message)}</div>`; }
},
_teamAuditPage: 1,
_teamAuditPerPage: 20,
@@ -1253,6 +1360,8 @@ const UI = {
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
},
async loadAdminUsers(quiet) {
@@ -1280,6 +1389,7 @@ const UI = {
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
}
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>
@@ -1310,6 +1420,136 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Roles ────────────────────────
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', 'generation'];
el.innerHTML = roleNames.map(role => {
const cfg = roles[role] || { primary: null, fallback: null };
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).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).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('');
// Store models list for provider change handler
window._adminModelList = modelList;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
const period = document.getElementById('usagePeriod')?.value || '30d';
const groupBy = document.getElementById('usageGroupBy')?.value || 'model';
const totalsEl = document.getElementById('adminUsageTotals');
const resultsEl = document.getElementById('adminUsageResults');
const pricingEl = document.getElementById('adminPricingTable');
if (!totalsEl) return;
totalsEl.innerHTML = '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
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>`; }
},
_auditPage: 1,
_auditPerPage: 30,