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

@@ -258,6 +258,16 @@
.sidebar-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
.sidebar.collapsed .sidebar-notes-btn .sb-label { opacity: 0; width: 0; }
.sidebar.collapsed .sidebar-notes-btn { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar-editor-btn {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px; color: var(--text-2); text-decoration: none;
font-size: 13px; white-space: nowrap;
transition: background var(--transition), color var(--transition);
}
.sidebar-editor-btn:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
.sidebar-editor-btn svg { flex-shrink: 0; }
.sidebar.collapsed .sidebar-editor-btn .sb-label { opacity: 0; width: 0; }
.sidebar.collapsed .sidebar-editor-btn { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .sidebar-bottom-divider { margin: 4px 12px; }
.user-btn {

View File

@@ -192,7 +192,7 @@
padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px;
min-height: 52px;
}
.admin-user-info { flex: 1; }
.admin-user-info { flex: 1; text-align: left; min-width: 0; }
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }

View File

@@ -248,6 +248,14 @@
}
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
.settings-content .settings-section {
background: none; border: none; border-radius: 0;
padding: 0 0 16px; margin-bottom: 20px; max-width: 560px;
}
.settings-content .settings-section h3 {
font-size: 14px; font-weight: 600; margin: 0 0 14px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
.settings-section {
margin-bottom: 24px; padding: 20px;
background: var(--bg-surface); border-radius: 10px;
@@ -328,16 +336,6 @@
}
.admin-content-body { flex: 1; overflow-y: auto; padding: 24px; }
/* ── Stat Cards ──────────────────────────── */
.stat-cards { display: flex; gap: 12px; margin-bottom: 20px; }
.stat-card {
padding: 16px 20px; background: var(--bg-surface);
border: 1px solid var(--border); border-radius: 10px; flex: 1;
}
.stat-card-value { font-size: 24px; font-weight: 700; }
.stat-card-label { font-size: 12px; color: var(--text-2); margin-top: 2px; }
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
/* ── Data Tables ─────────────────────────── */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
@@ -419,6 +417,103 @@
padding: 8px 12px; border-top: 1px solid var(--border);
}
/* ── Stat Cards Grid ──────────────────────── */
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px;
}
.stat-card-label { font-size: 11px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
.stat-card-value { font-size: 24px; font-weight: 700; line-height: 1; }
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
/* ── Bar Chart ───────────────────────────── */
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 140px; }
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--font-mono, monospace); }
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: 4px; position: relative; height: 100px; }
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; }
.bar-chart-label { font-size: 10px; color: var(--text-3); }
/* ── Admin Settings Form (flat sections, prototype match) ── */
.admin-settings-form { max-width: 600px; }
.admin-settings-form .settings-section {
background: none; border: none; border-radius: 0;
padding: 0 0 16px; margin-bottom: 20px;
}
.admin-settings-form .settings-section h3 {
font-size: 14px; font-weight: 600; margin: 0 0 14px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
.admin-settings-form .toggle-label { margin-bottom: 8px; }
/* ── Toggle Switch ────────────────────────── */
.toggle-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-1); }
.toggle-label input[type="checkbox"] { display: none; }
.toggle-track {
position: relative; width: 36px; height: 20px; flex-shrink: 0;
background: var(--text-3); border-radius: 10px; transition: background 0.2s;
}
.toggle-track::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: transform 0.2s;
}
.toggle-label input:checked + .toggle-track { background: var(--accent); }
.toggle-label input:checked + .toggle-track::after { transform: translateX(16px); }
.toggle-hint { font-size: 11px; color: var(--text-3); margin-left: 46px; margin-top: 2px; }
/* ── Admin Table ─────────────────────────── */
.admin-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }
.admin-table th {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-3); padding: 10px 12px; border-bottom: 1px solid var(--border);
font-weight: 600; white-space: nowrap;
}
.admin-table td { padding: 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table tbody tr { transition: background var(--transition); }
.admin-table tbody tr:hover { background: var(--bg-hover); }
.admin-table-footer {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; font-size: 12px; color: var(--text-3);
}
.admin-avatar {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600;
background: var(--accent-dim); color: var(--accent);
}
.admin-user-name { font-weight: 600; font-size: 13px; }
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--font-mono, monospace); }
.admin-actions-cell { white-space: nowrap; text-align: right; }
.btn-icon {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 4px; font-size: 14px; border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
.btn-icon:hover { color: var(--text); background: var(--bg-hover); }
.btn-icon-danger:hover { color: var(--danger); }
.badge-service { background: var(--purple-dim); color: var(--purple); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-disabled { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-active { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
/* ── Provider Cards ──────────────────────── */
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.provider-card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition);
}
.provider-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.provider-card-name { font-weight: 600; font-size: 14px; }
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
.badge-healthy { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-degraded { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-unhealthy { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
.badge-unknown { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
.badge-models { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
.badge-provider-type { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
@media (max-width: 768px) {
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; }

View File

@@ -17,38 +17,37 @@ async function toggleUserActive(id, active) {
}
async function showApproveForm(userId, username) {
const form = document.getElementById(`approveForm-${userId}`);
const teamsEl = document.getElementById(`approveTeams-${userId}`);
if (!form || !teamsEl) return;
// Load teams for checkboxes
document.getElementById('approveUserId').value = userId;
document.getElementById('approveUserTitle').textContent = `Approve "${username}"`;
const teamsEl = document.getElementById('approveTeamsList');
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
form.style.display = '';
openModal('approveUserModal');
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
if (teams.length === 0) {
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
} else {
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:4px">Assign to teams:</label>' +
teams.map(t => `<label class="checkbox-label" style="font-size:13px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> ${esc(t.name)}</label>`).join('');
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:8px;display:block">Assign to teams:</label>' +
teams.map(t => `<label class="toggle-label" style="margin-bottom:6px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> <span>${esc(t.name)}</span></label>`).join('');
}
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
}
function hideApproveForm(userId) {
const form = document.getElementById(`approveForm-${userId}`);
if (form) form.style.display = 'none';
closeModal('approveUserModal');
}
async function submitApproval(userId) {
const form = document.getElementById(`approveForm-${userId}`);
const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value);
if (!userId) userId = document.getElementById('approveUserId').value;
const teamIds = [...(document.querySelectorAll('#approveTeamsList .approve-team-cb:checked') || [])].map(cb => cb.value);
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(userId, true, teamIds, 'member');
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
UI.toast(msg, 'success');
closeModal('approveUserModal');
await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
@@ -73,27 +72,38 @@ async function createAdminUser() {
const r = document.getElementById('adminNewRole').value;
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
await API.adminCreateUser(u, e, p, r);
document.getElementById('adminAddUserForm').style.display = 'none';
closeModal('createUserModal');
// Clear form fields
document.getElementById('adminNewUsername').value = '';
document.getElementById('adminNewEmail').value = '';
document.getElementById('adminNewPassword').value = '';
document.getElementById('adminNewRole').value = 'user';
UI.toast('User created', 'success');
await UI.loadAdminUsers();
} 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 (!await showConfirm(`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'); }
// Show the reset password modal
document.getElementById('resetPwUser').textContent = username;
document.getElementById('resetPwInput').value = '';
openModal('resetPwModal');
document.getElementById('resetPwInput').focus();
// Wire up the confirm button (remove old listener, add new)
const btn = document.getElementById('resetPwConfirmBtn');
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async () => {
const pw = document.getElementById('resetPwInput').value;
if (!pw || pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
closeModal('resetPwModal');
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
}
// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
@@ -197,7 +207,7 @@ function ensureAdminPersonaForm() {
kbScope: 'admin',
onSubmit: (vals) => createAdminPersona(vals),
onCancel: () => {
container.style.display = 'none';
closeModal('personaFormModal');
_editingPersonaId = null;
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
@@ -262,7 +272,8 @@ function editAdminPersona(id) {
ensureAdminPersonaForm();
const form = _adminPersonaForm;
if (!form) return;
document.getElementById('adminAddPersonaForm').style.display = '';
document.getElementById('personaFormTitle').textContent = 'Edit Persona';
openModal('personaFormModal');
form.setValues(p);
form.setSubmitLabel('Update');
// Load KB bindings (v0.17.0)
@@ -312,7 +323,7 @@ async function createAdminPersona(vals) {
}
_editingPersonaId = null;
document.getElementById('adminAddPersonaForm').style.display = 'none';
closeModal('personaFormModal');
if (_adminPersonaForm) {
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
@@ -467,17 +478,18 @@ function _initAdminListeners() {
// Admin — users
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddUserForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
openModal('createUserModal');
});
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — approve user modal
document.getElementById('approveUserSubmitBtn')?.addEventListener('click', () => submitApproval());
// Admin — providers (form managed by primitives in loadAdminProviders)
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddProviderForm');
if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
f.style.display = f.style.display === 'none' ? '' : 'none';
document.getElementById('providerFormTitle').textContent = 'Add Provider';
openModal('providerFormModal');
});
// Admin — models
@@ -490,19 +502,17 @@ function _initAdminListeners() {
_editingPersonaId = null;
form.setSubmitLabel('Create');
form.clearForm();
const f = document.getElementById('adminAddPersonaForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
document.getElementById('personaFormTitle').textContent = 'Create Persona';
openModal('personaFormModal');
});
// Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = '';
openModal('createTeamModal');
document.getElementById('adminTeamName').focus();
});
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = 'none';
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
closeModal('createTeamModal');
});
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminTeamName').value.trim();
@@ -510,7 +520,7 @@ function _initAdminListeners() {
if (!name) return UI.toast('Team name required', 'error');
try {
await API.adminCreateTeam(name, desc);
document.getElementById('adminAddTeamForm').style.display = 'none';
closeModal('createTeamModal');
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
UI.toast('Team created');
@@ -559,7 +569,7 @@ function _initAdminListeners() {
// ── Group management ────────────────────────
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddGroupForm').style.display = '';
openModal('createGroupModal');
document.getElementById('adminGroupName').focus();
// Populate team dropdown
try {
@@ -574,9 +584,7 @@ function _initAdminListeners() {
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
});
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
document.getElementById('adminAddGroupForm').style.display = 'none';
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
closeModal('createGroupModal');
});
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminGroupName').value.trim();
@@ -587,7 +595,7 @@ function _initAdminListeners() {
if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
try {
await API.adminCreateGroup(name, desc, scope, teamId);
document.getElementById('adminAddGroupForm').style.display = 'none';
closeModal('createGroupModal');
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
UI.toast('Group created');
@@ -783,7 +791,7 @@ function editAdminExtension(id) {
`;
// Find the extension row and insert the form after it
const rows = document.querySelectorAll('#adminExtensionsList .admin-user-row');
const rows = document.querySelectorAll('#adminExtensionsList tbody tr');
for (const row of rows) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML);

View File

@@ -16,42 +16,18 @@
// -- People -----------------------------------------------------------
SCAFFOLDING.users =
'<div id="adminUserList" class="admin-list"></div>' +
'<div id="adminAddUserForm" class="admin-inline-form" style="display:none">' +
'<h4>Create User</h4>' +
'<div class="form-row">' +
'<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>' +
'<div class="form-group"><label>Email</label><input type="text" id="adminNewEmail" placeholder="email@example.com"></div>' +
'</div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>' +
'<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminCreateUserBtn">Create User</button>' +
'<button class="btn-small" id="adminCancelUserBtn">Cancel</button>' +
'</div>' +
'</div>';
'<div id="adminUserList" class="admin-list"></div>';
SCAFFOLDING.teams =
'<div id="adminTeamList" class="admin-list"></div>' +
'<div id="adminAddTeamForm" class="admin-inline-form" style="display:none">' +
'<h4>Create Team</h4>' +
'<div class="form-group"><label>Name</label><input type="text" id="adminTeamName" placeholder="Team name"></div>' +
'<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Optional description"></div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminCreateTeamBtn">Create Team</button>' +
'<button class="btn-small" id="adminCancelTeamBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<div id="adminTeamDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminTeamBackBtn">&larr; Back</button>' +
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
'</div>' +
'<div style="margin-bottom:12px">' +
'<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (BYOK)</label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team providers</label>' +
'<label class="toggle-label"><input type="checkbox" id="adminTeamPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="adminTeamAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>' +
'</div>' +
'<div id="adminMemberList" class="admin-list"></div>' +
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
@@ -69,19 +45,6 @@
SCAFFOLDING.groups =
'<div id="adminGroupList" class="admin-list"></div>' +
'<div id="adminAddGroupForm" class="admin-inline-form" style="display:none">' +
'<h4>Create Group</h4>' +
'<div class="form-group"><label>Name</label><input type="text" id="adminGroupName" placeholder="Group name"></div>' +
'<div class="form-group"><label>Description</label><input type="text" id="adminGroupDesc" placeholder="Optional description"></div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Scope</label><select id="adminGroupScope"><option value="global">Global</option><option value="team">Team</option></select></div>' +
'<div class="form-group" id="adminGroupTeamRow" style="display:none"><label>Team</label><select id="adminGroupTeam"></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminCreateGroupBtn">Create Group</button>' +
'<button class="btn-small" id="adminCancelGroupBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<div id="adminGroupDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminGroupBackBtn">&larr; Back</button>' +
@@ -104,8 +67,7 @@
// -- AI ---------------------------------------------------------------
SCAFFOLDING.providers =
'<div id="adminProviderList" class="admin-list"></div>' +
'<div id="adminAddProviderForm" class="admin-inline-form" style="display:none"></div>';
'<div id="adminProviderList" class="provider-cards"></div>';
SCAFFOLDING.models =
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
@@ -115,8 +77,7 @@
'<div id="adminModelList" class="admin-list"></div>';
SCAFFOLDING.personas =
'<div id="adminPersonaList" class="admin-list"></div>' +
'<div id="adminAddPersonaForm" class="admin-inline-form" style="display:none"></div>';
'<div id="adminPersonaList" class="admin-list"></div>';
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
@@ -126,25 +87,25 @@
SCAFFOLDING.settings =
'<div class="admin-settings-form">' +
'<div class="settings-section"><h4>Registration</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminRegToggle"> Allow new user registration</label>' +
'<div class="settings-section"><h3>Registration</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminRegToggle"><span class="toggle-track"></span><span>Allow new user registration</span></label>' +
'<div class="form-group" style="margin-top:8px"><label>Default state for new users</label>' +
'<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>System Prompt</h4>' +
'<div class="settings-section"><h3>System Prompt</h3>' +
'<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
'</div>' +
'<div class="settings-section"><h4>Default Model</h4>' +
'<div class="settings-section"><h3>Default Model</h3>' +
'<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
'</div>' +
'<div class="settings-section"><h4>Policies</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle"> Allow BYOK (user API keys)</label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminUserPersonasToggle"> Allow user personas</label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Allow direct KB access (non-persona)</label>' +
'<div class="settings-section"><h3>Policies</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminUserProvidersToggle"><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="adminUserPersonasToggle"><span class="toggle-track"></span><span>Allow user personas</span></label>' +
'<label class="toggle-label"><input type="checkbox" id="adminKBDirectAccessToggle"><span class="toggle-track"></span><span>Allow direct KB access (non-persona)</span></label>' +
'</div>' +
'<div class="settings-section"><h4>Environment Banner</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>' +
'<div class="settings-section"><h3>Environment Banner</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminBannerEnabled"><span class="toggle-track"></span><span>Show environment banner</span></label>' +
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
'<div class="form-row">' +
@@ -154,7 +115,7 @@
'<div id="bannerPreview" class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px">PREVIEW</div>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>Web Search</h4>' +
'<div class="settings-section"><h3>Web Search</h3>' +
'<div class="form-group"><label>Provider</label>' +
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
'</div>' +
@@ -164,8 +125,8 @@
'</div>' +
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
'</div>' +
'<div class="settings-section"><h4>Auto-Compaction</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminCompactionEnabled"> Enable auto-compaction</label>' +
'<div class="settings-section"><h3>Auto-Compaction</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminCompactionEnabled"><span class="toggle-track"></span><span>Enable auto-compaction</span></label>' +
'<div id="compactionConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-row">' +
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' +
@@ -173,13 +134,13 @@
'</div>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>Memory Extraction</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryExtractionEnabled"> Enable memory extraction</label>' +
'<div class="settings-section"><h3>Memory Extraction</h3>' +
'<label class="toggle-label"><input type="checkbox" id="adminMemoryExtractionEnabled"><span class="toggle-track"></span><span>Enable memory extraction</span></label>' +
'<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">' +
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryAutoApprove"> Auto-approve extracted memories</label>' +
'<label class="toggle-label"><input type="checkbox" id="adminMemoryAutoApprove"><span class="toggle-track"></span><span>Auto-approve extracted memories</span></label>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>Encryption Vault</h4>' +
'<div class="settings-section"><h3>Encryption Vault</h3>' +
'<div id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
'</div>' +
'<div id="roleFallbackBanner"></div>' +
@@ -206,7 +167,7 @@
'<div class="form-group"><label>Description</label><textarea id="extInstallDesc" rows="2" placeholder="What it does..."></textarea></div>' +
'<div class="form-group"><label>Manifest JSON</label><textarea id="extInstallManifest" rows="4"></textarea></div>' +
'<div class="form-group"><label>Script Source</label><textarea id="extInstallScript" rows="6"></textarea></div>' +
'<label class="checkbox-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>' +
'<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked><span class="toggle-track"></span><span>Enabled</span></label>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
@@ -216,8 +177,8 @@
// -- Routing ----------------------------------------------------------
SCAFFOLDING.health =
'<div style="margin-bottom:12px"><button class="btn-small" id="adminHealthRefreshBtn">Refresh</button></div>' +
'<div id="adminHealthContent"><div class="loading">Loading...</div></div>';
'<div id="adminHealthCards" class="stat-cards-grid"></div>' +
'<div id="adminHealthContent"></div>';
SCAFFOLDING.routing =
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
@@ -238,6 +199,8 @@
// -- Monitoring -------------------------------------------------------
SCAFFOLDING.usage =
'<div id="adminUsageCards" class="stat-cards-grid"></div>' +
'<div id="adminUsageChart" style="margin-bottom:24px"></div>' +
'<div id="adminUsageTotals"></div>' +
'<div id="adminUsageResults" style="margin-top:12px"></div>' +
'<div id="adminPricingTable" style="margin-top:24px"></div>';
@@ -255,38 +218,39 @@
'</div>';
SCAFFOLDING.stats =
'<div id="adminStats"><div class="loading">Loading...</div></div>';
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
// === Add button actions ==============================================
var ADD_ACTIONS = {
users: function() {
var f = document.getElementById('adminAddUserForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
openModal('createUserModal');
var n = document.getElementById('adminNewUsername');
if (n) n.focus();
},
teams: function() {
var f = document.getElementById('adminAddTeamForm');
if (f) f.style.display = '';
openModal('createTeamModal');
var n = document.getElementById('adminTeamName');
if (n) n.focus();
},
providers: function() {
var f = document.getElementById('adminAddProviderForm');
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode();
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
document.getElementById('providerFormTitle').textContent = 'Add Provider';
openModal('providerFormModal');
},
personas: function() {
if (typeof ensureAdminPersonaForm === 'function') {
var form = ensureAdminPersonaForm();
if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
}
var f = document.getElementById('adminAddPersonaForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
document.getElementById('personaFormTitle').textContent = 'Create Persona';
openModal('personaFormModal');
},
groups: function() {
var f = document.getElementById('adminAddGroupForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
openModal('createGroupModal');
var n = document.getElementById('adminGroupName');
if (n) n.focus();
},
extensions: function() {
var f = document.getElementById('adminInstallExtForm');
@@ -359,6 +323,8 @@
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
if (typeof _initAdminListeners === 'function') _initAdminListeners();
// -- Wire settings toggle show/hide listeners (banner, compaction, etc.) --
if (typeof _initAdminSettingsToggles === 'function') _initAdminSettingsToggles();
// -- Call the section data loader --------
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) {

View File

@@ -377,6 +377,12 @@ function _cleanStaleChatModel(chatId) {
}
}
/** Close the new-chat dropdown and reset any fixed positioning from collapsed mode. */
function closeNewChatMenu() {
const dd = document.getElementById('newChatDropdown');
if (dd) { dd.classList.remove('open'); dd.style.cssText = ''; }
}
async function newChat() {
clearStaged(); // Discard any staged files
App.currentChatId = null;
@@ -822,7 +828,21 @@ async function switchSibling(messageId, direction) {
function _initChatListeners() {
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('newChatBtn').addEventListener('click', (e) => {
const sidebar = document.querySelector('.sidebar');
if (sidebar && sidebar.classList.contains('collapsed')) {
// Collapsed: show the dropdown menu, positioned to pop out right
const dd = document.getElementById('newChatDropdown');
const rect = e.currentTarget.getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.left = rect.right + 4 + 'px';
dd.style.top = rect.top + 'px';
dd.style.width = '200px';
dd.classList.toggle('open');
} else {
newChat();
}
});
// Split button dropdown
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
@@ -831,7 +851,9 @@ function _initChatListeners() {
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.split-btn')) {
document.getElementById('newChatDropdown').classList.remove('open');
const dd = document.getElementById('newChatDropdown');
dd.classList.remove('open');
dd.style.cssText = ''; // Reset any fixed positioning from collapsed mode
}
});

View File

@@ -263,10 +263,10 @@ async function handleSaveAdminSettings() {
UI.toast('Settings saved', 'success');
// Live-apply: refresh policies and dependent UI
await initBanners();
UI.checkUserProvidersAllowed();
UI.checkUserPersonasAllowed();
fetchModels();
if (typeof initBanners === 'function') await initBanners();
if (typeof UI.checkUserProvidersAllowed === 'function') UI.checkUserProvidersAllowed();
if (typeof UI.checkUserPersonasAllowed === 'function') UI.checkUserPersonasAllowed();
if (typeof fetchModels === 'function') fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -662,6 +662,32 @@ function _initSettingsListeners() {
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
});
}
// Admin settings toggle wiring — safe to call from admin surface scaffold
// (no App dependencies, all optional-chained)
function _initAdminSettingsToggles() {
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
});
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
});
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
document.getElementById('adminCompactionEnabled')?.addEventListener('change', (e) => {
document.getElementById('compactionConfigFields').style.display = e.target.checked ? '' : 'none';
});
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
});
// Memory extraction toggle
document.getElementById('adminMemoryExtractionEnabled')?.addEventListener('change', (e) => {
const f = document.getElementById('memoryExtractionConfigFields');
if (f) f.style.display = e.target.checked ? '' : 'none';
});
// Admin — audit log
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));

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>`;
}

View File

@@ -141,7 +141,7 @@ Object.assign(UI, {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
const theme = prefs.theme || 'dark';
const theme = prefs.theme || 'system';
const keymap = prefs.editorKeymap || 'standard';
const scaleEl = document.getElementById('settingsScale');
@@ -164,7 +164,7 @@ Object.assign(UI, {
// Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
UI.applyTheme(prefs.theme || 'dark');
UI.applyTheme(prefs.theme || 'system');
// Live preview on slider change
const scaleEl = document.getElementById('settingsScale');
@@ -380,21 +380,27 @@ Object.assign(UI, {
try {
const resp = await API.teamListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row" style="padding:6px 0">
<div class="admin-user-info">
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span>
</div>
<div class="admin-user-actions">
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members</div>';
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members</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;font-size:13px">${esc(m.display_name || m.email)}</div>
${m.user_role === 'admin' ? '<span class="badge-admin" style="font-size:9px">sys-admin</span>' : ''}
</td>
<td>
<select onchange="settingsUpdateTeamMember('${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' : ''}>Admin</option>
</select>
</td>
<td class="admin-actions-cell">
<button class="btn-icon btn-icon-danger" onclick="settingsRemoveTeamMember('${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>`; }
},
@@ -515,17 +521,45 @@ Object.assign(UI, {
try {
const resp = await API.teamListGroups(teamId);
const groups = resp.data || [];
el.innerHTML = groups.map(g => `
<div class="admin-user-row" style="padding:6px 0">
<div class="admin-user-info">
<div><strong>${esc(g.name)}</strong></div>
<div class="text-muted" style="font-size:11px">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
</div>
</div>
`).join('') || '<div class="empty-hint">No groups assigned to this team yet</div>';
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups assigned to this team yet</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Group</th><th>Members</th></tr></thead>
<tbody>${groups.map(g => `<tr>
<td><div style="font-weight:500">${esc(g.name)}</div><div class="admin-user-handle">${esc(g.description || 'No description')}</div></td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
</tr>`).join('')}</tbody>
</table>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageSettings(teamId) {
if (!teamId) return;
try {
const team = await API.teamGetTeam ? API.teamGetTeam(teamId) : await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
const privEl = document.getElementById('teamSettingPrivatePolicy');
const allowEl = document.getElementById('teamSettingAllowProviders');
if (privEl) privEl.checked = !!settings.require_private_providers;
if (allowEl) allowEl.checked = settings.allow_team_providers !== false;
// Wire save button (once)
const saveBtn = document.getElementById('teamSettingSaveBtn');
if (saveBtn && !saveBtn._wired) {
saveBtn._wired = true;
saveBtn.addEventListener('click', async () => {
const tid = UI._managingTeamId;
const priv = document.getElementById('teamSettingPrivatePolicy')?.checked || false;
const allow = document.getElementById('teamSettingAllowProviders')?.checked !== false;
try {
await API.adminUpdateTeam(tid, { settings: JSON.stringify({ require_private_providers: priv, allow_team_providers: allow }) });
UI.toast('Team settings saved', 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
}
} catch (e) { /* proceed with defaults */ }
},
async loadMyUsage() {
const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return;