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

@@ -1 +1 @@
0.22.9 0.22.10

View File

@@ -30,10 +30,22 @@
.banner { flex-shrink: 0; } .banner { flex-shrink: 0; }
</style> </style>
<meta name="theme-color" content="#0e0e10"> <meta name="theme-color" content="#0e0e10">
<script>
// Early theme application — prevents flash of wrong theme on all surfaces
(function() {
try {
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
var mode = p.theme || 'system';
var resolved = mode;
if (mode === 'system') resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (resolved === 'light') document.documentElement.setAttribute('data-theme', 'light');
} catch(e) {}
})();
</script>
</head> </head>
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}" data-theme="{{or .Theme "dark"}}"> <body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-top" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
{{.Banner.Text}} {{.Banner.Text}}
</div> </div>
{{end}} {{end}}
@@ -49,7 +61,7 @@
</div> </div>
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-bottom" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
{{.Banner.Text}} {{.Banner.Text}}
</div> </div>
{{end}} {{end}}

View File

@@ -211,7 +211,7 @@
<div class="auth-tabs anim anim-a2"> <div class="auth-tabs anim anim-a2">
<button class="auth-tab active" id="tabLogin" onclick="_switchTab('login')">Log In</button> <button class="auth-tab active" id="tabLogin" onclick="_switchTab('login')">Log In</button>
<button class="auth-tab" id="tabRegister" onclick="_switchTab('register')">Register</button> {{if .RegistrationOpen}}<button class="auth-tab" id="tabRegister" onclick="_switchTab('register')">Register</button>{{end}}
</div> </div>
<!-- LOGIN FORM --> <!-- LOGIN FORM -->
@@ -229,6 +229,7 @@
</div> </div>
<!-- REGISTER FORM --> <!-- REGISTER FORM -->
{{if .RegistrationOpen}}
<div id="registerForm" style="display:none;"> <div id="registerForm" style="display:none;">
<div class="form-field"> <div class="form-field">
<label for="regUsername">Username</label> <label for="regUsername">Username</label>
@@ -249,6 +250,7 @@
<p class="auth-error" id="regError" style="display:none;"></p> <p class="auth-error" id="regError" style="display:none;"></p>
<button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button> <button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button>
</div> </div>
{{end}}
<div class="auth-footer"> <div class="auth-footer">
<p>Self-hosted instance &middot; Your data stays on your infrastructure</p> <p>Self-hosted instance &middot; Your data stays on your infrastructure</p>
@@ -272,15 +274,18 @@
<script> <script>
function _switchTab(tab) { function _switchTab(tab) {
document.getElementById('loginForm').style.display = tab === 'login' ? '' : 'none'; document.getElementById('loginForm').style.display = tab === 'login' ? '' : 'none';
document.getElementById('registerForm').style.display = tab === 'register' ? '' : 'none'; const regForm = document.getElementById('registerForm');
if (regForm) regForm.style.display = tab === 'register' ? '' : 'none';
document.getElementById('tabLogin').classList.toggle('active', tab === 'login'); document.getElementById('tabLogin').classList.toggle('active', tab === 'login');
document.getElementById('tabRegister').classList.toggle('active', tab === 'register'); const regTab = document.getElementById('tabRegister');
if (regTab) regTab.classList.toggle('active', tab === 'register');
document.getElementById('authTitle').textContent = tab === 'login' ? 'Welcome back' : 'Create account'; document.getElementById('authTitle').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
document.getElementById('authSubtitle').textContent = tab === 'login' document.getElementById('authSubtitle').textContent = tab === 'login'
? 'Sign in to your Switchboard instance' ? 'Sign in to your Switchboard instance'
: 'Join your team on Switchboard'; : 'Join your team on Switchboard';
document.getElementById('loginError').textContent = ''; document.getElementById('loginError').textContent = '';
document.getElementById('regError').textContent = ''; const regErr = document.getElementById('regError');
if (regErr) { regErr.textContent = ''; regErr.style.display = 'none'; }
} }
async function _doRegister() { async function _doRegister() {
@@ -291,10 +296,11 @@
const errEl = document.getElementById('regError'); const errEl = document.getElementById('regError');
const btn = document.getElementById('regBtn'); const btn = document.getElementById('regBtn');
if (!username || !password) { errEl.textContent = 'Username and password are required'; return; } if (!username || !password) { errEl.textContent = 'Username and password are required'; errEl.style.display = ''; return; }
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; return; } if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = ''; return; }
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; return; } if (password !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = ''; return; }
errEl.style.display = 'none';
errEl.textContent = ''; errEl.textContent = '';
btn.disabled = true; btn.textContent = 'Creating account\u2026'; btn.disabled = true; btn.textContent = 'Creating account\u2026';
@@ -313,7 +319,8 @@
if (data.pending) { if (data.pending) {
errEl.textContent = 'Account created — pending admin approval.'; errEl.textContent = 'Account created — pending admin approval.';
errEl.style.color = 'var(--success)'; errEl.style.color = 'var(--success)';
setTimeout(() => { errEl.style.color = ''; _switchTab('login'); }, 3000); errEl.style.display = '';
setTimeout(() => { errEl.style.color = ''; errEl.style.display = 'none'; _switchTab('login'); }, 3000);
} else { } else {
// Auto-login // Auto-login
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth'; const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
@@ -325,6 +332,7 @@
} }
} catch (e) { } catch (e) {
errEl.textContent = e.message; errEl.textContent = e.message;
errEl.style.display = '';
} finally { } finally {
btn.disabled = false; btn.textContent = 'Create Account'; btn.disabled = false; btn.textContent = 'Create Account';
} }
@@ -337,7 +345,7 @@
document.getElementById('loginUsername').addEventListener('keydown', e => { document.getElementById('loginUsername').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('loginPassword').focus(); if (e.key === 'Enter') document.getElementById('loginPassword').focus();
}); });
document.getElementById('regConfirm').addEventListener('keydown', e => { document.getElementById('regConfirm')?.addEventListener('keydown', e => {
if (e.key === 'Enter') _doRegister(); if (e.key === 'Enter') _doRegister();
}); });
</script> </script>

View File

