Changeset 0.22.10 (#152)
This commit is contained in:
@@ -30,10 +30,22 @@
|
||||
.banner { flex-shrink: 0; }
|
||||
</style>
|
||||
<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>
|
||||
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}" data-theme="{{or .Theme "dark"}}">
|
||||
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
|
||||
{{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}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -49,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
{{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}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
<div class="auth-tabs anim anim-a2">
|
||||
<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>
|
||||
|
||||
<!-- LOGIN FORM -->
|
||||
@@ -229,6 +229,7 @@
|
||||
</div>
|
||||
|
||||
<!-- REGISTER FORM -->
|
||||
{{if .RegistrationOpen}}
|
||||
<div id="registerForm" style="display:none;">
|
||||
<div class="form-field">
|
||||
<label for="regUsername">Username</label>
|
||||
@@ -249,6 +250,7 @@
|
||||
<p class="auth-error" id="regError" style="display:none;"></p>
|
||||
<button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted instance · Your data stays on your infrastructure</p>
|
||||
@@ -272,15 +274,18 @@
|
||||
<script>
|
||||
function _switchTab(tab) {
|
||||
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('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('authSubtitle').textContent = tab === 'login'
|
||||
? 'Sign in to your Switchboard instance'
|
||||
: 'Join your team on Switchboard';
|
||||
document.getElementById('loginError').textContent = '';
|
||||
document.getElementById('regError').textContent = '';
|
||||
const regErr = document.getElementById('regError');
|
||||
if (regErr) { regErr.textContent = ''; regErr.style.display = 'none'; }
|
||||
}
|
||||
|
||||
async function _doRegister() {
|
||||
@@ -291,10 +296,11 @@
|
||||
const errEl = document.getElementById('regError');
|
||||
const btn = document.getElementById('regBtn');
|
||||
|
||||
if (!username || !password) { errEl.textContent = 'Username and password are required'; return; }
|
||||
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; return; }
|
||||
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; 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'; errEl.style.display = ''; return; }
|
||||
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = ''; return; }
|
||||
|
||||
errEl.style.display = 'none';
|
||||
errEl.textContent = '';
|
||||
btn.disabled = true; btn.textContent = 'Creating account\u2026';
|
||||
|
||||
@@ -313,7 +319,8 @@
|
||||
if (data.pending) {
|
||||
errEl.textContent = 'Account created — pending admin approval.';
|
||||
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 {
|
||||
// Auto-login
|
||||
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
|
||||
@@ -325,6 +332,7 @@
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = '';
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Create Account';
|
||||
}
|
||||
@@ -337,7 +345,7 @@
|
||||
document.getElementById('loginUsername').addEventListener('keydown', e => {
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
Back to Chat
|
||||
</a>
|
||||
<div class="admin-topbar-sep"></div>
|
||||
<span class="admin-topbar-title">🔀 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 */}}
|
||||
<div class="admin-category-tabs" id="adminCategoryTabs">
|
||||
@@ -95,6 +95,133 @@
|
||||
</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')">✕</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')">✕</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')">✕</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')">✕</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')">✕</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')">✕</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')">✕</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}}
|
||||
|
||||
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}
|
||||
|
||||
@@ -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>
|
||||
</button>
|
||||
<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>
|
||||
<span>New Chat</span>
|
||||
</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>
|
||||
<span>New Project</span>
|
||||
</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>
|
||||
<span class="sb-label">Notes</span>
|
||||
</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>
|
||||
<button id="userMenuBtn" class="user-btn">
|
||||
<div id="userAvatar" class="user-avatar">
|
||||
@@ -594,51 +599,105 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
|
||||
{{/* ── Team Admin Modal ────────────────────── */}}
|
||||
<div id="teamAdminModal" class="modal-overlay">
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<div class="modal-content modal-lg" style="max-width:900px;height:80vh;display:flex;flex-direction:column;">
|
||||
<div class="modal-header" style="flex-shrink:0;">
|
||||
<h2 id="teamAdminTitle">Team Admin</h2>
|
||||
<button id="teamAdminCloseBtn" class="modal-close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" style="display:flex;min-height:300px;">
|
||||
<div id="teamAdminPicker" class="admin-sidebar">
|
||||
<div class="modal-body" style="display:flex;flex:1;min-height:0;padding:0;">
|
||||
{{/* Team picker (multi-team admins) */}}
|
||||
<div id="teamAdminPicker" style="padding:20px;flex:1;overflow-y:auto;">
|
||||
<div id="teamAdminPickerList"></div>
|
||||
</div>
|
||||
<div id="teamAdminContent" style="flex:1;overflow-y:auto;padding:16px;">
|
||||
<div id="adminCurrentUser" style="display:none;"></div>
|
||||
<div id="adminTeamDetail" style="display:none;">
|
||||
<h3 id="adminTeamDetailName"></h3>
|
||||
<div id="adminMemberList"></div>
|
||||
<div id="adminAddMemberForm" style="display:none;">
|
||||
<select id="adminMemberUser"></select>
|
||||
|
||||
{{/* Tabbed team management */}}
|
||||
<div id="teamAdminContent" style="display:none;flex:1;display:flex;flex-direction:column;min-width:0;">
|
||||
{{/* Tab bar */}}
|
||||
<div id="teamAdminTabs" style="display:flex;gap:2px;padding:8px 16px;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto;">
|
||||
<button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
|
||||
<button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
|
||||
<button class="admin-tab" data-ttab="personas" onclick="UI.switchTeamTab('personas')">Personas</button>
|
||||
<button class="admin-tab" data-ttab="knowledge" onclick="UI.switchTeamTab('knowledge')">Knowledge</button>
|
||||
<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 id="adminTeamAllowProviders" style="display:none;"></div>
|
||||
<div id="adminTeamPrivatePolicy" style="display:none;"></div>
|
||||
</div>
|
||||
<div id="adminGroupDetail" style="display:none;">
|
||||
<div id="adminGroupDetailMeta"></div>
|
||||
<h3 id="adminGroupDetailName"></h3>
|
||||
<div id="adminGroupList" style="display:none;"></div>
|
||||
<div id="adminGroupMemberList"></div>
|
||||
<div id="adminAddGroupMemberForm" style="display:none;">
|
||||
<select id="adminGroupMemberUser"></select>
|
||||
|
||||
{{/* Tab content panels */}}
|
||||
<div style="flex:1;overflow-y:auto;padding:16px;">
|
||||
{{/* Members */}}
|
||||
<div id="teamTabMembers" class="team-tab-content">
|
||||
<div id="settingsTeamMembers"></div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{{/* Team persona model select */}}
|
||||
|
||||
{{/* 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>
|
||||
</div>
|
||||
|
||||
@@ -89,9 +89,9 @@
|
||||
<div class="settings-section">
|
||||
<h3>Theme</h3>
|
||||
<div id="themeToggle" class="toggle-group">
|
||||
<button class="toggle-btn" data-theme="light">☀ Light</button>
|
||||
<button class="toggle-btn" data-theme="dark">☾ Dark</button>
|
||||
<button class="toggle-btn" data-theme="system">⊞ System</button>
|
||||
<button class="theme-btn" data-theme="light">☀ Light</button>
|
||||
<button class="theme-btn" data-theme="dark">☾ Dark</button>
|
||||
<button class="theme-btn" data-theme="system">⊞ System</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
|
||||
@@ -258,6 +258,16 @@
|
||||
.sidebar-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sidebar.collapsed .sidebar-notes-btn .sb-label { opacity: 0; width: 0; }
|
||||
.sidebar.collapsed .sidebar-notes-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar-editor-btn {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 4px 8px; color: var(--text-2); text-decoration: none;
|
||||
font-size: 13px; white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.sidebar-editor-btn:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||
.sidebar-editor-btn svg { flex-shrink: 0; }
|
||||
.sidebar.collapsed .sidebar-editor-btn .sb-label { opacity: 0; width: 0; }
|
||||
.sidebar.collapsed .sidebar-editor-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .sidebar-bottom-divider { margin: 4px 12px; }
|
||||
|
||||
.user-btn {
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px;
|
||||
min-height: 52px;
|
||||
}
|
||||
.admin-user-info { flex: 1; }
|
||||
.admin-user-info { flex: 1; text-align: left; min-width: 0; }
|
||||
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }
|
||||
|
||||
@@ -248,6 +248,14 @@
|
||||
}
|
||||
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
|
||||
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
|
||||
.settings-content .settings-section {
|
||||
background: none; border: none; border-radius: 0;
|
||||
padding: 0 0 16px; margin-bottom: 20px; max-width: 560px;
|
||||
}
|
||||
.settings-content .settings-section h3 {
|
||||
font-size: 14px; font-weight: 600; margin: 0 0 14px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-section {
|
||||
margin-bottom: 24px; padding: 20px;
|
||||
background: var(--bg-surface); border-radius: 10px;
|
||||
@@ -328,16 +336,6 @@
|
||||
}
|
||||
.admin-content-body { flex: 1; overflow-y: auto; padding: 24px; }
|
||||
|
||||
/* ── Stat Cards ──────────────────────────── */
|
||||
.stat-cards { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
padding: 16px 20px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border); border-radius: 10px; flex: 1;
|
||||
}
|
||||
.stat-card-value { font-size: 24px; font-weight: 700; }
|
||||
.stat-card-label { font-size: 12px; color: var(--text-2); margin-top: 2px; }
|
||||
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
/* ── Data Tables ─────────────────────────── */
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th {
|
||||
@@ -419,6 +417,103 @@
|
||||
padding: 8px 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Stat Cards Grid ──────────────────────── */
|
||||
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px;
|
||||
}
|
||||
.stat-card-label { font-size: 11px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.stat-card-value { font-size: 24px; font-weight: 700; line-height: 1; }
|
||||
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
||||
|
||||
/* ── Bar Chart ───────────────────────────── */
|
||||
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 140px; }
|
||||
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--font-mono, monospace); }
|
||||
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: 4px; position: relative; height: 100px; }
|
||||
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; }
|
||||
.bar-chart-label { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
/* ── Admin Settings Form (flat sections, prototype match) ── */
|
||||
.admin-settings-form { max-width: 600px; }
|
||||
.admin-settings-form .settings-section {
|
||||
background: none; border: none; border-radius: 0;
|
||||
padding: 0 0 16px; margin-bottom: 20px;
|
||||
}
|
||||
.admin-settings-form .settings-section h3 {
|
||||
font-size: 14px; font-weight: 600; margin: 0 0 14px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-settings-form .toggle-label { margin-bottom: 8px; }
|
||||
|
||||
/* ── Toggle Switch ────────────────────────── */
|
||||
.toggle-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-1); }
|
||||
.toggle-label input[type="checkbox"] { display: none; }
|
||||
.toggle-track {
|
||||
position: relative; width: 36px; height: 20px; flex-shrink: 0;
|
||||
background: var(--text-3); border-radius: 10px; transition: background 0.2s;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: ''; position: absolute; top: 2px; left: 2px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; transition: transform 0.2s;
|
||||
}
|
||||
.toggle-label input:checked + .toggle-track { background: var(--accent); }
|
||||
.toggle-label input:checked + .toggle-track::after { transform: translateX(16px); }
|
||||
.toggle-hint { font-size: 11px; color: var(--text-3); margin-left: 46px; margin-top: 2px; }
|
||||
|
||||
/* ── Admin Table ─────────────────────────── */
|
||||
.admin-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }
|
||||
.admin-table th {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text-3); padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||||
font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.admin-table td { padding: 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.admin-table tbody tr { transition: background var(--transition); }
|
||||
.admin-table tbody tr:hover { background: var(--bg-hover); }
|
||||
.admin-table-footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 12px; font-size: 12px; color: var(--text-3);
|
||||
}
|
||||
.admin-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600;
|
||||
background: var(--accent-dim); color: var(--accent);
|
||||
}
|
||||
.admin-user-name { font-weight: 600; font-size: 13px; }
|
||||
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
|
||||
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--font-mono, monospace); }
|
||||
.admin-actions-cell { white-space: nowrap; text-align: right; }
|
||||
.btn-icon {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
padding: 4px; font-size: 14px; border-radius: 4px;
|
||||
transition: color var(--transition), background var(--transition);
|
||||
}
|
||||
.btn-icon:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.btn-icon-danger:hover { color: var(--danger); }
|
||||
.badge-service { background: var(--purple-dim); color: var(--purple); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-disabled { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-active { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
|
||||
/* ── Provider Cards ──────────────────────── */
|
||||
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||
.provider-card {
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 16px; cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.provider-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
||||
.provider-card-name { font-weight: 600; font-size: 14px; }
|
||||
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
|
||||
.badge-healthy { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
|
||||
.badge-degraded { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
|
||||
.badge-unhealthy { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
|
||||
.badge-unknown { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
|
||||
.badge-models { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
|
||||
.badge-provider-type { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 2px 8px; border-radius: 4px; }
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; }
|
||||
|
||||
@@ -17,38 +17,37 @@ async function toggleUserActive(id, active) {
|
||||
}
|
||||
|
||||
async function showApproveForm(userId, username) {
|
||||
const form = document.getElementById(`approveForm-${userId}`);
|
||||
const teamsEl = document.getElementById(`approveTeams-${userId}`);
|
||||
if (!form || !teamsEl) return;
|
||||
|
||||
// Load teams for checkboxes
|
||||
document.getElementById('approveUserId').value = userId;
|
||||
document.getElementById('approveUserTitle').textContent = `Approve "${username}"`;
|
||||
const teamsEl = document.getElementById('approveTeamsList');
|
||||
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
|
||||
form.style.display = '';
|
||||
openModal('approveUserModal');
|
||||
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = (resp.data || []).filter(t => t.is_active);
|
||||
if (teams.length === 0) {
|
||||
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
|
||||
} else {
|
||||
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:4px">Assign to teams:</label>' +
|
||||
teams.map(t => `<label class="checkbox-label" style="font-size:13px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> ${esc(t.name)}</label>`).join('');
|
||||
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:8px;display:block">Assign to teams:</label>' +
|
||||
teams.map(t => `<label class="toggle-label" style="margin-bottom:6px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> <span>${esc(t.name)}</span></label>`).join('');
|
||||
}
|
||||
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
|
||||
}
|
||||
|
||||
function hideApproveForm(userId) {
|
||||
const form = document.getElementById(`approveForm-${userId}`);
|
||||
if (form) form.style.display = 'none';
|
||||
closeModal('approveUserModal');
|
||||
}
|
||||
|
||||
async function submitApproval(userId) {
|
||||
const form = document.getElementById(`approveForm-${userId}`);
|
||||
const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value);
|
||||
if (!userId) userId = document.getElementById('approveUserId').value;
|
||||
const teamIds = [...(document.querySelectorAll('#approveTeamsList .approve-team-cb:checked') || [])].map(cb => cb.value);
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(userId, true, teamIds, 'member');
|
||||
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
|
||||
UI.toast(msg, 'success');
|
||||
closeModal('approveUserModal');
|
||||
await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
@@ -73,27 +72,38 @@ async function createAdminUser() {
|
||||
const r = document.getElementById('adminNewRole').value;
|
||||
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
|
||||
await API.adminCreateUser(u, e, p, r);
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
closeModal('createUserModal');
|
||||
// Clear form fields
|
||||
document.getElementById('adminNewUsername').value = '';
|
||||
document.getElementById('adminNewEmail').value = '';
|
||||
document.getElementById('adminNewPassword').value = '';
|
||||
document.getElementById('adminNewRole').value = 'user';
|
||||
UI.toast('User created', 'success');
|
||||
await UI.loadAdminUsers();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function adminResetUserPassword(id, username) {
|
||||
const pw = prompt(
|
||||
`Reset password for "${username}"?\n\n` +
|
||||
`⚠️ WARNING: This will DESTROY the user's personal vault.\n` +
|
||||
`All personal API keys (BYOK) will be permanently deleted.\n` +
|
||||
`The user will need to re-add any personal provider keys.\n\n` +
|
||||
`Enter new password (min 8 chars):`
|
||||
);
|
||||
if (!pw) return;
|
||||
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
|
||||
// Show the reset password modal
|
||||
document.getElementById('resetPwUser').textContent = username;
|
||||
document.getElementById('resetPwInput').value = '';
|
||||
openModal('resetPwModal');
|
||||
document.getElementById('resetPwInput').focus();
|
||||
|
||||
// Wire up the confirm button (remove old listener, add new)
|
||||
const btn = document.getElementById('resetPwConfirmBtn');
|
||||
const newBtn = btn.cloneNode(true);
|
||||
btn.parentNode.replaceChild(newBtn, btn);
|
||||
newBtn.addEventListener('click', async () => {
|
||||
const pw = document.getElementById('resetPwInput').value;
|
||||
if (!pw || pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
|
||||
if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
|
||||
try {
|
||||
await API.adminResetPassword(id, pw);
|
||||
closeModal('resetPwModal');
|
||||
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ──
|
||||
@@ -197,7 +207,7 @@ function ensureAdminPersonaForm() {
|
||||
kbScope: 'admin',
|
||||
onSubmit: (vals) => createAdminPersona(vals),
|
||||
onCancel: () => {
|
||||
container.style.display = 'none';
|
||||
closeModal('personaFormModal');
|
||||
_editingPersonaId = null;
|
||||
_adminPersonaForm.setSubmitLabel('Create');
|
||||
_adminPersonaForm.clearForm();
|
||||
@@ -262,7 +272,8 @@ function editAdminPersona(id) {
|
||||
ensureAdminPersonaForm();
|
||||
const form = _adminPersonaForm;
|
||||
if (!form) return;
|
||||
document.getElementById('adminAddPersonaForm').style.display = '';
|
||||
document.getElementById('personaFormTitle').textContent = 'Edit Persona';
|
||||
openModal('personaFormModal');
|
||||
form.setValues(p);
|
||||
form.setSubmitLabel('Update');
|
||||
// Load KB bindings (v0.17.0)
|
||||
@@ -312,7 +323,7 @@ async function createAdminPersona(vals) {
|
||||
}
|
||||
|
||||
_editingPersonaId = null;
|
||||
document.getElementById('adminAddPersonaForm').style.display = 'none';
|
||||
closeModal('personaFormModal');
|
||||
if (_adminPersonaForm) {
|
||||
_adminPersonaForm.setSubmitLabel('Create');
|
||||
_adminPersonaForm.clearForm();
|
||||
@@ -467,17 +478,18 @@ function _initAdminListeners() {
|
||||
|
||||
// Admin — users
|
||||
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddUserForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
openModal('createUserModal');
|
||||
});
|
||||
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
|
||||
|
||||
// Admin — approve user modal
|
||||
document.getElementById('approveUserSubmitBtn')?.addEventListener('click', () => submitApproval());
|
||||
|
||||
// Admin — providers (form managed by primitives in loadAdminProviders)
|
||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddProviderForm');
|
||||
if (UI._adminProvForm) UI._adminProvForm.setCreateMode();
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
document.getElementById('providerFormTitle').textContent = 'Add Provider';
|
||||
openModal('providerFormModal');
|
||||
});
|
||||
|
||||
// Admin — models
|
||||
@@ -490,19 +502,17 @@ function _initAdminListeners() {
|
||||
_editingPersonaId = null;
|
||||
form.setSubmitLabel('Create');
|
||||
form.clearForm();
|
||||
const f = document.getElementById('adminAddPersonaForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
document.getElementById('personaFormTitle').textContent = 'Create Persona';
|
||||
openModal('personaFormModal');
|
||||
});
|
||||
|
||||
// Admin — Teams
|
||||
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddTeamForm').style.display = '';
|
||||
openModal('createTeamModal');
|
||||
document.getElementById('adminTeamName').focus();
|
||||
});
|
||||
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
closeModal('createTeamModal');
|
||||
});
|
||||
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('adminTeamName').value.trim();
|
||||
@@ -510,7 +520,7 @@ function _initAdminListeners() {
|
||||
if (!name) return UI.toast('Team name required', 'error');
|
||||
try {
|
||||
await API.adminCreateTeam(name, desc);
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
closeModal('createTeamModal');
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
UI.toast('Team created');
|
||||
@@ -559,7 +569,7 @@ function _initAdminListeners() {
|
||||
|
||||
// ── Group management ────────────────────────
|
||||
document.getElementById('adminAddGroupBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('adminAddGroupForm').style.display = '';
|
||||
openModal('createGroupModal');
|
||||
document.getElementById('adminGroupName').focus();
|
||||
// Populate team dropdown
|
||||
try {
|
||||
@@ -574,9 +584,7 @@ function _initAdminListeners() {
|
||||
document.getElementById('adminGroupTeamRow').style.display = this.value === 'team' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelGroupBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddGroupForm').style.display = 'none';
|
||||
document.getElementById('adminGroupName').value = '';
|
||||
document.getElementById('adminGroupDesc').value = '';
|
||||
closeModal('createGroupModal');
|
||||
});
|
||||
document.getElementById('adminCreateGroupBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('adminGroupName').value.trim();
|
||||
@@ -587,7 +595,7 @@ function _initAdminListeners() {
|
||||
if (scope === 'team' && !teamId) return UI.toast('Select a team for team-scoped groups', 'error');
|
||||
try {
|
||||
await API.adminCreateGroup(name, desc, scope, teamId);
|
||||
document.getElementById('adminAddGroupForm').style.display = 'none';
|
||||
closeModal('createGroupModal');
|
||||
document.getElementById('adminGroupName').value = '';
|
||||
document.getElementById('adminGroupDesc').value = '';
|
||||
UI.toast('Group created');
|
||||
@@ -783,7 +791,7 @@ function editAdminExtension(id) {
|
||||
`;
|
||||
|
||||
// Find the extension row and insert the form after it
|
||||
const rows = document.querySelectorAll('#adminExtensionsList .admin-user-row');
|
||||
const rows = document.querySelectorAll('#adminExtensionsList tbody tr');
|
||||
for (const row of rows) {
|
||||
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
|
||||
row.insertAdjacentHTML('afterend', formHTML);
|
||||
|
||||
@@ -16,42 +16,18 @@
|
||||
// -- People -----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.users =
|
||||
'<div id="adminUserList" class="admin-list"></div>' +
|
||||
'<div id="adminAddUserForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Create User</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>' +
|
||||
'<div class="form-group"><label>Email</label><input type="text" id="adminNewEmail" placeholder="email@example.com"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>' +
|
||||
'<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminCreateUserBtn">Create User</button>' +
|
||||
'<button class="btn-small" id="adminCancelUserBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'<div id="adminUserList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.teams =
|
||||
'<div id="adminTeamList" class="admin-list"></div>' +
|
||||
'<div id="adminAddTeamForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Create Team</h4>' +
|
||||
'<div class="form-group"><label>Name</label><input type="text" id="adminTeamName" placeholder="Team name"></div>' +
|
||||
'<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Optional description"></div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminCreateTeamBtn">Create Team</button>' +
|
||||
'<button class="btn-small" id="adminCancelTeamBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="adminTeamDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="adminTeamBackBtn">← Back</button>' +
|
||||
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:12px">' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (BYOK)</label>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team providers</label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminTeamPrivatePolicy"><span class="toggle-track"></span><span>Require private providers (BYOK)</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminTeamAllowProviders" checked><span class="toggle-track"></span><span>Allow team providers</span></label>' +
|
||||
'</div>' +
|
||||
'<div id="adminMemberList" class="admin-list"></div>' +
|
||||
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
|
||||
@@ -69,19 +45,6 @@
|
||||
|
||||
SCAFFOLDING.groups =
|
||||
'<div id="adminGroupList" class="admin-list"></div>' +
|
||||
'<div id="adminAddGroupForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Create Group</h4>' +
|
||||
'<div class="form-group"><label>Name</label><input type="text" id="adminGroupName" placeholder="Group name"></div>' +
|
||||
'<div class="form-group"><label>Description</label><input type="text" id="adminGroupDesc" placeholder="Optional description"></div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Scope</label><select id="adminGroupScope"><option value="global">Global</option><option value="team">Team</option></select></div>' +
|
||||
'<div class="form-group" id="adminGroupTeamRow" style="display:none"><label>Team</label><select id="adminGroupTeam"></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminCreateGroupBtn">Create Group</button>' +
|
||||
'<button class="btn-small" id="adminCancelGroupBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="adminGroupDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="adminGroupBackBtn">← Back</button>' +
|
||||
@@ -104,8 +67,7 @@
|
||||
// -- AI ---------------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.providers =
|
||||
'<div id="adminProviderList" class="admin-list"></div>' +
|
||||
'<div id="adminAddProviderForm" class="admin-inline-form" style="display:none"></div>';
|
||||
'<div id="adminProviderList" class="provider-cards"></div>';
|
||||
|
||||
SCAFFOLDING.models =
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
@@ -115,8 +77,7 @@
|
||||
'<div id="adminModelList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.personas =
|
||||
'<div id="adminPersonaList" class="admin-list"></div>' +
|
||||
'<div id="adminAddPersonaForm" class="admin-inline-form" style="display:none"></div>';
|
||||
'<div id="adminPersonaList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
|
||||
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
|
||||
@@ -126,25 +87,25 @@
|
||||
|
||||
SCAFFOLDING.settings =
|
||||
'<div class="admin-settings-form">' +
|
||||
'<div class="settings-section"><h4>Registration</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminRegToggle"> Allow new user registration</label>' +
|
||||
'<div class="settings-section"><h3>Registration</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminRegToggle"><span class="toggle-track"></span><span>Allow new user registration</span></label>' +
|
||||
'<div class="form-group" style="margin-top:8px"><label>Default state for new users</label>' +
|
||||
'<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>System Prompt</h4>' +
|
||||
'<div class="settings-section"><h3>System Prompt</h3>' +
|
||||
'<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Default Model</h4>' +
|
||||
'<div class="settings-section"><h3>Default Model</h3>' +
|
||||
'<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Policies</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle"> Allow BYOK (user API keys)</label>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminUserPersonasToggle"> Allow user personas</label>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Allow direct KB access (non-persona)</label>' +
|
||||
'<div class="settings-section"><h3>Policies</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminUserProvidersToggle"><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminUserPersonasToggle"><span class="toggle-track"></span><span>Allow user personas</span></label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminKBDirectAccessToggle"><span class="toggle-track"></span><span>Allow direct KB access (non-persona)</span></label>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Environment Banner</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>' +
|
||||
'<div class="settings-section"><h3>Environment Banner</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminBannerEnabled"><span class="toggle-track"></span><span>Show environment banner</span></label>' +
|
||||
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
|
||||
'<div class="form-row">' +
|
||||
@@ -154,7 +115,7 @@
|
||||
'<div id="bannerPreview" class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px">PREVIEW</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Web Search</h4>' +
|
||||
'<div class="settings-section"><h3>Web Search</h3>' +
|
||||
'<div class="form-group"><label>Provider</label>' +
|
||||
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
|
||||
'</div>' +
|
||||
@@ -164,8 +125,8 @@
|
||||
'</div>' +
|
||||
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Auto-Compaction</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminCompactionEnabled"> Enable auto-compaction</label>' +
|
||||
'<div class="settings-section"><h3>Auto-Compaction</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminCompactionEnabled"><span class="toggle-track"></span><span>Enable auto-compaction</span></label>' +
|
||||
'<div id="compactionConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' +
|
||||
@@ -173,13 +134,13 @@
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Memory Extraction</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryExtractionEnabled"> Enable memory extraction</label>' +
|
||||
'<div class="settings-section"><h3>Memory Extraction</h3>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminMemoryExtractionEnabled"><span class="toggle-track"></span><span>Enable memory extraction</span></label>' +
|
||||
'<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryAutoApprove"> Auto-approve extracted memories</label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="adminMemoryAutoApprove"><span class="toggle-track"></span><span>Auto-approve extracted memories</span></label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Encryption Vault</h4>' +
|
||||
'<div class="settings-section"><h3>Encryption Vault</h3>' +
|
||||
'<div id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
|
||||
'</div>' +
|
||||
'<div id="roleFallbackBanner"></div>' +
|
||||
@@ -206,7 +167,7 @@
|
||||
'<div class="form-group"><label>Description</label><textarea id="extInstallDesc" rows="2" placeholder="What it does..."></textarea></div>' +
|
||||
'<div class="form-group"><label>Manifest JSON</label><textarea id="extInstallManifest" rows="4"></textarea></div>' +
|
||||
'<div class="form-group"><label>Script Source</label><textarea id="extInstallScript" rows="6"></textarea></div>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>' +
|
||||
'<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked><span class="toggle-track"></span><span>Enabled</span></label>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
|
||||
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
|
||||
@@ -216,8 +177,8 @@
|
||||
// -- Routing ----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.health =
|
||||
'<div style="margin-bottom:12px"><button class="btn-small" id="adminHealthRefreshBtn">Refresh</button></div>' +
|
||||
'<div id="adminHealthContent"><div class="loading">Loading...</div></div>';
|
||||
'<div id="adminHealthCards" class="stat-cards-grid"></div>' +
|
||||
'<div id="adminHealthContent"></div>';
|
||||
|
||||
SCAFFOLDING.routing =
|
||||
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
|
||||
@@ -238,6 +199,8 @@
|
||||
// -- Monitoring -------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.usage =
|
||||
'<div id="adminUsageCards" class="stat-cards-grid"></div>' +
|
||||
'<div id="adminUsageChart" style="margin-bottom:24px"></div>' +
|
||||
'<div id="adminUsageTotals"></div>' +
|
||||
'<div id="adminUsageResults" style="margin-top:12px"></div>' +
|
||||
'<div id="adminPricingTable" style="margin-top:24px"></div>';
|
||||
@@ -255,38 +218,39 @@
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.stats =
|
||||
'<div id="adminStats"><div class="loading">Loading...</div></div>';
|
||||
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
|
||||
|
||||
|
||||
// === Add button actions ==============================================
|
||||
|
||||
var ADD_ACTIONS = {
|
||||
users: function() {
|
||||
var f = document.getElementById('adminAddUserForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
openModal('createUserModal');
|
||||
var n = document.getElementById('adminNewUsername');
|
||||
if (n) n.focus();
|
||||
},
|
||||
teams: function() {
|
||||
var f = document.getElementById('adminAddTeamForm');
|
||||
if (f) f.style.display = '';
|
||||
openModal('createTeamModal');
|
||||
var n = document.getElementById('adminTeamName');
|
||||
if (n) n.focus();
|
||||
},
|
||||
providers: function() {
|
||||
var f = document.getElementById('adminAddProviderForm');
|
||||
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode();
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
document.getElementById('providerFormTitle').textContent = 'Add Provider';
|
||||
openModal('providerFormModal');
|
||||
},
|
||||
personas: function() {
|
||||
if (typeof ensureAdminPersonaForm === 'function') {
|
||||
var form = ensureAdminPersonaForm();
|
||||
if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
|
||||
}
|
||||
var f = document.getElementById('adminAddPersonaForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
document.getElementById('personaFormTitle').textContent = 'Create Persona';
|
||||
openModal('personaFormModal');
|
||||
},
|
||||
groups: function() {
|
||||
var f = document.getElementById('adminAddGroupForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
openModal('createGroupModal');
|
||||
var n = document.getElementById('adminGroupName');
|
||||
if (n) n.focus();
|
||||
},
|
||||
extensions: function() {
|
||||
var f = document.getElementById('adminInstallExtForm');
|
||||
@@ -359,6 +323,8 @@
|
||||
|
||||
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
|
||||
if (typeof _initAdminListeners === 'function') _initAdminListeners();
|
||||
// -- Wire settings toggle show/hide listeners (banner, compaction, etc.) --
|
||||
if (typeof _initAdminSettingsToggles === 'function') _initAdminSettingsToggles();
|
||||
|
||||
// -- Call the section data loader --------
|
||||
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) {
|
||||
|
||||
@@ -377,6 +377,12 @@ function _cleanStaleChatModel(chatId) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the new-chat dropdown and reset any fixed positioning from collapsed mode. */
|
||||
function closeNewChatMenu() {
|
||||
const dd = document.getElementById('newChatDropdown');
|
||||
if (dd) { dd.classList.remove('open'); dd.style.cssText = ''; }
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
clearStaged(); // Discard any staged files
|
||||
App.currentChatId = null;
|
||||
@@ -822,7 +828,21 @@ async function switchSibling(messageId, direction) {
|
||||
function _initChatListeners() {
|
||||
// Sidebar
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
document.getElementById('newChatBtn').addEventListener('click', (e) => {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (sidebar && sidebar.classList.contains('collapsed')) {
|
||||
// Collapsed: show the dropdown menu, positioned to pop out right
|
||||
const dd = document.getElementById('newChatDropdown');
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
dd.style.position = 'fixed';
|
||||
dd.style.left = rect.right + 4 + 'px';
|
||||
dd.style.top = rect.top + 'px';
|
||||
dd.style.width = '200px';
|
||||
dd.classList.toggle('open');
|
||||
} else {
|
||||
newChat();
|
||||
}
|
||||
});
|
||||
|
||||
// Split button dropdown
|
||||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||
@@ -831,7 +851,9 @@ function _initChatListeners() {
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.split-btn')) {
|
||||
document.getElementById('newChatDropdown').classList.remove('open');
|
||||
const dd = document.getElementById('newChatDropdown');
|
||||
dd.classList.remove('open');
|
||||
dd.style.cssText = ''; // Reset any fixed positioning from collapsed mode
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -263,10 +263,10 @@ async function handleSaveAdminSettings() {
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
await initBanners();
|
||||
UI.checkUserProvidersAllowed();
|
||||
UI.checkUserPersonasAllowed();
|
||||
fetchModels();
|
||||
if (typeof initBanners === 'function') await initBanners();
|
||||
if (typeof UI.checkUserProvidersAllowed === 'function') UI.checkUserProvidersAllowed();
|
||||
if (typeof UI.checkUserPersonasAllowed === 'function') UI.checkUserPersonasAllowed();
|
||||
if (typeof fetchModels === 'function') fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -662,6 +662,32 @@ function _initSettingsListeners() {
|
||||
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
|
||||
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Admin settings toggle wiring — safe to call from admin surface scaffold
|
||||
// (no App dependencies, all optional-chained)
|
||||
function _initAdminSettingsToggles() {
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||||
});
|
||||
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminCompactionEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('compactionConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
|
||||
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
|
||||
});
|
||||
// Memory extraction toggle
|
||||
document.getElementById('adminMemoryExtractionEnabled')?.addEventListener('change', (e) => {
|
||||
const f = document.getElementById('memoryExtractionConfigFields');
|
||||
if (f) f.style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
|
||||
// Admin — audit log
|
||||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||||
|
||||
@@ -188,6 +188,7 @@ Object.assign(UI, {
|
||||
if (tab === 'personas') UI.loadTeamManagePersonas(teamId);
|
||||
if (tab === 'usage') UI.loadTeamUsage();
|
||||
if (tab === 'activity') UI.loadTeamAuditLog(1);
|
||||
if (tab === 'settings') UI.loadTeamManageSettings(teamId);
|
||||
if (tab === 'knowledge') {
|
||||
if (typeof KnowledgeUI === 'undefined') {
|
||||
console.error('[TeamAdmin] KnowledgeUI not loaded — check js/knowledge-ui.js');
|
||||
@@ -206,37 +207,50 @@ Object.assign(UI, {
|
||||
try {
|
||||
const resp = await API.adminListUsers();
|
||||
const users = resp.users || resp.data || [];
|
||||
el.innerHTML = users.map(u => {
|
||||
const teamBadges = (u.teams || []).map(t =>
|
||||
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
|
||||
).join(' ');
|
||||
if (!users.length) { el.innerHTML = '<div class="empty-hint">No users</div>'; return; }
|
||||
|
||||
const rows = users.map(u => {
|
||||
const initial = (u.display_name || u.username || '?')[0].toUpperCase();
|
||||
const name = esc(u.display_name || u.username);
|
||||
const handle = '@' + esc(u.username);
|
||||
const roleCls = u.role === 'admin' ? 'badge-admin' : u.role === 'service' ? 'badge-service' : 'badge-user';
|
||||
const isPending = !u.is_active;
|
||||
return `
|
||||
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
|
||||
${isPending ? '<span class="badge-pending">pending</span>' : ''}
|
||||
${teamBadges}</div>
|
||||
<div class="admin-user-email">${esc(u.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
const statusCls = isPending ? 'badge-warning' : 'badge-active';
|
||||
const statusLabel = isPending ? 'pending' : 'active';
|
||||
const teams = (u.teams || []).map(t => esc(t.team_name)).join(', ') || '\u2014';
|
||||
const lastSeen = u.last_login_at ? _relativeTime(u.last_login_at) : '\u2014';
|
||||
|
||||
return `<tr data-name="${esc(u.username)}">
|
||||
<td><div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="admin-avatar">${initial}</div>
|
||||
<div><div class="admin-user-name">${name}</div><div class="admin-user-handle">${handle}</div></div>
|
||||
</div></td>
|
||||
<td><span class="${roleCls}">${u.role}</span></td>
|
||||
<td><span class="${statusCls}">${statusLabel}</span></td>
|
||||
<td style="font-size:12px;color:var(--text-2)">${teams}</td>
|
||||
<td class="admin-user-time">${lastSeen}</td>
|
||||
<td class="admin-actions-cell">
|
||||
${isPending
|
||||
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
|
||||
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
|
||||
? `<button class="btn-icon" onclick="showApproveForm('${u.id}','${esc(u.username)}')" title="Approve">✓</button>`
|
||||
: `<button class="btn-icon" onclick="toggleUserActive('${u.id}',false)" title="Disable">⏸</button>`
|
||||
}
|
||||
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
|
||||
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
|
||||
<div class="approve-teams" id="approveTeams-${u.id}"></div>
|
||||
<div class="form-row" style="margin-top:8px">
|
||||
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
|
||||
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
|
||||
</div>
|
||||
<button class="btn-icon" onclick="toggleUserRole('${u.id}','${u.role === 'admin' ? 'user' : 'admin'}')" title="${u.role === 'admin' ? 'Demote' : 'Promote'}">⇅</button>
|
||||
<button class="btn-icon" onclick="adminResetUserPassword('${u.id}','${esc(u.username)}')" title="Reset PW">🔑</button>
|
||||
<button class="btn-icon btn-icon-danger" onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Delete">🗑</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Teams</th><th>Last Seen</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer">
|
||||
<span>${users.length} user${users.length !== 1 ? 's' : ''}</span>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No users</div>';
|
||||
|
||||
// Approve form placeholder (injected per-user when needed)
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -245,14 +259,12 @@ Object.assign(UI, {
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const s = await API.adminGetStats();
|
||||
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
|
||||
el.innerHTML = '<div class="stats-grid">' +
|
||||
Object.entries(s).map(([k, v]) => `
|
||||
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages', models: 'Models', providers: 'Providers', personas: 'Personas' };
|
||||
el.innerHTML = Object.entries(s).map(([k, v]) => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
|
||||
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
<div class="stat-card-label">${labels[k] || k.replace(/_/g, ' ')}</div>
|
||||
<div class="stat-card-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
|
||||
</div>`).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -291,10 +303,64 @@ Object.assign(UI, {
|
||||
// ── Admin: Usage ────────────────────────
|
||||
|
||||
async loadAdminUsage() {
|
||||
// Usage stats via primitive
|
||||
// Stat cards + bar chart
|
||||
const cardsEl = document.getElementById('adminUsageCards');
|
||||
const chartEl = document.getElementById('adminUsageChart');
|
||||
|
||||
// Fetch 7-day usage for cards and chart
|
||||
if (cardsEl) {
|
||||
try {
|
||||
const weekData = await API.adminGetUsage({ period: '7d', group_by: 'day' });
|
||||
const buckets = weekData.data || weekData.buckets || [];
|
||||
const totalReqs = buckets.reduce((s, b) => s + (b.request_count || 0), 0);
|
||||
const totalIn = buckets.reduce((s, b) => s + (b.input_tokens || 0), 0);
|
||||
const totalOut = buckets.reduce((s, b) => s + (b.output_tokens || 0), 0);
|
||||
const totalTokens = totalIn + totalOut;
|
||||
const tokenStr = totalTokens > 1e6 ? (totalTokens / 1e6).toFixed(1) + 'M' : totalTokens > 1e3 ? (totalTokens / 1e3).toFixed(0) + 'K' : totalTokens;
|
||||
const userSet = new Set(buckets.filter(b => b.user_id).map(b => b.user_id));
|
||||
const avgMs = buckets.reduce((s, b) => s + (b.avg_latency_ms || 0), 0);
|
||||
const avgLatency = buckets.length ? (avgMs / buckets.length / 1000).toFixed(1) + 's' : '—';
|
||||
|
||||
cardsEl.innerHTML = `
|
||||
<div class="stat-card"><div class="stat-card-label">Total Requests</div><div class="stat-card-value">${totalReqs.toLocaleString()}</div><div class="stat-card-sub">Last 7 days</div></div>
|
||||
<div class="stat-card"><div class="stat-card-label">Tokens Used</div><div class="stat-card-value">${tokenStr}</div><div class="stat-card-sub">Input + output</div></div>
|
||||
<div class="stat-card"><div class="stat-card-label">Active Users</div><div class="stat-card-value">${userSet.size || '—'}</div><div class="stat-card-sub">Last 7 days</div></div>
|
||||
<div class="stat-card"><div class="stat-card-label">Avg Response</div><div class="stat-card-value">${avgLatency}</div><div class="stat-card-sub">p50 latency</div></div>`;
|
||||
|
||||
// Bar chart — aggregate by day label
|
||||
if (chartEl && buckets.length > 0) {
|
||||
const dayMap = {};
|
||||
buckets.forEach(b => {
|
||||
const d = new Date(b.window_start || b.date || b.bucket);
|
||||
const label = d.toLocaleDateString('en', { weekday: 'short' });
|
||||
dayMap[label] = (dayMap[label] || 0) + (b.request_count || 0);
|
||||
});
|
||||
const days = Object.entries(dayMap);
|
||||
const maxVal = Math.max(...days.map(d => d[1]), 1);
|
||||
chartEl.innerHTML = `
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;padding:20px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:16px">Requests per Day</div>
|
||||
<div class="bar-chart">
|
||||
${days.map(([label, val]) => {
|
||||
const pct = Math.round(val / maxVal * 100);
|
||||
return `<div class="bar-chart-col">
|
||||
<span class="bar-chart-val">${val}</span>
|
||||
<div class="bar-chart-track"><div class="bar-chart-fill" style="height:${pct}%"></div></div>
|
||||
<span class="bar-chart-label">${label}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
cardsEl.innerHTML = '';
|
||||
if (chartEl) chartEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Usage breakdown via primitive (preserves existing period/group-by controls)
|
||||
const totalsEl = document.getElementById('adminUsageTotals');
|
||||
if (!totalsEl) return;
|
||||
// Wrap totals+results in a container for the primitive (first time only)
|
||||
let usageContainer = document.getElementById('_adminUsageContainer');
|
||||
if (!usageContainer) {
|
||||
usageContainer = document.createElement('div');
|
||||
@@ -316,7 +382,7 @@ Object.assign(UI, {
|
||||
}
|
||||
await UI._adminUsageDash.refresh();
|
||||
|
||||
// Pricing table (admin-only, separate from usage primitive)
|
||||
// Pricing table
|
||||
const pricingEl = document.getElementById('adminPricingTable');
|
||||
if (pricingEl) {
|
||||
try {
|
||||
@@ -351,47 +417,36 @@ Object.assign(UI, {
|
||||
try {
|
||||
const resp = await API._get('/api/v1/admin/extensions');
|
||||
const exts = resp.data || [];
|
||||
this._adminExtensions = exts; // store for edit access
|
||||
this._adminExtensions = exts;
|
||||
if (exts.length === 0) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
|
||||
el.innerHTML = '<div class="empty-hint">No extensions installed</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = exts.map(ext => {
|
||||
const statusBadge = ext.is_enabled
|
||||
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
|
||||
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
|
||||
const systemBadge = ext.is_system
|
||||
? ' <span class="badge-user" style="font-size:11px">system</span>'
|
||||
: '';
|
||||
return `
|
||||
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
|
||||
<div class="admin-user-info" style="flex:1">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<strong>${esc(ext.name)}</strong>
|
||||
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
|
||||
${statusBadge}${systemBadge}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-top:2px">
|
||||
${esc(ext.description || 'No description')}
|
||||
${ext.author ? ` · by ${esc(ext.author)}` : ''}
|
||||
· v${esc(ext.version)} · ${ext.tier}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
|
||||
${ext.is_enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Extension</th><th>Status</th><th>Tier</th><th>Version</th><th></th></tr></thead>
|
||||
<tbody>${exts.map(ext => {
|
||||
const statusBadge = ext.is_enabled ? '<span class="badge-active">enabled</span>' : '<span class="badge-disabled">disabled</span>';
|
||||
const systemBadge = ext.is_system ? ' <span class="badge-user">system</span>' : '';
|
||||
return `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500">${esc(ext.name)}${systemBadge}</div>
|
||||
<div class="admin-user-handle">${esc(ext.description || 'No description')}${ext.author ? ' · by ' + esc(ext.author) : ''}</div>
|
||||
</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td><span class="badge-provider-type">${esc(ext.tier)}</span></td>
|
||||
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">v${esc(ext.version)}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon" onclick="editAdminExtension('${ext.id}')" title="Edit">✎</button>
|
||||
<button class="btn-icon" onclick="toggleAdminExtension('${ext.id}',${!ext.is_enabled})" title="${ext.is_enabled ? 'Disable' : 'Enable'}">${ext.is_enabled ? '⏸' : '▶'}</button>
|
||||
<button class="btn-icon btn-icon-danger" onclick="deleteAdminExtension('${ext.id}','${esc(ext.name)}')" title="Uninstall">🗑</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${exts.length} extension${exts.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
|
||||
el.innerHTML = '<div class="empty-hint">Failed to load extensions</div>';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -433,24 +488,24 @@ Object.assign(UI, {
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
|
||||
} else {
|
||||
el.innerHTML = entries.map(e => {
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Action</th><th>Actor</th><th>Resource</th><th>Details</th><th>Time</th><th>IP</th></tr></thead>
|
||||
<tbody>${entries.map(e => {
|
||||
const ts = new Date(e.created_at);
|
||||
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
const actor = e.actor_name || 'system';
|
||||
const meta = UI._formatAuditMeta(e.metadata);
|
||||
return `<div class="audit-row">
|
||||
<div class="audit-main">
|
||||
<span class="audit-action">${esc(e.action)}</span>
|
||||
<span class="audit-actor">${esc(actor)}</span>
|
||||
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||
</div>
|
||||
<div class="audit-meta">
|
||||
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||
<span class="audit-time">${timeStr}</span>
|
||||
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<tr>
|
||||
<td><span style="font-weight:600;color:var(--accent);font-family:var(--font-mono,monospace);font-size:12px">${esc(e.action)}</span></td>
|
||||
<td style="font-size:12px">${esc(actor)}</td>
|
||||
<td style="font-size:12px;color:var(--text-3)">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</td>
|
||||
<td style="font-size:11px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${meta || '—'}</td>
|
||||
<td class="admin-user-time">${timeStr}</td>
|
||||
<td style="font-size:11px;color:var(--text-3);font-family:var(--font-mono,monospace)">${e.ip_address ? esc(e.ip_address) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
@@ -478,7 +533,7 @@ Object.assign(UI, {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
const formEl = document.getElementById('adminAddProviderForm');
|
||||
|
||||
// Initialize admin provider form primitive (once)
|
||||
// Initialize admin provider form primitive (once) — now renders inside modal body
|
||||
if (!UI._adminProvForm && formEl) {
|
||||
UI._adminProvForm = renderProviderForm(formEl, {
|
||||
prefix: 'adminProv',
|
||||
@@ -500,43 +555,90 @@ Object.assign(UI, {
|
||||
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
|
||||
UI.toast('Provider added', 'success');
|
||||
}
|
||||
formEl.style.display = 'none';
|
||||
closeModal('providerFormModal');
|
||||
UI._adminProvForm.setCreateMode();
|
||||
UI._adminProvList.refresh();
|
||||
UI.loadAdminProviders(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => {
|
||||
formEl.style.display = 'none';
|
||||
closeModal('providerFormModal');
|
||||
UI._adminProvForm.setCreateMode();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize admin provider list primitive (once)
|
||||
if (!UI._adminProvList) {
|
||||
UI._adminProvList = renderProviderList(el, {
|
||||
apiFetch: () => API.adminListGlobalConfigs(),
|
||||
showEndpoint: true,
|
||||
showPrivate: true,
|
||||
emptyMsg: 'No global providers — add one above',
|
||||
onEdit: (prov) => {
|
||||
if (UI._adminProvForm) {
|
||||
UI._adminProvForm.setEditMode(prov.id, prov);
|
||||
formEl.style.display = '';
|
||||
}
|
||||
},
|
||||
onDelete: async (prov) => {
|
||||
if (!await showConfirm('Delete this global provider?')) return;
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
await API.adminDeleteGlobalConfig(prov.id);
|
||||
UI.toast('Provider deleted', 'success');
|
||||
UI._adminProvList.refresh();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
// Fetch providers, health, and models in parallel
|
||||
const [configsRaw, healthRaw, modelsRaw] = await Promise.all([
|
||||
API.adminListGlobalConfigs(),
|
||||
API.adminGetAllProviderHealth().catch(() => ({ providers: [] })),
|
||||
API.adminListModels().catch(() => ({ models: [] })),
|
||||
]);
|
||||
|
||||
const configs = Array.isArray(configsRaw) ? configsRaw : (configsRaw.configs || configsRaw.providers || configsRaw.data || []);
|
||||
const healthList = healthRaw.providers || healthRaw.data || healthRaw || [];
|
||||
const models = modelsRaw.models || modelsRaw.data || modelsRaw || [];
|
||||
|
||||
// Build health lookup by provider_config_id
|
||||
const healthMap = {};
|
||||
(Array.isArray(healthList) ? healthList : []).forEach(h => { healthMap[h.provider_config_id] = h; });
|
||||
|
||||
// Count models per provider
|
||||
const modelCounts = {};
|
||||
(Array.isArray(models) ? models : []).forEach(m => {
|
||||
const pid = m.provider_config_id;
|
||||
if (pid) modelCounts[pid] = (modelCounts[pid] || 0) + 1;
|
||||
});
|
||||
|
||||
// Cache for edit
|
||||
UI._providerCache = {};
|
||||
configs.forEach(c => { UI._providerCache[c.id] = c; });
|
||||
|
||||
if (!configs.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No providers configured \u2014 click + Add above</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
await UI._adminProvList.refresh(quiet);
|
||||
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');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async loadAdminModels(quiet) {
|
||||
@@ -546,20 +648,26 @@ Object.assign(UI, {
|
||||
const data = await API.adminListModels();
|
||||
const list = data.models || data.data || data || [];
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(m => {
|
||||
if (!arr.length) { el.innerHTML = '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>'; return; }
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Capabilities</th><th>Visibility</th></tr></thead>
|
||||
<tbody>${arr.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = renderCapBadges(caps, { compact: true });
|
||||
// Support both old is_enabled (bool) and new visibility (string)
|
||||
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
|
||||
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
|
||||
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
|
||||
return `<div class="admin-model-row">
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges}</span>
|
||||
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
|
||||
return `<tr>
|
||||
<td style="font-weight:500;font-family:var(--font-mono,monospace);font-size:12px">${esc(m.model_id || m.id)}</td>
|
||||
<td style="font-size:12px;color:var(--text-3)">${esc(m.provider_name || '')}</td>
|
||||
<td>${badges}</td>
|
||||
<td><button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}','${vis}')">${visLabel}</button></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${arr.length} model${arr.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -607,36 +715,44 @@ Object.assign(UI, {
|
||||
}
|
||||
|
||||
UI._personaCache = {};
|
||||
el.innerHTML = list.map(p => {
|
||||
if (!list.length) { el.innerHTML = '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>'; return; }
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Persona</th><th>Scope</th><th>Model</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>${list.map(p => {
|
||||
UI._personaCache[p.id] = p;
|
||||
const avatarEl = p.avatar
|
||||
? `<img src="${p.avatar}" class="persona-row-avatar" alt="">`
|
||||
? `<img src="${p.avatar}" style="width:24px;height:24px;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:6px" alt="">`
|
||||
: '';
|
||||
// Scope badge with attribution
|
||||
let scopeBadge = '';
|
||||
if (p.scope === 'global') {
|
||||
scopeBadge = '<span class="badge-admin">global</span>';
|
||||
} else if (p.scope === 'team' && p.team_name) {
|
||||
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
|
||||
} else if (p.scope === 'personal') {
|
||||
scopeBadge = '<span class="badge-user">personal</span>';
|
||||
}
|
||||
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
|
||||
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||
return `<div class="admin-persona-row" data-grant-persona="${p.id}">
|
||||
<div class="persona-info">
|
||||
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
|
||||
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
${p.description ? `<div class="persona-desc">${esc(p.description)}</div>` : ''}
|
||||
if (p.scope === 'global') scopeBadge = '<span class="badge-admin">global</span>';
|
||||
else if (p.scope === 'team' && p.team_name) scopeBadge = `<span class="badge-team">${esc(p.team_name)}</span>`;
|
||||
else if (p.scope === 'personal') scopeBadge = '<span class="badge-user">personal</span>';
|
||||
const creator = p.creator_name ? `<div class="admin-user-handle">by ${esc(p.creator_name)}</div>` : '';
|
||||
return `<tr data-grant-persona="${p.id}">
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${avatarEl}
|
||||
<div>
|
||||
<div style="font-weight:500">${esc(p.name)}</div>
|
||||
${p.description ? `<div class="admin-user-handle" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.description)}</div>` : creator}
|
||||
</div>
|
||||
<div class="persona-actions">
|
||||
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona="${p.id}"]')" title="Manage access">🔒 Access</button>
|
||||
<button class="btn-edit" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
|
||||
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPersona('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteAdminPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>';
|
||||
</td>
|
||||
<td>${scopeBadge}</td>
|
||||
<td style="font-size:12px;color:var(--text-3);font-family:var(--font-mono,monospace)">${esc(p.base_model_id)}</td>
|
||||
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon" onclick="UI.openGrantPicker('persona','${p.id}','${esc(p.name)}','[data-grant-persona="${p.id}"]')" title="Access">🔒</button>
|
||||
<button class="btn-icon" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
|
||||
<button class="btn-icon" onclick="toggleAdminPersona('${p.id}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
|
||||
<button class="btn-icon btn-icon-danger" onclick="deleteAdminPersona('${p.id}','${esc(p.name)}')" title="Delete">🗑</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${list.length} persona${list.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -650,33 +766,40 @@ Object.assign(UI, {
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
detail.style.display = 'none';
|
||||
el.style.display = '';
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
closeModal('createTeamModal');
|
||||
}
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = resp.data || [];
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(t.name)}</strong>
|
||||
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
|
||||
</div>
|
||||
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
|
||||
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
|
||||
if (!teams.length) { el.innerHTML = '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>'; return; }
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Team</th><th>Status</th><th>Members</th><th></th></tr></thead>
|
||||
<tbody>${teams.map(t => `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500">${esc(t.name)}</div>
|
||||
<div class="admin-user-handle">${esc(t.description || 'No description')}</div>
|
||||
</td>
|
||||
<td>
|
||||
${t.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-warning">inactive</span>'}
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--text-2)">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon" onclick="UI.openTeamDetail('${t.id}','${esc(t.name)}')" title="Members">👥</button>
|
||||
<button class="btn-icon" onclick="toggleTeamActive('${t.id}',${!t.is_active})" title="${t.is_active ? 'Disable' : 'Enable'}">${t.is_active ? '⏸' : '▶'}</button>
|
||||
<button class="btn-icon btn-icon-danger" onclick="deleteTeam('${t.id}','${esc(t.name)}')" title="Delete">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${teams.length} team${teams.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async openTeamDetail(teamId, teamName) {
|
||||
this._teamEditId = teamId;
|
||||
document.getElementById('adminTeamList').style.display = 'none';
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
closeModal('createTeamModal');
|
||||
const detail = document.getElementById('adminTeamDetail');
|
||||
detail.style.display = '';
|
||||
document.getElementById('adminTeamDetailName').textContent = teamName;
|
||||
@@ -700,25 +823,29 @@ Object.assign(UI, {
|
||||
try {
|
||||
const resp = await API.adminListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members — add users to this team</div>'; return; }
|
||||
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(m.display_name || m.email)}</strong>
|
||||
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
|
||||
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
|
||||
</div>
|
||||
<div class="text-muted">${esc(m.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>${members.map(m => `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500">${esc(m.display_name || m.email)}</div>
|
||||
<div class="admin-user-handle">${esc(m.email)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<select onchange="updateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
|
||||
</select>
|
||||
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
|
||||
${m.user_role === 'admin' ? '<span class="badge-admin" style="margin-left:4px;font-size:9px">sys-admin</span>' : ''}
|
||||
</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon btn-icon-danger" onclick="removeTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -747,33 +874,42 @@ Object.assign(UI, {
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
detail.style.display = 'none';
|
||||
el.style.display = '';
|
||||
document.getElementById('adminAddGroupForm').style.display = 'none';
|
||||
closeModal('createGroupModal');
|
||||
}
|
||||
try {
|
||||
const resp = await API.adminListGroups();
|
||||
const groups = resp.data || [];
|
||||
el.innerHTML = groups.map(g => {
|
||||
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>'; return; }
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Group</th><th>Scope</th><th>Members</th><th></th></tr></thead>
|
||||
<tbody>${groups.map(g => {
|
||||
const scopeBadge = g.scope === 'global'
|
||||
? '<span class="badge-admin">global</span>'
|
||||
: `<span class="badge-team">team</span>`;
|
||||
return `<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(g.name)}</strong> ${scopeBadge}</div>
|
||||
<div class="text-muted">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-small" onclick="UI.openGroupDetail('${g.id}', '${esc(g.name)}')">Members</button>
|
||||
<button class="btn-delete" onclick="deleteGroup('${g.id}', '${esc(g.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No groups yet — create one to control access to personas and knowledge bases</div>';
|
||||
: '<span class="badge-team">team</span>';
|
||||
return `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500">${esc(g.name)}</div>
|
||||
<div class="admin-user-handle">${esc(g.description || 'No description')}</div>
|
||||
</td>
|
||||
<td>${scopeBadge}</td>
|
||||
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon" onclick="UI.openGroupDetail('${g.id}','${esc(g.name)}')" title="Members">👥</button>
|
||||
<button class="btn-icon btn-icon-danger" onclick="deleteGroup('${g.id}','${esc(g.name)}')" title="Delete">🗑</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${groups.length} group${groups.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async openGroupDetail(groupId, groupName) {
|
||||
this._groupEditId = groupId;
|
||||
document.getElementById('adminGroupList').style.display = 'none';
|
||||
document.getElementById('adminAddGroupForm').style.display = 'none';
|
||||
closeModal('createGroupModal');
|
||||
const detail = document.getElementById('adminGroupDetail');
|
||||
detail.style.display = '';
|
||||
document.getElementById('adminGroupDetailName').textContent = groupName;
|
||||
@@ -795,17 +931,22 @@ Object.assign(UI, {
|
||||
try {
|
||||
const resp = await API.adminListGroupMembers(groupId);
|
||||
const members = resp.data || [];
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(m.display_name || m.username || m.email)}</strong></div>
|
||||
<div class="text-muted">${esc(m.email)} · added ${new Date(m.added_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-delete" onclick="removeGroupMember('${groupId}', '${m.user_id}', '${esc(m.username || m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members — add users to this group</div>';
|
||||
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members — add users to this group</div>'; return; }
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Member</th><th>Added</th><th></th></tr></thead>
|
||||
<tbody>${members.map(m => `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500">${esc(m.display_name || m.username || m.email)}</div>
|
||||
<div class="admin-user-handle">${esc(m.email)}</div>
|
||||
</td>
|
||||
<td class="admin-user-time">${new Date(m.added_at).toLocaleDateString()}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon btn-icon-danger" onclick="removeGroupMember('${groupId}','${m.user_id}','${esc(m.username || m.email)}')" title="Remove">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -879,11 +1020,11 @@ Object.assign(UI, {
|
||||
|
||||
// ── Provider Health Dashboard (v0.22.3) ──────
|
||||
async loadAdminHealth() {
|
||||
const cardsEl = document.getElementById('adminHealthCards');
|
||||
const el = document.getElementById('adminHealthContent');
|
||||
if (cardsEl) cardsEl.innerHTML = '';
|
||||
el.innerHTML = '<div class="loading">Loading health data...</div>';
|
||||
|
||||
document.getElementById('adminHealthRefreshBtn')?.addEventListener('click', () => UI.loadAdminHealth(), { once: true });
|
||||
|
||||
try {
|
||||
const data = await API.adminGetAllProviderHealth();
|
||||
const providers = data.data || [];
|
||||
@@ -893,54 +1034,48 @@ Object.assign(UI, {
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = '<div class="admin-health-grid"></div>';
|
||||
const grid = el.querySelector('.admin-health-grid');
|
||||
// Stat cards
|
||||
const total = providers.length;
|
||||
const healthy = providers.filter(p => p.status === 'healthy').length;
|
||||
const degraded = providers.filter(p => p.status === 'degraded').length;
|
||||
const down = providers.filter(p => p.status === 'down' || p.status === 'unhealthy').length;
|
||||
const avgLat = providers.reduce((s, p) => s + (p.avg_latency_ms || 0), 0);
|
||||
const avgLatStr = total > 0 ? Math.round(avgLat / total) + 'ms' : '—';
|
||||
|
||||
for (const p of providers) {
|
||||
const statusClass = p.status === 'healthy' ? 'badge-success' : p.status === 'degraded' ? 'badge-warning' : p.status === 'down' ? 'badge-danger' : '';
|
||||
const errorPct = p.error_rate !== undefined ? (p.error_rate * 100).toFixed(1) + '%' : '—';
|
||||
const avgLatency = p.avg_latency_ms !== undefined ? p.avg_latency_ms + 'ms' : '—';
|
||||
const configName = p.provider_config_id?.slice(0, 8) || '—';
|
||||
|
||||
grid.innerHTML += `
|
||||
<div class="admin-health-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>${esc(p.provider_name || configName)}</strong>
|
||||
<span class="badge ${statusClass}">${esc(p.status || 'unknown')}</span>
|
||||
</div>
|
||||
<div class="admin-storage-grid" style="gap:4px">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Requests</span>
|
||||
<span class="admin-storage-value">${p.request_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Error Rate</span>
|
||||
<span class="admin-storage-value" style="color:${p.error_rate > 0.05 ? 'var(--error)' : 'inherit'}">${errorPct}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Avg Latency</span>
|
||||
<span class="admin-storage-value">${avgLatency}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Errors</span>
|
||||
<span class="admin-storage-value">${p.error_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Timeouts</span>
|
||||
<span class="admin-storage-value">${p.timeout_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Rate Limits</span>
|
||||
<span class="admin-storage-value" style="color:${(p.rate_limit_count || 0) > 0 ? 'var(--warning)' : 'inherit'}">${p.rate_limit_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Max Latency</span>
|
||||
<span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
${p.last_error ? `<div class="text-muted" style="font-size:11px;margin-top:6px;word-break:break-all">Last error: ${esc(p.last_error)}</div>` : ''}
|
||||
</div>`;
|
||||
if (cardsEl) {
|
||||
cardsEl.innerHTML = `
|
||||
<div class="stat-card"><div class="stat-card-label">Providers</div><div class="stat-card-value">${total}</div><div class="stat-card-sub">All configured</div></div>
|
||||
<div class="stat-card"><div class="stat-card-label">Healthy</div><div class="stat-card-value" style="color:var(--success)">${healthy}</div><div class="stat-card-sub">${total ? Math.round(healthy / total * 100) : 0}%</div></div>
|
||||
<div class="stat-card"><div class="stat-card-label">Degraded</div><div class="stat-card-value" style="color:var(--warning)">${degraded + down}</div><div class="stat-card-sub">${total ? Math.round((degraded + down) / total * 100) : 0}%</div></div>
|
||||
<div class="stat-card"><div class="stat-card-label">Avg Latency</div><div class="stat-card-value">${avgLatStr}</div><div class="stat-card-sub">Last hour</div></div>`;
|
||||
}
|
||||
|
||||
// Table
|
||||
const rows = providers.map(p => {
|
||||
const name = esc(p.provider_name || p.provider_config_id?.slice(0, 8) || '—');
|
||||
const statusCls = p.status === 'healthy' ? 'badge-healthy' : p.status === 'degraded' ? 'badge-degraded' : p.status === 'down' || p.status === 'unhealthy' ? 'badge-unhealthy' : 'badge-unknown';
|
||||
const latency = p.avg_latency_ms != null ? p.avg_latency_ms + 'ms' : '—';
|
||||
const errorRate = p.error_rate != null ? (100 - p.error_rate * 100).toFixed(1) + '%' : '—';
|
||||
const reqs = p.request_count ?? 0;
|
||||
const errCount = (p.error_count || 0) + (p.timeout_count || 0);
|
||||
const lastErr = p.last_error ? `<div class="text-muted" style="font-size:10px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.last_error)}">${esc(p.last_error)}</div>` : '—';
|
||||
|
||||
return `<tr>
|
||||
<td style="font-weight:500">${name}</td>
|
||||
<td><span class="${statusCls}">${esc(p.status || 'unknown')}</span></td>
|
||||
<td class="admin-user-time">${latency}</td>
|
||||
<td class="admin-user-time">${errorRate}</td>
|
||||
<td class="admin-user-time">${reqs}</td>
|
||||
<td class="admin-user-time">${errCount}</td>
|
||||
<td>${lastErr}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Provider</th><th>Status</th><th>Latency</th><th>Uptime</th><th>Requests</th><th>Errors</th><th>Last Error</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty-hint">Failed to load health data: ${esc(e.message)}</div>`;
|
||||
}
|
||||
@@ -971,26 +1106,23 @@ Object.assign(UI, {
|
||||
|
||||
const typeLabels = { provider_prefer: 'Provider Prefer', team_route: 'Team Route', cost_limit: 'Cost Limit', model_alias: 'Model Alias' };
|
||||
|
||||
listEl.innerHTML = policies.map(p => `
|
||||
<div class="list-card" data-id="${esc(p.id)}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<strong>${esc(p.name)}</strong>
|
||||
<span class="badge" style="margin-left:6px">${esc(typeLabels[p.policy_type] || p.policy_type)}</span>
|
||||
<span class="badge ${p.is_active ? 'badge-success' : ''}" style="margin-left:4px">${p.is_active ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted" style="font-size:11px;margin-right:8px">Priority: ${p.priority}</span>
|
||||
<span class="badge">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn-small" onclick="UI._showRoutingForm('${esc(p.id)}')">Edit</button>
|
||||
<button class="btn-small" onclick="UI._toggleRoutingPolicy('${esc(p.id)}', ${!p.is_active})">${p.is_active ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn-small btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}', '${esc(p.name)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
listEl.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Policy</th><th>Type</th><th>Scope</th><th>Priority</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>${policies.map(p => `<tr data-id="${esc(p.id)}">
|
||||
<td style="font-weight:500">${esc(p.name)}</td>
|
||||
<td><span class="badge-provider-type">${esc(typeLabels[p.policy_type] || p.policy_type)}</span></td>
|
||||
<td style="font-size:12px;color:var(--text-3)">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</td>
|
||||
<td class="admin-user-time">${p.priority}</td>
|
||||
<td>${p.is_active ? '<span class="badge-active">active</span>' : '<span class="badge-disabled">inactive</span>'}</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon" onclick="UI._showRoutingForm('${esc(p.id)}')" title="Edit">✎</button>
|
||||
<button class="btn-icon" onclick="UI._toggleRoutingPolicy('${esc(p.id)}',${!p.is_active})" title="${p.is_active ? 'Disable' : 'Enable'}">${p.is_active ? '⏸' : '▶'}</button>
|
||||
<button class="btn-icon btn-icon-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}','${esc(p.name)}')" title="Delete">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${policies.length} polic${policies.length !== 1 ? 'ies' : 'y'}</span></div>`;
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="empty-hint">Failed to load routing policies: ${esc(e.message)}</div>`;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ Object.assign(UI, {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
const msgFont = prefs.msgFont || 14;
|
||||
const theme = prefs.theme || 'dark';
|
||||
const theme = prefs.theme || 'system';
|
||||
const keymap = prefs.editorKeymap || 'standard';
|
||||
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
@@ -164,7 +164,7 @@ Object.assign(UI, {
|
||||
// Load saved prefs on startup
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
|
||||
UI.applyTheme(prefs.theme || 'dark');
|
||||
UI.applyTheme(prefs.theme || 'system');
|
||||
|
||||
// Live preview on slider change
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
@@ -380,21 +380,27 @@ Object.assign(UI, {
|
||||
try {
|
||||
const resp = await API.teamListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row" style="padding:6px 0">
|
||||
<div class="admin-user-info">
|
||||
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong>
|
||||
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select">
|
||||
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members</div>'; return; }
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>${members.map(m => `<tr>
|
||||
<td>
|
||||
<div style="font-weight:500;font-size:13px">${esc(m.display_name || m.email)}</div>
|
||||
${m.user_role === 'admin' ? '<span class="badge-admin" style="font-size:9px">sys-admin</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members</div>';
|
||||
</td>
|
||||
<td class="admin-actions-cell">
|
||||
<button class="btn-icon btn-icon-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -515,17 +521,45 @@ Object.assign(UI, {
|
||||
try {
|
||||
const resp = await API.teamListGroups(teamId);
|
||||
const groups = resp.data || [];
|
||||
el.innerHTML = groups.map(g => `
|
||||
<div class="admin-user-row" style="padding:6px 0">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(g.name)}</strong></div>
|
||||
<div class="text-muted" style="font-size:11px">${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No groups assigned to this team yet</div>';
|
||||
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups assigned to this team yet</div>'; return; }
|
||||
el.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Group</th><th>Members</th></tr></thead>
|
||||
<tbody>${groups.map(g => `<tr>
|
||||
<td><div style="font-weight:500">${esc(g.name)}</div><div class="admin-user-handle">${esc(g.description || 'No description')}</div></td>
|
||||
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManageSettings(teamId) {
|
||||
if (!teamId) return;
|
||||
try {
|
||||
const team = await API.teamGetTeam ? API.teamGetTeam(teamId) : await API.adminGetTeam(teamId);
|
||||
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
||||
const privEl = document.getElementById('teamSettingPrivatePolicy');
|
||||
const allowEl = document.getElementById('teamSettingAllowProviders');
|
||||
if (privEl) privEl.checked = !!settings.require_private_providers;
|
||||
if (allowEl) allowEl.checked = settings.allow_team_providers !== false;
|
||||
|
||||
// Wire save button (once)
|
||||
const saveBtn = document.getElementById('teamSettingSaveBtn');
|
||||
if (saveBtn && !saveBtn._wired) {
|
||||
saveBtn._wired = true;
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const tid = UI._managingTeamId;
|
||||
const priv = document.getElementById('teamSettingPrivatePolicy')?.checked || false;
|
||||
const allow = document.getElementById('teamSettingAllowProviders')?.checked !== false;
|
||||
try {
|
||||
await API.adminUpdateTeam(tid, { settings: JSON.stringify({ require_private_providers: priv, allow_team_providers: allow }) });
|
||||
UI.toast('Team settings saved', 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
} catch (e) { /* proceed with defaults */ }
|
||||
},
|
||||
|
||||
async loadMyUsage() {
|
||||
const el = document.getElementById('myUsageTotals')?.parentElement;
|
||||
if (!el) return;
|
||||
|
||||
Reference in New Issue
Block a user