@@ -14,7 +14,7 @@
Back to Chat Back to Chat
</a> </a>
<div class="admin-topbar-sep"></div> <div class="admin-topbar-sep"></div>
<span class="admin-topbar-title">&#x1F500; Administration</span> <span class="admin-topbar-title"><img src="{{.BasePath}}/favicon.svg?v={{.Version}}" alt="" width="18" height="18" style="vertical-align: -3px;"> Administration</span>
{{/* Category tabs */}} {{/* Category tabs */}}
<div class="admin-category-tabs" id="adminCategoryTabs"> <div class="admin-category-tabs" id="adminCategoryTabs">
@@ -95,6 +95,133 @@
</div> </div>
</div> </div>
</div> </div>
{{/* Create User modal */}}
<div id="createUserModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Create User</h2>
<button class="modal-close" onclick="closeModal('createUserModal')">&#10005;</button>
</div>
<div class="modal-body">
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username" autocomplete="off"></div>
<div class="form-group"><label>Email</label><input type="text" id="adminNewEmail" placeholder="email@example.com" autocomplete="off"></div>
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars" autocomplete="new-password"></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="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('createUserModal')">Cancel</button>
<button class="btn-md btn-primary" id="adminCreateUserBtn">Create User</button>
</div>
</div>
</div>
{{/* Reset Password modal */}}
<div id="resetPwModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Reset Password</h2>
<button class="modal-close" onclick="closeModal('resetPwModal')">&#10005;</button>
</div>
<div class="modal-body">
<p id="resetPwUser" style="font-weight:600;margin-bottom:8px;"></p>
<div class="admin-warning" style="background:var(--danger-dim);border:1px solid var(--danger);border-radius:var(--radius);padding:10px 12px;font-size:12px;margin-bottom:12px;color:var(--danger);">
<strong>Warning:</strong> This will destroy the user's personal vault. All personal API keys (BYOK) will be permanently deleted.
</div>
<div class="form-group"><label>New Password</label><input type="password" id="resetPwInput" placeholder="min 8 chars" autocomplete="new-password"></div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('resetPwModal')">Cancel</button>
<button class="btn-md btn-danger" id="resetPwConfirmBtn">Reset Password & Destroy Vault</button>
</div>
</div>
</div>
{{/* Approve User modal */}}
<div id="approveUserModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2 id="approveUserTitle">Approve User</h2>
<button class="modal-close" onclick="closeModal('approveUserModal')">&#10005;</button>
</div>
<div class="modal-body">
<input type="hidden" id="approveUserId">
<div id="approveTeamsList"></div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('approveUserModal')">Cancel</button>
<button class="btn-md btn-primary" id="approveUserSubmitBtn">Activate User</button>
</div>
</div>
</div>
{{/* Create Team modal */}}
<div id="createTeamModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Create Team</h2>
<button class="modal-close" onclick="closeModal('createTeamModal')">&#10005;</button>
</div>
<div class="modal-body">
<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>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('createTeamModal')">Cancel</button>
<button class="btn-md btn-primary" id="adminCreateTeamBtn">Create Team</button>
</div>
</div>
</div>
{{/* Create Group modal */}}
<div id="createGroupModal" class="modal-overlay">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h2>Create Group</h2>
<button class="modal-close" onclick="closeModal('createGroupModal')">&#10005;</button>
</div>
<div class="modal-body">
<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 style="display:flex;gap:12px;">
<div class="form-group" style="flex:1"><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="flex:1;display:none"><label>Team</label><select id="adminGroupTeam"></select></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('createGroupModal')">Cancel</button>
<button class="btn-md btn-primary" id="adminCreateGroupBtn">Create Group</button>
</div>
</div>
</div>
{{/* Provider form modal */}}
<div id="providerFormModal" class="modal-overlay">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 id="providerFormTitle">Add Provider</h2>
<button class="modal-close" onclick="closeModal('providerFormModal')">&#10005;</button>
</div>
<div class="modal-body" id="adminAddProviderForm"></div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('providerFormModal')">Cancel</button>
</div>
</div>
</div>
{{/* Persona form modal */}}
<div id="personaFormModal" class="modal-overlay">
<div class="modal" style="max-width:520px;">
<div class="modal-header">
<h2 id="personaFormTitle">Create Persona</h2>
<button class="modal-close" onclick="closeModal('personaFormModal')">&#10005;</button>
</div>
<div class="modal-body" id="adminAddPersonaForm"></div>
<div class="modal-footer">
<button class="btn-md btn-ghost" onclick="closeModal('personaFormModal')">Cancel</button>
</div>
</div>
</div>
{{end}} {{end}}
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}} {{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}

View File

@@ -98,11 +98,11 @@ window.addEventListener('unhandledrejection', function(e) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button> </button>
<div id="newChatDropdown" class="split-dropdown"> <div id="newChatDropdown" class="split-dropdown">
<button class="split-dropdown-item" onclick="document.getElementById('newChatDropdown').classList.remove('open'); newChat();"> <button class="split-dropdown-item" onclick="closeNewChatMenu(); newChat();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<span>New Chat</span> <span>New Chat</span>
</button> </button>
<button class="split-dropdown-item" onclick="document.getElementById('newChatDropdown').classList.remove('open'); if(typeof createProject==='function') createProject();"> <button class="split-dropdown-item" onclick="closeNewChatMenu(); if(typeof createProject==='function') createProject();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>New Project</span> <span>New Project</span>
</button> </button>
@@ -153,6 +153,11 @@ window.addEventListener('unhandledrejection', function(e) {
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="sb-label">Notes</span> <span class="sb-label">Notes</span>
</button> </button>
{{/* Editor shortcut */}}
<a href="{{.BasePath}}/editor" class="sb-btn sidebar-editor-btn" title="Editor">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="sb-label">Editor</span>
</a>
<div class="sidebar-bottom-divider"></div> <div class="sidebar-bottom-divider"></div>
<button id="userMenuBtn" class="user-btn"> <button id="userMenuBtn" class="user-btn">
<div id="userAvatar" class="user-avatar"> <div id="userAvatar" class="user-avatar">
@@ -594,50 +599,104 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* ── Team Admin Modal ────────────────────── */}} {{/* ── Team Admin Modal ────────────────────── */}}
<div id="teamAdminModal" class="modal-overlay"> <div id="teamAdminModal" class="modal-overlay">
<div class="modal-content modal-lg"> <div class="modal-content modal-lg" style="max-width:900px;height:80vh;display:flex;flex-direction:column;">
<div class="modal-header"> <div class="modal-header" style="flex-shrink:0;">
<h2 id="teamAdminTitle">Team Admin</h2> <h2 id="teamAdminTitle">Team Admin</h2>
<button id="teamAdminCloseBtn" class="modal-close"></button> <button id="teamAdminCloseBtn" class="modal-close"></button>
</div> </div>
<div class="modal-body" style="display:flex;min-height:300px;"> <div class="modal-body" style="display:flex;flex:1;min-height:0;padding:0;">
<div id="teamAdminPicker" class="admin-sidebar"> {{/* Team picker (multi-team admins) */}}
<div id="teamAdminPicker" style="padding:20px;flex:1;overflow-y:auto;">
<div id="teamAdminPickerList"></div> <div id="teamAdminPickerList"></div>
</div> </div>
<div id="teamAdminContent" style="flex:1;overflow-y:auto;padding:16px;">
<div id="adminCurrentUser" style="display:none;"></div> {{/* Tabbed team management */}}
<div id="adminTeamDetail" style="display:none;"> <div id="teamAdminContent" style="display:none;flex:1;display:flex;flex-direction:column;min-width:0;">
<h3 id="adminTeamDetailName"></h3> {{/* Tab bar */}}
<div id="adminMemberList"></div> <div id="teamAdminTabs" style="display:flex;gap:2px;padding:8px 16px;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto;">
<div id="adminAddMemberForm" style="display:none;"> <button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
<select id="adminMemberUser"></select> <button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
</div> <button class="admin-tab" data-ttab="personas" onclick="UI.switchTeamTab('personas')">Personas</button>
<div id="adminTeamAllowProviders" style="display:none;"></div> <button class="admin-tab" data-ttab="knowledge" onclick="UI.switchTeamTab('knowledge')">Knowledge</button>
<div id="adminTeamPrivatePolicy" style="display:none;"></div> <button class="admin-tab" data-ttab="groups" onclick="UI.switchTeamTab('groups')">Groups</button>
<button class="admin-tab" data-ttab="settings" onclick="UI.switchTeamTab('settings')">Settings</button>
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
</div> </div>
<div id="adminGroupDetail" style="display:none;">
<div id="adminGroupDetailMeta"></div> {{/* Tab content panels */}}
<h3 id="adminGroupDetailName"></h3> <div style="flex:1;overflow-y:auto;padding:16px;">
<div id="adminGroupList" style="display:none;"></div> {{/* Members */}}
<div id="adminGroupMemberList"></div> <div id="teamTabMembers" class="team-tab-content">
<div id="adminAddGroupMemberForm" style="display:none;"> <div id="settingsTeamMembers"></div>
<select id="adminGroupMemberUser"></select> <div id="settingsTeamAddMember" class="admin-inline-form" style="display:none;margin-top:12px;">
<div style="display:flex;gap:8px;align-items:flex-end;">
<div class="form-group" style="flex:1"><label>User</label><select id="settingsTeamMemberUser"><option value="">Select user...</option></select></div>
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Admin</option></select></div>
<button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button>
<button class="btn-small" onclick="document.getElementById('settingsTeamAddMember').style.display='none'">Cancel</button>
</div>
</div>
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:8px">+ Add Member</button>
</div>
{{/* Providers */}}
<div id="teamTabProviders" class="team-tab-content" style="display:none;">
<div id="settingsTeamProviders"></div>
<div id="settingsTeamProviderForm" style="display:none;margin-top:12px;"></div>
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:8px">+ Add Provider</button>
</div>
{{/* Personas */}}
<div id="teamTabPersonas" class="team-tab-content" style="display:none;">
<div id="teamPersonaList"></div>
<div id="settingsTeamPersonas" style="display:none;"></div>
<div style="display:none;"><select id="teamPersona_model"></select></div>
</div>
{{/* Knowledge */}}
<div id="teamTabKnowledge" class="team-tab-content" style="display:none;">
<div id="teamKBContent"></div>
</div>
{{/* Groups */}}
<div id="teamTabGroups" class="team-tab-content" style="display:none;">
<div id="settingsTeamGroups"></div>
</div>
{{/* Settings */}}
<div id="teamTabSettings" class="team-tab-content" style="display:none;">
<div style="max-width:500px;">
<div class="form-group" style="margin-bottom:12px;">
<label class="toggle-label"><input type="checkbox" id="teamSettingPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>
<div class="toggle-hint">Team members must use their own API keys</div>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label class="toggle-label"><input type="checkbox" id="teamSettingAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>
<div class="toggle-hint">Team admins can add shared provider configs</div>
</div>
<button class="btn-small btn-primary" id="teamSettingSaveBtn">Save Settings</button>
</div>
</div>
{{/* Usage */}}
<div id="teamTabUsage" class="team-tab-content" style="display:none;">
<div id="settingsTeamUsageTotals"></div>
</div>
{{/* Activity (Audit) */}}
<div id="teamTabActivity" class="team-tab-content" style="display:none;">
<div style="margin-bottom:8px;">
<select id="teamAuditFilterAction" style="font-size:12px;padding:4px 8px;"><option value="">All actions</option></select>
</div>
<div id="settingsTeamAudit"></div>
<div id="teamAuditPagination" style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<button id="teamAuditPrevBtn" class="btn-small" disabled>← Prev</button>
<span id="teamAuditPageInfo" class="text-muted" style="font-size:12px;"></span>
<button id="teamAuditNextBtn" class="btn-small">Next →</button>
</div>
</div> </div>
</div> </div>
<div id="adminAddTeamForm" style="display:none;"></div>
<div id="adminAddGroupForm" style="display:none;"></div>
<div id="adminAddProviderForm" style="display:none;"></div>
{{/* Team audit (inside team admin modal) */}}
<div id="teamKBContent" style="display:none;"></div>
<div style="display:none;">
<select id="teamAuditFilterAction"><option value="">All</option></select>
<div id="teamAuditPagination" style="display:flex;gap:8px;align-items:center;">
<button id="teamAuditPrevBtn" class="btn-small" disabled></button>
<span id="teamAuditPageInfo"></span>
<button id="teamAuditNextBtn" class="btn-small"></button>
</div>
</div>
{{/* Team persona model select */}}
<div style="display:none;"><select id="teamPersona_model"></select></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -89,9 +89,9 @@
<div class="settings-section"> <div class="settings-section">
<h3>Theme</h3> <h3>Theme</h3>
<div id="themeToggle" class="toggle-group"> <div id="themeToggle" class="toggle-group">
<button class="toggle-btn" data-theme="light">&#9728; Light</button> <button class="theme-btn" data-theme="light">&#9728; Light</button>
<button class="toggle-btn" data-theme="dark">&#9790; Dark</button> <button class="theme-btn" data-theme="dark">&#9790; Dark</button>
<button class="toggle-btn" data-theme="system">&#8862; System</button> <button class="theme-btn" data-theme="system">&#8862; System</button>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">

View File

@@ -258,6 +258,16 @@
.sidebar-notes-btn:hover { background: var(--bg-hover); color: var(--text); } .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 .sb-label { opacity: 0; width: 0; }
.sidebar.collapsed .sidebar-notes-btn { justify-content: center; padding: 8px 0; gap: 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; } .sidebar.collapsed .sidebar-bottom-divider { margin: 4px 12px; }
.user-btn { .user-btn {

View File

@@ -192,7 +192,7 @@
padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px;
min-height: 52px; 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-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; } .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; } .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 { flex: 1; overflow-y: auto; padding: 28px; }
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; } .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 { .settings-section {
margin-bottom: 24px; padding: 20px; margin-bottom: 24px; padding: 20px;
background: var(--bg-surface); border-radius: 10px; background: var(--bg-surface); border-radius: 10px;
@@ -328,16 +336,6 @@
} }
.admin-content-body { flex: 1; overflow-y: auto; padding: 24px; } .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 Tables ─────────────────────────── */
.data-table { width: 100%; border-collapse: collapse; } .data-table { width: 100%; border-collapse: collapse; }
.data-table th { .data-table th {
@@ -419,6 +417,103 @@
padding: 8px 12px; border-top: 1px solid var(--border); 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) { @media (max-width: 768px) {
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; } .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) { async function showApproveForm(userId, username) {
const form = document.getElementById(`approveForm-${userId}`); document.getElementById('approveUserId').value = userId;
const teamsEl = document.getElementById(`approveTeams-${userId}`); document.getElementById('approveUserTitle').textContent = `Approve "${username}"`;
if (!form || !teamsEl) return; const teamsEl = document.getElementById('approveTeamsList');
// Load teams for checkboxes
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>'; teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
form.style.display = ''; openModal('approveUserModal');
try { try {
const resp = await API.adminListTeams(); const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active); const teams = (resp.data || []).filter(t => t.is_active);
if (teams.length === 0) { if (teams.length === 0) {
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>'; teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
} else { } else {
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:4px">Assign to teams:</label>' + teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:8px;display:block">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(''); 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>`; } } catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
} }
function hideApproveForm(userId) { function hideApproveForm(userId) {
const form = document.getElementById(`approveForm-${userId}`); closeModal('approveUserModal');
if (form) form.style.display = 'none';
} }
async function submitApproval(userId) { async function submitApproval(userId) {
const form = document.getElementById(`approveForm-${userId}`); if (!userId) userId = document.getElementById('approveUserId').value;
const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value); const teamIds = [...(document.querySelectorAll('#approveTeamsList .approve-team-cb:checked') || [])].map(cb => cb.value);
try { try {
const el = _adminScroll(), pos = el?.scrollTop || 0; const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(userId, true, teamIds, 'member'); await API.adminToggleActive(userId, true, teamIds, 'member');
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated'; const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
UI.toast(msg, 'success'); UI.toast(msg, 'success');
closeModal('approveUserModal');
await UI.loadAdminUsers(); await UI.loadAdminUsers();
_restoreScroll(el, pos); _restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
@@ -73,27 +72,38 @@ async function createAdminUser() {
const r = document.getElementById('adminNewRole').value; const r = document.getElementById('adminNewRole').value;
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; } if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
await API.adminCreateUser(u, e, p, r); 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'); UI.toast('User created', 'success');
await UI.loadAdminUsers(); await UI.loadAdminUsers();
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
} }
async function adminResetUserPassword(id, username) { async function adminResetUserPassword(id, username) {
const pw = prompt( // Show the reset password modal
`Reset password for "${username}"?\n\n` + document.getElementById('resetPwUser').textContent = username;
`⚠️ WARNING: This will DESTROY the user's personal vault.\n` + document.getElementById('resetPwInput').value = '';
`All personal API keys (BYOK) will be permanently deleted.\n` + openModal('resetPwModal');
`The user will need to re-add any personal provider keys.\n\n` + document.getElementById('resetPwInput').focus();
`Enter new password (min 8 chars):`
); // Wire up the confirm button (remove old listener, add new)
if (!pw) return; const btn = document.getElementById('resetPwConfirmBtn');
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; } const newBtn = btn.cloneNode(true);
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return; btn.parentNode.replaceChild(newBtn, btn);
try { newBtn.addEventListener('click', async () => {
await API.adminResetPassword(id, pw); const pw = document.getElementById('resetPwInput').value;
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success'); if (!pw || pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
} catch (e) { UI.toast(e.message, 'error'); } 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 ── // ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
@@ -197,7 +207,7 @@ function ensureAdminPersonaForm() {
kbScope: 'admin', kbScope: 'admin',
onSubmit: (vals) => createAdminPersona(vals), onSubmit: (vals) => createAdminPersona(vals),
onCancel: () => { onCancel: () => {
container.style.display = 'none'; closeModal('personaFormModal');
_editingPersonaId = null; _editingPersonaId = null;
_adminPersonaForm.setSubmitLabel('Create'); _adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm(); _adminPersonaForm.clearForm();
@@ -262,7 +272,8 @@ function editAdminPersona(id) {
ensureAdminPersonaForm(); ensureAdminPersonaForm();
const form = _adminPersonaForm; const form = _adminPersonaForm;
if (!form) return; if (!form) return;
document.getElementById('adminAddPersonaForm').style.display = ''; document.getElementById('personaFormTitle').textContent = 'Edit Persona';
openModal('personaFormModal');
form.setValues(p); form.setValues(p);
form.setSubmitLabel('Update'); form.setSubmitLabel('Update');
// Load KB bindings (v0.17.0) // Load KB bindings (v0.17.0)
@@ -312,7 +323,7 @@ async function createAdminPersona(vals) {
} }
_editingPersonaId = null; _editingPersonaId = null;
document.getElementById('adminAddPersonaForm').style.display = 'none'; closeModal('personaFormModal');
if (_adminPersonaForm) { if (_adminPersonaForm) {
_adminPersonaForm.setSubmitLabel('Create'); _adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm(); _adminPersonaForm.clearForm();
@@ -467,17 +478,18 @@ function _initAdminListeners() {
// Admin — users // Admin — users
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddUserForm'); openModal('createUserModal');
f.style.display = f.style.display === 'none' ? '' : 'none';
}); });
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser); document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — approve user modal
document.getElementById('approveUserSubmitBtn')?.addEventListener('click', () => submitApproval());
// Admin — providers (form managed by primitives in loadAdminProviders) // Admin — providers (form managed by primitives in loadAdminProviders)
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddProviderForm');
if (UI._adminProvForm) UI._adminProvForm.setCreateMode(); if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
f.style.display = f.style.display === 'none' ? '' : 'none'; document.getElementById('providerFormTitle').textContent = 'Add Provider';
openModal('providerFormModal');
}); });
// Admin — models // Admin — models
@@ -490,19 +502,17 @@ function _initAdminListeners() {
_editingPersonaId = null; _editingPersonaId = null;
form.setSubmitLabel('Create'); form.setSubmitLabel('Create');
form.clearForm(); form.clearForm();
const f = document.getElementById('adminAddPersonaForm'); document.getElementById('personaFormTitle').textContent = 'Create Persona';
f.style.display = f.style.display === 'none' ? '' : 'none'; openModal('personaFormModal');
}); });
// Admin — Teams // Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => { document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = ''; openModal('createTeamModal');
document.getElementById('adminTeamName').focus(); document.getElementById('adminTeamName').focus();
}); });
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => { document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = 'none'; closeModal('createTeamModal');
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
}); });
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => { document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminTeamName').value.trim(); const name = document.getElementById('adminTeamName').value.trim();
@@ -510,7 +520,7 @@ function _initAdminListeners() {
if (!name) return UI.toast('Team name required', 'error'); if (!name) return UI.toast('Team name required', 'error');
try { try {
await API.adminCreateTeam(name, desc); await API.adminCreateTeam(name, desc);
document.getElementById('adminAddTeamForm').style.display = 'none'; closeModal('createTeamModal');
document.getElementById('adminTeamName').value = ''; document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = ''; document.getElementById('adminTeamDesc').value = '';
UI.toast('Team created'); UI.toast('Team created');
@@ -559,7 +569,7 @@ function _initAdminListeners() {
// ── Group management ──────────────────────── // ── Group management ────────────────────────
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => { document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddGroupForm').style.display = ''; openModal('createGroupModal');
document.getElementById('adminGroupName').focus(); document.getElementById('adminGroupName').focus();
// Populate team dropdown // Populate team dropdown
try { try {
@@ -574,9 +584,7 @@ function _initAdminListeners() {
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none'; document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
}); });
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => { document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
document.getElementById('adminAddGroupForm').style.display = 'none'; closeModal('createGroupModal');
document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = '';
}); });
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => { document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminGroupName').value.trim(); 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'); if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
try { try {
await API.adminCreateGroup(name, desc, scope, teamId); await API.adminCreateGroup(name, desc, scope, teamId);
document.getElementById('adminAddGroupForm').style.display = 'none'; closeModal('createGroupModal');
document.getElementById('adminGroupName').value = ''; document.getElementById('adminGroupName').value = '';
document.getElementById('adminGroupDesc').value = ''; document.getElementById('adminGroupDesc').value = '';
UI.toast('Group created'); UI.toast('Group created');
@@ -783,7 +791,7 @@ function editAdminExtension(id) {
`; `;
// Find the extension row and insert the form after it // 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) { for (const row of rows) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) { if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML); row.insertAdjacentHTML('afterend', formHTML);

View File

@@ -16,42 +16,18 @@
// -- People ----------------------------------------------------------- // -- People -----------------------------------------------------------
SCAFFOLDING.users = SCAFFOLDING.users =
'<div id="adminUserList" class="admin-list"></div>' + '<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>';
SCAFFOLDING.teams = SCAFFOLDING.teams =
'<div id="adminTeamList" class="admin-list"></div>' + '<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 id="adminTeamDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminTeamBackBtn">&larr; Back</button>' + '<button class="btn-small" id="adminTeamBackBtn">&larr; Back</button>' +
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' + '<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
'</div>' + '</div>' +
'<div style="margin-bottom:12px">' + '<div style="margin-bottom:12px">' +
'<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (BYOK)</label>' + '<label class="toggle-label"><input type="checkbox" id="adminTeamPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team providers</label>' + '<label class="toggle-label"><input type="checkbox" id="adminTeamAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>' +
'</div>' + '</div>' +
'<div id="adminMemberList" class="admin-list"></div>' + '<div id="adminMemberList" class="admin-list"></div>' +
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' + '<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
@@ -69,19 +45,6 @@
SCAFFOLDING.groups = SCAFFOLDING.groups =
'<div id="adminGroupList" class="admin-list"></div>' + '<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 id="adminGroupDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminGroupBackBtn">&larr; Back</button>' + '<button class="btn-small" id="adminGroupBackBtn">&larr; Back</button>' +
@@ -104,8 +67,7 @@
// -- AI --------------------------------------------------------------- // -- AI ---------------------------------------------------------------
SCAFFOLDING.providers = SCAFFOLDING.providers =
'<div id="adminProviderList" class="admin-list"></div>' + '<div id="adminProviderList" class="provider-cards"></div>';
'<div id="adminAddProviderForm" class="admin-inline-form" style="display:none"></div>';
SCAFFOLDING.models = SCAFFOLDING.models =
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
@@ -115,8 +77,7 @@
'<div id="adminModelList" class="admin-list"></div>'; '<div id="adminModelList" class="admin-list"></div>';
SCAFFOLDING.personas = SCAFFOLDING.personas =
'<div id="adminPersonaList" class="admin-list"></div>' + '<div id="adminPersonaList" class="admin-list"></div>';
'<div id="adminAddPersonaForm" class="admin-inline-form" style="display:none"></div>';
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>'; SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>'; SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
@@ -126,25 +87,25 @@
SCAFFOLDING.settings = SCAFFOLDING.settings =
'<div class="admin-settings-form">' + '<div class="admin-settings-form">' +
'<div class="settings-section"><h4>Registration</h4>' + '<div class="settings-section"><h3>Registration</h3>' +
'<label class="checkbox-label"><input type="checkbox" id="adminRegToggle"> Allow new user registration</label>' + '<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>' + '<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>' + '<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
'</div>' + '</div>' +
'</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>' + '<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
'</div>' + '</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>' + '<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
'</div>' + '</div>' +
'<div class="settings-section"><h4>Policies</h4>' + '<div class="settings-section"><h3>Policies</h3>' +
'<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle"> Allow BYOK (user API keys)</label>' + '<label class="toggle-label"><input type="checkbox" id="adminUserProvidersToggle"><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminUserPersonasToggle"> Allow user personas</label>' + '<label class="toggle-label"><input type="checkbox" id="adminUserPersonasToggle"><span class="toggle-track"></span><span>Allow user personas</span></label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Allow direct KB access (non-persona)</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>' +
'<div class="settings-section"><h4>Environment Banner</h4>' + '<div class="settings-section"><h3>Environment Banner</h3>' +
'<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>' + '<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 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-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
'<div class="form-row">' + '<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 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>' + '</div>' +
'<div class="settings-section"><h4>Web Search</h4>' + '<div class="settings-section"><h3>Web Search</h3>' +
'<div class="form-group"><label>Provider</label>' + '<div class="form-group"><label>Provider</label>' +
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' + '<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
'</div>' + '</div>' +
@@ -164,8 +125,8 @@
'</div>' + '</div>' +
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' + '<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
'</div>' + '</div>' +
'<div class="settings-section"><h4>Auto-Compaction</h4>' + '<div class="settings-section"><h3>Auto-Compaction</h3>' +
'<label class="checkbox-label"><input type="checkbox" id="adminCompactionEnabled"> Enable auto-compaction</label>' + '<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 id="compactionConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-row">' + '<div class="form-row">' +
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' + '<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>' +
'</div>' + '</div>' +
'<div class="settings-section"><h4>Memory Extraction</h4>' + '<div class="settings-section"><h3>Memory Extraction</h3>' +
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryExtractionEnabled"> Enable memory extraction</label>' + '<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">' + '<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>' + '</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 id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
'</div>' + '</div>' +
'<div id="roleFallbackBanner"></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>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>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>' + '<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">' + '<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' + '<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' + '<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
@@ -216,8 +177,8 @@
// -- Routing ---------------------------------------------------------- // -- Routing ----------------------------------------------------------
SCAFFOLDING.health = SCAFFOLDING.health =
'<div style="margin-bottom:12px"><button class="btn-small" id="adminHealthRefreshBtn">Refresh</button></div>' + '<div id="adminHealthCards" class="stat-cards-grid"></div>' +
'<div id="adminHealthContent"><div class="loading">Loading...</div></div>'; '<div id="adminHealthContent"></div>';
SCAFFOLDING.routing = SCAFFOLDING.routing =
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' + '<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
@@ -238,6 +199,8 @@
// -- Monitoring ------------------------------------------------------- // -- Monitoring -------------------------------------------------------
SCAFFOLDING.usage = SCAFFOLDING.usage =
'<div id="adminUsageCards" class="stat-cards-grid"></div>' +
'<div id="adminUsageChart" style="margin-bottom:24px"></div>' +
'<div id="adminUsageTotals"></div>' + '<div id="adminUsageTotals"></div>' +
'<div id="adminUsageResults" style="margin-top:12px"></div>' + '<div id="adminUsageResults" style="margin-top:12px"></div>' +
'<div id="adminPricingTable" style="margin-top:24px"></div>'; '<div id="adminPricingTable" style="margin-top:24px"></div>';
@@ -255,38 +218,39 @@
'</div>'; '</div>';
SCAFFOLDING.stats = 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 ============================================== // === Add button actions ==============================================
var ADD_ACTIONS = { var ADD_ACTIONS = {
users: function() { users: function() {
var f = document.getElementById('adminAddUserForm'); openModal('createUserModal');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none'; var n = document.getElementById('adminNewUsername');
if (n) n.focus();
}, },
teams: function() { teams: function() {
var f = document.getElementById('adminAddTeamForm'); openModal('createTeamModal');
if (f) f.style.display = '';
var n = document.getElementById('adminTeamName'); var n = document.getElementById('adminTeamName');
if (n) n.focus(); if (n) n.focus();
}, },
providers: function() { providers: function() {
var f = document.getElementById('adminAddProviderForm');
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode(); 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() { personas: function() {
if (typeof ensureAdminPersonaForm === 'function') { if (typeof ensureAdminPersonaForm === 'function') {
var form = ensureAdminPersonaForm(); var form = ensureAdminPersonaForm();
if (form) { form.setSubmitLabel('Create'); form.clearForm(); } if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
} }
var f = document.getElementById('adminAddPersonaForm'); document.getElementById('personaFormTitle').textContent = 'Create Persona';
if (f) f.style.display = f.style.display === 'none' ? '' : 'none'; openModal('personaFormModal');
}, },
groups: function() { groups: function() {
var f = document.getElementById('adminAddGroupForm'); openModal('createGroupModal');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none'; var n = document.getElementById('adminGroupName');
if (n) n.focus();
}, },
extensions: function() { extensions: function() {
var f = document.getElementById('adminInstallExtForm'); var f = document.getElementById('adminInstallExtForm');
@@ -359,6 +323,8 @@
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) -- // -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
if (typeof _initAdminListeners === 'function') _initAdminListeners(); if (typeof _initAdminListeners === 'function') _initAdminListeners();
// -- Wire settings toggle show/hide listeners (banner, compaction, etc.) --
if (typeof _initAdminSettingsToggles === 'function') _initAdminSettingsToggles();
// -- Call the section data loader -------- // -- Call the section data loader --------
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) { 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() { async function newChat() {
clearStaged(); // Discard any staged files clearStaged(); // Discard any staged files
App.currentChatId = null; App.currentChatId = null;
@@ -822,7 +828,21 @@ async function switchSibling(messageId, direction) {
function _initChatListeners() { function _initChatListeners() {
// Sidebar // Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar); 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 // Split button dropdown
document.getElementById('newChatDropBtn').addEventListener('click', (e) => { document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
@@ -831,7 +851,9 @@ function _initChatListeners() {
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.split-btn')) { 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'); UI.toast('Settings saved', 'success');
// Live-apply: refresh policies and dependent UI // Live-apply: refresh policies and dependent UI
await initBanners(); if (typeof initBanners === 'function') await initBanners();
UI.checkUserProvidersAllowed(); if (typeof UI.checkUserProvidersAllowed === 'function') UI.checkUserProvidersAllowed();
UI.checkUserPersonasAllowed(); if (typeof UI.checkUserPersonasAllowed === 'function') UI.checkUserPersonasAllowed();
fetchModels(); if (typeof fetchModels === 'function') fetchModels();
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
} }
@@ -662,6 +662,32 @@ function _initSettingsListeners() {
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => { document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none'; 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 // Admin — audit log
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1)); 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 === 'personas') UI.loadTeamManagePersonas(teamId);
if (tab === 'usage') UI.loadTeamUsage(); if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1); if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'settings') UI.loadTeamManageSettings(teamId);
if (tab === 'knowledge') { if (tab === 'knowledge') {
if (typeof KnowledgeUI === 'undefined') { if (typeof KnowledgeUI === 'undefined') {
console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js'); console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js');
@@ -206,37 +207,50 @@ Object.assign(UI, {
try { try {
const resp = await API.adminListUsers(); const resp = await API.adminListUsers();
const users = resp.users || resp.data || []; const users = resp.users || resp.data || [];
el.innerHTML = users.map(u => { if (!users.length) { el.innerHTML = '<div class="empty-hint">No users</div>'; return; }
const teamBadges = (u.teams || []).map(t =>
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>` const rows = users.map(u => {
).join(' '); const initial = (u.display_name || u.username || '?')[0].toUpperCase();
const name = esc(u.display_name || u.username);
const handle = '@' + esc(u.username);
const roleCls = u.role === 'admin' ? 'badge-admin' : u.role === 'service' ? 'badge-service' : 'badge-user';
const isPending = !u.is_active; const isPending = !u.is_active;
return ` const statusCls = isPending ? 'badge-warning' : 'badge-active';
<div class="admin-user-row${isPending ? ' user-inactive' : ''}"> const statusLabel = isPending ? 'pending' : 'active';
<div class="admin-user-info"> const teams = (u.teams || []).map(t => esc(t.team_name)).join(', ') || '\u2014';
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span> const lastSeen = u.last_login_at ? _relativeTime(u.last_login_at) : '\u2014';
${isPending ? '<span class="badge-pending">pending</span>' : ''}
${teamBadges}</div> return `<tr data-name="${esc(u.username)}">
<div class="admin-user-email">${esc(u.email)}</div> <td><div style="display:flex;align-items:center;gap:10px">
</div> <div class="admin-avatar">${initial}</div>
<div class="admin-user-actions"> <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 ${isPending
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>` ? `<button class="btn-icon" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</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 class="btn-icon" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button> <button class="btn-icon" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button> <button class="btn-icon btn-icon-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
</div> </td>
</div> </tr>`;
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none"> }).join('');
<div class="approve-teams" id="approveTeams-${u.id}"></div>
<div class="form-row" style="margin-top:8px"> el.innerHTML = `
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button> <table class="admin-table">
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button> <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Teams</th><th>Last Seen</th><th></th></tr></thead>
</div> <tbody>${rows}</tbody>
</table>
<div class="admin-table-footer">
<span>${users.length} user${users.length !== 1 ? 's' : ''}</span>
</div>`; </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>`; } } 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>'; el.innerHTML = '<div class="loading">Loading...</div>';
try { try {
const s = await API.adminGetStats(); const s = await API.adminGetStats();
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' }; const labels = { users: 'Users', channels: 'Channels', messages: 'Messages', models: 'Models', providers: 'Providers', personas: 'Personas' };
el.innerHTML = '<div class="stats-grid">' + el.innerHTML = Object.entries(s).map(([k, v]) => `
Object.entries(s).map(([k, v]) => ` <div class="stat-card">
<div class="stat-card"> <div class="stat-card-label">${labels[k] || k.replace(/_/g, ' ')}</div>
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div> <div class="stat-card-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div> </div>`).join('');
</div>`).join('') +
'</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
@@ -291,10 +303,64 @@ Object.assign(UI, {
// ── Admin: Usage ──────────────────────── // ── Admin: Usage ────────────────────────
async loadAdminUsage() { 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'); const totalsEl = document.getElementById('adminUsageTotals');
if (!totalsEl) return; if (!totalsEl) return;
// Wrap totals+results in a container for the primitive (first time only)
let usageContainer = document.getElementById('_adminUsageContainer'); let usageContainer = document.getElementById('_adminUsageContainer');
if (!usageContainer) { if (!usageContainer) {
usageContainer = document.createElement('div'); usageContainer = document.createElement('div');
@@ -316,7 +382,7 @@ Object.assign(UI, {
} }
await UI._adminUsageDash.refresh(); await UI._adminUsageDash.refresh();
// Pricing table (admin-only, separate from usage primitive) // Pricing table
const pricingEl = document.getElementById('adminPricingTable'); const pricingEl = document.getElementById('adminPricingTable');
if (pricingEl) { if (pricingEl) {
try { try {
@@ -351,47 +417,36 @@ Object.assign(UI, {
try { try {
const resp = await API._get('/api/v1/admin/extensions'); const resp = await API._get('/api/v1/admin/extensions');
const exts = resp.data || []; const exts = resp.data || [];
this._adminExtensions = exts; // store for edit access this._adminExtensions = exts;
if (exts.length === 0) { 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; return;
} }
el.innerHTML = exts.map(ext => { el.innerHTML = `
const statusBadge = ext.is_enabled <table class="admin-table">
? '<span class="badge-admin" style="font-size:11px">enabled</span>' <thead><tr><th>Extension</th><th>Status</th><th>Tier</th><th>Version</th><th></th></tr></thead>
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>'; <tbody>${exts.map(ext => {
const systemBadge = ext.is_system const statusBadge = ext.is_enabled ? '<span class="badge-active">enabled</span>' : '<span class="badge-disabled">disabled</span>';
? ' <span class="badge-user" style="font-size:11px">system</span>' const systemBadge = ext.is_system ? ' <span class="badge-user">system</span>' : '';
: ''; return `<tr>
return ` <td>
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start"> <div style="font-weight:500">${esc(ext.name)}${systemBadge}</div>
<div class="admin-user-info" style="flex:1"> <div class="admin-user-handle">${esc(ext.description || 'No description')}${ext.author ? ' · by ' + esc(ext.author) : ''}</div>
<div style="display:flex;align-items:center;gap:8px"> </td>
<strong>${esc(ext.name)}</strong> <td>${statusBadge}</td>
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code> <td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
${statusBadge}${systemBadge} <td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
</div> <td class="admin-actions-cell">
<div class="text-muted" style="font-size:12px;margin-top:2px"> <button class="btn-icon" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
${esc(ext.description || 'No description')} <button class="btn-icon" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
${ext.author ? ` · by ${esc(ext.author)}` : ''} <button class="btn-icon btn-icon-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
· v${esc(ext.version)} · ${ext.tier} </td>
</div> </tr>`;
</div> }).join('')}</tbody>
<div style="display:flex;gap:6px;flex-shrink:0"> </table>
<button class="btn-small" onclick="editAdminExtension('${ext.id}')"> <div class="admin-table-footer"><span>${exts.length} extension${exts.length !== 1 ? 's' : ''}</span></div>`;
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('');
} catch (e) { } 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) { if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No audit entries found</div>'; el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
} else { } else {
el.innerHTML = entries.map(e => { el.innerHTML = `
const ts = new Date(e.created_at); <table class="admin-table">
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); <thead><tr><th>Action</th><th>Actor</th><th>Resource</th><th>Details</th><th>Time</th><th>IP</th></tr></thead>
const actor = e.actor_name || 'system'; <tbody>${entries.map(e => {
const meta = UI._formatAuditMeta(e.metadata); const ts = new Date(e.created_at);
return `<div class="audit-row"> const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
<div class="audit-main"> const actor = e.actor_name || 'system';
<span class="audit-action">${esc(e.action)}</span> const meta = UI._formatAuditMeta(e.metadata);
<span class="audit-actor">${esc(actor)}</span> return `<tr>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span> <td><span style="font-weight:600;color:var(--accent);font-family:var(--font-mono,monospace);font-size:12px">${esc(e.action)}</span></td>
</div> <td style="font-size:12px">${esc(actor)}</td>
<div class="audit-meta"> <td style="font-size:12px;color:var(--text-3)">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</td>
${meta ? `<span class="audit-details">${meta}</span>` : ''} <td style="font-size:11px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${meta || ''}</td>
<span class="audit-time">${timeStr}</span> <td class="admin-user-time">${timeStr}</td>
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''} <td style="font-size:11px;color:var(--text-3);font-family:var(--font-mono,monospace)">${e.ip_address ? esc(e.ip_address) : ''}</td>
</div> </tr>`;
</div>`; }).join('')}</tbody>
}).join(''); </table>`;
} }
// Pagination // Pagination
@@ -478,7 +533,7 @@ Object.assign(UI, {
const el = document.getElementById('adminProviderList'); const el = document.getElementById('adminProviderList');
const formEl = document.getElementById('adminAddProviderForm'); 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) { if (!UI._adminProvForm && formEl) {
UI._adminProvForm = renderProviderForm(formEl, { UI._adminProvForm = renderProviderForm(formEl, {
prefix: 'adminProv', 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); await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
UI.toast('Provider added', 'success'); UI.toast('Provider added', 'success');
} }
formEl.style.display = 'none'; closeModal('providerFormModal');
UI._adminProvForm.setCreateMode(); UI._adminProvForm.setCreateMode();
UI._adminProvList.refresh(); UI.loadAdminProviders(true);
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
}, },
onCancel: () => { onCancel: () => {
formEl.style.display = 'none'; closeModal('providerFormModal');
UI._adminProvForm.setCreateMode(); UI._adminProvForm.setCreateMode();
}, },
}); });
} }
// Initialize admin provider list primitive (once) if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
if (!UI._adminProvList) {
UI._adminProvList = renderProviderList(el, { try {
apiFetch: () => API.adminListGlobalConfigs(), // Fetch providers, health, and models in parallel
showEndpoint: true, const [configsRaw, healthRaw, modelsRaw] = await Promise.all([
showPrivate: true, API.adminListGlobalConfigs(),
emptyMsg: 'No global providers — add one above', API.adminGetAllProviderHealth().catch(() => ({ providers: [] })),
onEdit: (prov) => { API.adminListModels().catch(() => ({ models: [] })),
if (UI._adminProvForm) { ]);
UI._adminProvForm.setEditMode(prov.id, prov);
formEl.style.display = ''; 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 || [];
onDelete: async (prov) => {
if (!await showConfirm('Delete this global provider?')) return; // Build health lookup by provider_config_id
try { const healthMap = {};
await API.adminDeleteGlobalConfig(prov.id); (Array.isArray(healthList) ? healthList : []).forEach(h => { healthMap[h.provider_config_id] = h; });
UI.toast('Provider deleted', 'success');
UI._adminProvList.refresh(); // Count models per provider
} catch (e) { UI.toast(e.message, 'error'); } 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) { async loadAdminModels(quiet) {
@@ -546,20 +648,26 @@ Object.assign(UI, {
const data = await API.adminListModels(); const data = await API.adminListModels();
const list = data.models || data.data || data || []; const list = data.models || data.data || data || [];
const arr = Array.isArray(list) ? list : []; const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => { if (!arr.length) { el.innerHTML = '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>'; return; }
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true }); el.innerHTML = `
// Support both old is_enabled (bool) and new visibility (string) <table class="admin-table">
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled'); <thead><tr><th>Model</th><th>Provider</th><th>Capabilities</th><th>Visibility</th></tr></thead>
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled'; <tbody>${arr.map(m => {
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : ''; const caps = m.capabilities || {};
return `<div class="admin-model-row"> const badges = renderCapBadges(caps, { compact: true });
<span class="model-name">${esc(m.model_id || m.id)}</span> const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
<span class="model-caps-inline">${badges}</span> const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
<span class="provider-meta">${esc(m.provider_name || '')}</span> const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button> return `<tr>
</div>`; <td style="font-weight:500;font-family:var(--font-mono,monospace);font-size:12px">${esc(m.model_id || m.id)}</td>
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>'; <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>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
@@ -607,36 +715,44 @@ Object.assign(UI, {
} }
UI._personaCache = {}; UI._personaCache = {};
el.innerHTML = list.map(p => { if (!list.length) { el.innerHTML = '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>'; return; }
UI._personaCache[p.id] = p;
const avatarEl = p.avatar el.innerHTML = `
? `<img src="${p.avatar}" class="persona-row-avatar" alt="">` <table class="admin-table">
: ''; <thead><tr><th>Persona</th><th>Scope</th><th>Model</th><th>Status</th><th></th></tr></thead>
// Scope badge with attribution <tbody>${list.map(p => {
let scopeBadge = ''; UI._personaCache[p.id] = p;
if (p.scope === 'global') { const avatarEl = p.avatar
scopeBadge = '<span class="badge-admin">global</span>'; ? `<img src="${p.avatar}" style="width:24px;height:24px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:6px" alt="">`
} else if (p.scope === 'team' && p.team_name) { : '';
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`; let scopeBadge = '';
} else if (p.scope === 'personal') { if (p.scope === 'global') scopeBadge = '<span class="badge-admin">global</span>';
scopeBadge = '<span class="badge-user">personal</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 creator = p.creator_name ? `<div class="admin-user-handle">by ${esc(p.creator_name)}</div>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>'; return `<tr data-grant-persona="${p.id}">
return `<div class="admin-persona-row" data-grant-persona="${p.id}"> <td>
<div class="persona-info"> <div style="display:flex;align-items:center;gap:8px">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status} ${avatarEl}
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div> <div>
${p.description ? `<div class="persona-desc">${esc(p.description)}</div>` : ''} <div style="font-weight:500">${esc(p.name)}</div>
</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 class="persona-actions"> </div>
<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> </div>
<button class="btn-edit" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button> </td>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPersona('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button> <td>${scopeBadge}</td>
<button class="btn-delete" onclick="deleteAdminPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button> <td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
</div> <td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
</div>`; <td class="admin-actions-cell">
}).join('') || '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>'; <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>`; } } 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>'; el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none'; detail.style.display = 'none';
el.style.display = ''; el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none'; closeModal('createTeamModal');
} }
try { try {
const resp = await API.adminListTeams(); const resp = await API.adminListTeams();
const teams = resp.data || []; const teams = resp.data || [];
el.innerHTML = teams.map(t => ` if (!teams.length) { el.innerHTML = '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>'; return; }
<div class="admin-user-row">
<div class="admin-user-info"> el.innerHTML = `
<div><strong>${esc(t.name)}</strong> <table class="admin-table">
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'} <thead><tr><th>Team</th><th>Status</th><th>Members</th><th></th></tr></thead>
</div> <tbody>${teams.map(t => `<tr>
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div> <td>
</div> <div style="font-weight:500">${esc(t.name)}</div>
<div class="admin-user-actions"> <div class="admin-user-handle">${esc(t.description || 'No description')}</div>
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button> </td>
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button> <td>
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button> ${t.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-warning">inactive</span>'}
</div> </td>
</div> <td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>'; <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>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
async openTeamDetail(teamId, teamName) { async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId; this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none'; document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none'; closeModal('createTeamModal');
const detail = document.getElementById('adminTeamDetail'); const detail = document.getElementById('adminTeamDetail');
detail.style.display = ''; detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName; document.getElementById('adminTeamDetailName').textContent = teamName;
@@ -700,25 +823,29 @@ Object.assign(UI, {
try { try {
const resp = await API.adminListMembers(teamId); const resp = await API.adminListMembers(teamId);
const members = resp.data || []; 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 => ` el.innerHTML = `
<div class="admin-user-row"> <table class="admin-table">
<div class="admin-user-info"> <thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
<div><strong>${esc(m.display_name || m.email)}</strong> <tbody>${members.map(m => `<tr>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span> <td>
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''} <div style="font-weight:500">${esc(m.display_name || m.email)}</div>
</div> <div class="admin-user-handle">${esc(m.email)}</div>
<div class="text-muted">${esc(m.email)}</div> </td>
</div> <td>
<div class="admin-user-actions"> <select onchange="updateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select"> <option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option> <option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option> </select>
</select> ${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button> </td>
</div> <td class="admin-actions-cell">
</div> <button class="btn-icon btn-icon-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
`).join('') || '<div class="empty-hint">No members — add users to this team</div>'; </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>`; } } 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>'; el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none'; detail.style.display = 'none';
el.style.display = ''; el.style.display = '';
document.getElementById('adminAddGroupForm').style.display = 'none'; closeModal('createGroupModal');
} }
try { try {
const resp = await API.adminListGroups(); const resp = await API.adminListGroups();
const groups = resp.data || []; const groups = resp.data || [];
el.innerHTML = groups.map(g => { if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>'; return; }
const scopeBadge = g.scope === 'global'
? '<span class="badge-admin">global</span>' el.innerHTML = `
: `<span class="badge-team">team</span>`; <table class="admin-table">
return `<div class="admin-user-row"> <thead><tr><th>Group</th><th>Scope</th><th>Members</th><th></th></tr></thead>
<div class="admin-user-info"> <tbody>${groups.map(g => {
<div><strong>${esc(g.name)}</strong> ${scopeBadge}</div> const scopeBadge = g.scope === 'global'
<div class="text-muted">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div> ? '<span class="badge-admin">global</span>'
</div> : '<span class="badge-team">team</span>';
<div class="admin-user-actions"> return `<tr>
<button class="btn-small" onclick="UI.openGroupDetail('${g.id}', '${esc(g.name)}')">Members</button> <td>
<button class="btn-delete" onclick="deleteGroup('${g.id}', '${esc(g.name)}')" title="Delete">✕</button> <div style="font-weight:500">${esc(g.name)}</div>
</div> <div class="admin-user-handle">${esc(g.description || 'No description')}</div>
</div>`; </td>
}).join('') || '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>'; <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>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
async openGroupDetail(groupId, groupName) { async openGroupDetail(groupId, groupName) {
this._groupEditId = groupId; this._groupEditId = groupId;
document.getElementById('adminGroupList').style.display = 'none'; document.getElementById('adminGroupList').style.display = 'none';
document.getElementById('adminAddGroupForm').style.display = 'none'; closeModal('createGroupModal');
const detail = document.getElementById('adminGroupDetail'); const detail = document.getElementById('adminGroupDetail');
detail.style.display = ''; detail.style.display = '';
document.getElementById('adminGroupDetailName').textContent = groupName; document.getElementById('adminGroupDetailName').textContent = groupName;
@@ -795,17 +931,22 @@ Object.assign(UI, {
try { try {
const resp = await API.adminListGroupMembers(groupId); const resp = await API.adminListGroupMembers(groupId);
const members = resp.data || []; const members = resp.data || [];
el.innerHTML = members.map(m => ` if (!members.length) { el.innerHTML = '<div class="empty-hint">No members — add users to this group</div>'; return; }
<div class="admin-user-row"> el.innerHTML = `
<div class="admin-user-info"> <table class="admin-table">
<div><strong>${esc(m.display_name || m.username || m.email)}</strong></div> <thead><tr><th>Member</th><th>Added</th><th></th></tr></thead>
<div class="text-muted">${esc(m.email)} · added ${new Date(m.added_at).toLocaleDateString()}</div> <tbody>${members.map(m => `<tr>
</div> <td>
<div class="admin-user-actions"> <div style="font-weight:500">${esc(m.display_name || m.username || m.email)}</div>
<button class="btn-delete" onclick="removeGroupMember('${groupId}', '${m.user_id}', '${esc(m.username || m.email)}')" title="Remove">✕</button> <div class="admin-user-handle">${esc(m.email)}</div>
</div> </td>
</div> <td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
`).join('') || '<div class="empty-hint">No members — add users to this group</div>'; <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>`; } } 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) ────── // ── Provider Health Dashboard (v0.22.3) ──────
async loadAdminHealth() { async loadAdminHealth() {
const cardsEl = document.getElementById('adminHealthCards');
const el = document.getElementById('adminHealthContent'); const el = document.getElementById('adminHealthContent');
if (cardsEl) cardsEl.innerHTML = '';
el.innerHTML = '<div class="loading">Loading health data...</div>'; el.innerHTML = '<div class="loading">Loading health data...</div>';
document.getElementById('adminHealthRefreshBtn')?.addEventListener('click', () => UI.loadAdminHealth(), { once: true });
try { try {
const data = await API.adminGetAllProviderHealth(); const data = await API.adminGetAllProviderHealth();
const providers = data.data || []; const providers = data.data || [];
@@ -893,54 +1034,48 @@ Object.assign(UI, {
return; return;
} }
el.innerHTML = '<div class="admin-health-grid"></div>'; // Stat cards
const grid = el.querySelector('.admin-health-grid'); 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) { if (cardsEl) {
const statusClass = p.status === 'healthy' ? 'badge-success' : p.status === 'degraded' ? 'badge-warning' : p.status === 'down' ? 'badge-danger' : ''; cardsEl.innerHTML = `
const errorPct = p.error_rate !== undefined ? (p.error_rate * 100).toFixed(1) + '%' : '—'; <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>
const avgLatency = p.avg_latency_ms !== undefined ? p.avg_latency_ms + 'ms' : '—'; <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>
const configName = p.provider_config_id?.slice(0, 8) || '—'; <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>`;
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>`;
} }
// 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) { } catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load health data: ${esc(e.message)}</div>`; 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' }; const typeLabels = { provider_prefer: 'Provider Prefer', team_route: 'Team Route', cost_limit: 'Cost Limit', model_alias: 'Model Alias' };
listEl.innerHTML = policies.map(p => ` listEl.innerHTML = `
<div class="list-card" data-id="${esc(p.id)}"> <table class="admin-table">
<div style="display:flex;justify-content:space-between;align-items:center"> <thead><tr><th>Policy</th><th>Type</th><th>Scope</th><th>Priority</th><th>Status</th><th></th></tr></thead>
<div> <tbody>${policies.map(p => `<tr data-id="${esc(p.id)}">
<strong>${esc(p.name)}</strong> <td style="font-weight:500">${esc(p.name)}</td>
<span class="badge" style="margin-left:6px">${esc(typeLabels[p.policy_type] || p.policy_type)}</span> <td><span class="badge-provider-type">${esc(typeLabels[p.policy_type] || p.policy_type)}</span></td>
<span class="badge ${p.is_active ? 'badge-success' : ''}" style="margin-left:4px">${p.is_active ? 'Active' : 'Inactive'}</span> <td style="font-size:12px;color:var(--text-3)">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</td>
</div> <td class="admin-user-time">${p.priority}</td>
<div> <td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
<span class="text-muted" style="font-size:11px;margin-right:8px">Priority: ${p.priority}</span> <td class="admin-actions-cell">
<span class="badge">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</span> <button class="btn-icon" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
</div> <button class="btn-icon" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
</div> <button class="btn-icon btn-icon-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
<div style="display:flex;gap:8px;margin-top:8px"> </td>
<button class="btn-small" onclick="UI._showRoutingForm('${esc(p.id)}')">Edit</button> </tr>`).join('')}</tbody>
<button class="btn-small" onclick="UI._toggleRoutingPolicy('${esc(p.id)}', ${!p.is_active})">${p.is_active ? 'Disable' : 'Enable'}</button> </table>
<button class="btn-small btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}', '${esc(p.name)}')">Delete</button> <div class="admin-table-footer"><span>${policies.length} polic${policies.length !== 1 ? 'ies' : 'y'}</span></div>`;
</div>
</div>
`).join('');
} catch (e) { } catch (e) {
listEl.innerHTML = `<div class="empty-hint">Failed to load routing policies: ${esc(e.message)}</div>`; 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 prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100; const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14; const msgFont = prefs.msgFont || 14;
const theme = prefs.theme || 'dark'; const theme = prefs.theme || 'system';
const keymap = prefs.editorKeymap || 'standard'; const keymap = prefs.editorKeymap || 'standard';
const scaleEl = document.getElementById('settingsScale'); const scaleEl = document.getElementById('settingsScale');
@@ -164,7 +164,7 @@ Object.assign(UI, {
// Load saved prefs on startup // Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14); UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
UI.applyTheme(prefs.theme || 'dark'); UI.applyTheme(prefs.theme || 'system');
// Live preview on slider change // Live preview on slider change
const scaleEl = document.getElementById('settingsScale'); const scaleEl = document.getElementById('settingsScale');
@@ -380,21 +380,27 @@ Object.assign(UI, {
try { try {
const resp = await API.teamListMembers(teamId); const resp = await API.teamListMembers(teamId);
const members = resp.data || []; const members = resp.data || [];
el.innerHTML = members.map(m => ` if (!members.length) { el.innerHTML = '<div class="empty-hint">No members</div>'; return; }
<div class="admin-user-row" style="padding:6px 0"> el.innerHTML = `
<div class="admin-user-info"> <table class="admin-table">
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong> <thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span> <tbody>${members.map(m => `<tr>
</div> <td>
<div class="admin-user-actions"> <div style="font-weight:500;font-size:13px">${esc(m.display_name || m.email)}</div>
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select"> ${m.user_role === 'admin' ? '<span class="badge-admin" style="font-size:9px">sys-admin</span>' : ''}
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option> </td>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option> <td>
</select> <select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button> <option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
</div> <option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
</div> </select>
`).join('') || '<div class="empty-hint">No members</div>'; </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>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
@@ -515,17 +521,45 @@ Object.assign(UI, {
try { try {
const resp = await API.teamListGroups(teamId); const resp = await API.teamListGroups(teamId);
const groups = resp.data || []; const groups = resp.data || [];
el.innerHTML = groups.map(g => ` if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups assigned to this team yet</div>'; return; }
<div class="admin-user-row" style="padding:6px 0"> el.innerHTML = `
<div class="admin-user-info"> <table class="admin-table">
<div><strong>${esc(g.name)}</strong></div> <thead><tr><th>Group</th><th>Members</th></tr></thead>
<div class="text-muted" style="font-size:11px">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div> <tbody>${groups.map(g => `<tr>
</div> <td><div style="font-weight:500">${esc(g.name)}</div><div class="admin-user-handle">${esc(g.description || 'No description')}</div></td>
</div> <td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
`).join('') || '<div class="empty-hint">No groups assigned to this team yet</div>'; </tr>`).join('')}</tbody>
</table>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } 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() { async loadMyUsage() {
const el = document.getElementById('myUsageTotals')?.parentElement; const el = document.getElementById('myUsageTotals')?.parentElement;
if (!el) return; if (!el) return;