This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-15 05:38:44 -04:00
1 parent 3ce3c52936
commit f8ea15b84a
7 files changed
+215 -165

No files matched your search

@@ -96,10 +96,10 @@
const modelAccessClass = (model) => { const modelAccessClass = (model) => {
if (isPublicModel(model)) { if (isPublicModel(model)) {
return 'text-green-700/50 dark:text-green-300/50'; return 'text-[#4f7a5a] dark:text-[#8db395]';
} }
if (isSharedModel(model)) { if (isSharedModel(model)) {
return 'text-blue-700/50 dark:text-blue-300/50'; return 'text-[#4f6f93] dark:text-[#8ba6c6]';
} }
return 'text-gray-500 dark:text-gray-400'; return 'text-gray-500 dark:text-gray-400';
}; };
@@ -47,10 +47,10 @@
const roleClass = (role) => { const roleClass = (role) => {
if (role === 'admin') { if (role === 'admin') {
return 'text-blue-700/50 dark:text-blue-300/50'; return 'text-[#4f6f93] dark:text-[#8ba6c6]';
} }
if (role === 'user') { if (role === 'user') {
return 'text-green-700/50 dark:text-green-300/50'; return 'text-[#4f7a5a] dark:text-[#8db395]';
} }
return 'text-gray-500 dark:text-gray-400'; return 'text-gray-500 dark:text-gray-400';
}; };
@@ -138,7 +138,7 @@
<tr class=" border-b-[1.5px] border-gray-50/50 dark:border-gray-800/10"> <tr class=" border-b-[1.5px] border-gray-50/50 dark:border-gray-800/10">
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer text-left w-8" class="px-2.5 py-1.5 cursor-pointer text-left w-8"
on:click={() => setSortKey(`group_id:${groupId}`)} on:click={() => setSortKey(`group_id:${groupId}`)}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -162,7 +162,7 @@
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('name')} on:click={() => setSortKey('name')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -185,7 +185,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('role')} on:click={() => setSortKey('role')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -209,7 +209,7 @@
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')} on:click={() => setSortKey('last_active_at')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
+98 -139
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
import { config, user } from '$lib/stores'; import { adminUserCount, config, user } from '$lib/stores';
import { getContext, onDestroy } from 'svelte'; import { getContext, onDestroy } from 'svelte';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -15,6 +15,9 @@
import Pagination from '$lib/components/common/Pagination.svelte'; import Pagination from '$lib/components/common/Pagination.svelte';
import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte'; import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte';
import EditPencil from '$lib/components/icons/EditPencil.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
import Trash from '$lib/components/icons/Trash.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte'; import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte';
@@ -24,10 +27,11 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import Search from '$lib/components/icons/Search.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Banner from '$lib/components/common/Banner.svelte'; import Banner from '$lib/components/common/Banner.svelte';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import UserPreviewModal from '$lib/components/admin/UserPreviewModal.svelte'; import UserPreviewModal from '$lib/components/admin/UserPreviewModal.svelte';
@@ -80,10 +84,10 @@
const roleClass = (role) => { const roleClass = (role) => {
if (role === 'admin') { if (role === 'admin') {
return 'text-blue-700/50 dark:text-blue-300/50'; return 'text-[#4f6f93] dark:text-[#8ba6c6]';
} }
if (role === 'user') { if (role === 'user') {
return 'text-green-700/50 dark:text-green-300/50'; return 'text-[#4f7a5a] dark:text-[#8db395]';
} }
return 'text-gray-500 dark:text-gray-400'; return 'text-gray-500 dark:text-gray-400';
}; };
@@ -100,6 +104,7 @@
if (res) { if (res) {
users = res.users; users = res.users;
total = res.total; total = res.total;
adminUserCount.set(total);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -172,72 +177,46 @@
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
{:else} {:else}
<div <div class="pt-0.5 pb-1 sticky top-0 z-10 bg-white dark:bg-gray-900">
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900" <div class="flex h-8 flex-1 items-center w-full gap-2">
> <div class="flex min-w-0 flex-1 items-center">
<div class="flex items-center md:self-center text-xl font-normal px-0.5 gap-2 shrink-0"> <div class="self-center ml-1 mr-3">
<div class="flex-shrink-0"> <Search className="size-3.5" />
{$i18n.t('Users')}
</div>
<div>
{#if ($config?.license_metadata?.seats ?? null) !== null}
{#if total > $config?.license_metadata?.seats}
<span class="text-lg font-normal text-red-500"
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
>
{:else}
<span class="text-lg font-normal text-gray-500 dark:text-gray-300"
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
>
{/if}
{:else}
<span class="text-lg font-normal text-gray-500 dark:text-gray-300">{total}</span>
{/if}
</div>
</div>
<div class="flex gap-1">
<div class="flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
</div> </div>
<input
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
<div> {#if query}
<Tooltip content={$i18n.t('Add User')}> <div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button <button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-normal text-sm flex items-center space-x-1" class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => { on:click={() => {
showAddUserModal = !showAddUserModal; query = '';
handleSearchInput();
}} }}
> >
<Plus className="size-3.5" /> <XMark className="size-3" strokeWidth="2" />
</button> </button>
</Tooltip> </div>
</div> {/if}
</div> </div>
<Tooltip content={$i18n.t('Add User')}>
<button
class="p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-normal text-sm flex items-center"
on:click={() => {
showAddUserModal = !showAddUserModal;
}}
>
<Plus className="size-3.5" />
</button>
</Tooltip>
</div> </div>
</div> </div>
@@ -247,7 +226,7 @@
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30"> <tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('name')} on:click={() => setSortKey('name')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -270,7 +249,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('role')} on:click={() => setSortKey('role')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -293,7 +272,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('email')} on:click={() => setSortKey('email')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -317,7 +296,7 @@
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')} on:click={() => setSortKey('last_active_at')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -341,7 +320,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-1.5 cursor-pointer select-none"
on:click={() => setSortKey('created_at')} on:click={() => setSortKey('created_at')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -362,7 +341,7 @@
</div> </div>
</th> </th>
<th scope="col" class="px-2.5 py-2 text-right"></th> <th scope="col" class="px-2.5 py-1.5 text-right"></th>
</tr> </tr>
</thead> </thead>
<tbody class=""> <tbody class="">
@@ -372,7 +351,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ProfilePreview {user} side="right" align="center" sideOffset={6}> <ProfilePreview {user} side="right" align="center" sideOffset={6}>
<img <img
class="rounded-full w-6 min-w-6 h-6 object-cover mr-0.5 flex-shrink-0" class="rounded-full w-6 min-w-6 h-6 object-cover flex-shrink-0"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`} src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user" alt="user"
on:error={(e) => { on:error={(e) => {
@@ -422,14 +401,14 @@
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'} {#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
<Tooltip content={$i18n.t('Chats')}> <Tooltip content={$i18n.t('Chats')}>
<button <button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
aria-label={$i18n.t('Chats')} aria-label={$i18n.t('Chats')}
on:click={async () => { on:click={async () => {
showUserChatsModal = !showUserChatsModal; showUserChatsModal = !showUserChatsModal;
selectedUser = user; selectedUser = user;
}} }}
> >
<ChatBubbles /> <ChatBubbles className="size-3.5" />
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
@@ -437,86 +416,42 @@
{#if user.role !== 'admin'} {#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Preview Access')}> <Tooltip content={$i18n.t('Preview Access')}>
<button <button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
aria-label={$i18n.t('Preview Access')} aria-label={$i18n.t('Preview Access')}
on:click={() => { on:click={() => {
selectedUser = user; selectedUser = user;
showUserPreviewModal = true; showUserPreviewModal = true;
}} }}
> >
<svg <Eye className="size-3.5" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
<Tooltip content={$i18n.t('Edit User')}> <Tooltip content={$i18n.t('Edit User')}>
<button <button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
aria-label={$i18n.t('Edit User')} aria-label={$i18n.t('Edit User')}
on:click={async () => { on:click={async () => {
showEditUserModal = !showEditUserModal; showEditUserModal = !showEditUserModal;
selectedUser = user; selectedUser = user;
}} }}
> >
<svg <EditPencil className="size-3.5" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button> </button>
</Tooltip> </Tooltip>
{#if user.role !== 'admin'} {#if user.role !== 'admin'}
<Tooltip content={$i18n.t('Delete User')}> <Tooltip content={$i18n.t('Delete User')}>
<button <button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" class="self-center w-fit p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
aria-label={$i18n.t('Delete User')} aria-label={$i18n.t('Delete User')}
on:click={async () => { on:click={async () => {
showDeleteConfirmDialog = true; showDeleteConfirmDialog = true;
selectedUser = user; selectedUser = user;
}} }}
> >
<svg <Trash className="size-3.5" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
@@ -539,24 +474,48 @@
{#if !$config?.license_metadata} {#if !$config?.license_metadata}
{#if total > 50} {#if total > 50}
<div class="text-sm"> <div class="mt-3 mb-3 pb-1 text-gray-700 dark:text-gray-300">
<Markdown <div class="max-w-3xl text-xs leading-5">
content={` <div class="text-gray-900 dark:text-gray-100">
> [!NOTE] {$i18n.t('Running Open WebUI for a team?')}
> # **Hey there! 👋** </div>
> <div class="mt-2 space-y-2">
> It looks like you have over 50 users, that usually falls under organizational usage. <p>
> {$i18n.t(
> Open WebUI is completely free to use as-is, with no restrictions or hidden limits, and we'd love to keep it that way. 🌱 'You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.'
> )}
> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more, all at a fraction of what it would cost to build and maintain internally. </p>
> <p class="text-gray-500 dark:text-gray-400">
> Your support helps us stay independent and continue building great tools for everyone. 💛 {$i18n.t(
> 'By supporting the project through sponsorship or an enterprise license, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.'
> - 👉 **[Click here to learn more about enterprise licensing](https://docs.openwebui.com/enterprise)** )}
> - 👉 *[Click here to sponsor the project on GitHub](https://github.com/sponsors/open-webui)* </p>
`} <p class="text-gray-500 dark:text-gray-400">
/> {$i18n.t(
'Enterprise licenses also include dedicated support, customization options, and more, at a fraction of the cost of building and maintaining this stack internally.'
)}
</p>
</div>
<div class="mt-2 flex items-center gap-3">
<a
class="text-xs text-gray-700 underline transition hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
href="https://docs.openwebui.com/enterprise"
target="_blank"
rel="noreferrer"
>
{$i18n.t('Enterprise licensing')}
</a>
<a
class="text-xs text-gray-500 underline transition hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100"
href="https://github.com/sponsors/open-webui"
target="_blank"
rel="noreferrer"
>
{$i18n.t('Sponsor on GitHub')}
</a>
</div>
</div>
</div> </div>
{/if} {/if}
{/if} {/if}
+59 -10
View File
@@ -85,6 +85,45 @@
const isAdminTab = (tabId: string) => tabId.startsWith('admin:'); const isAdminTab = (tabId: string) => tabId.startsWith('admin:');
const adminTabSegment = (tabId: string) => tabId.replace('admin:', ''); const adminTabSegment = (tabId: string) => tabId.replace('admin:', '');
const adminTabPanelId = (tabId: string) => `tab-${tabId.replace(':', '-')}`; const adminTabPanelId = (tabId: string) => `tab-${tabId.replace(':', '-')}`;
const personalSettingGroups: Record<string, string> = {
general: 'Basics',
interface: 'Basics',
shortcuts: 'Basics',
connections: 'Services',
tools: 'Services',
personalization: 'Preferences',
audio: 'Preferences',
data_controls: 'Data',
archived_chats: 'Data',
account: 'Profile',
about: 'Profile'
};
const adminSettingGroups: Record<string, string> = {
'admin:general': 'System',
'admin:authentication': 'System',
'admin:connections': 'AI',
'admin:models': 'AI',
'admin:subagents': 'AI',
'admin:evaluations': 'Quality',
'admin:analytics': 'Quality',
'admin:integrations': 'Tools',
'admin:documents': 'Tools',
'admin:web': 'Tools',
'admin:code-execution': 'Tools',
'admin:pipelines': 'Tools',
'admin:interface': 'Experience',
'admin:audio': 'Experience',
'admin:images': 'Experience',
'admin:db': 'Data'
};
const settingGroupTitle = (tabId: string) =>
(isAdminTab(tabId) ? adminSettingGroups[tabId] : personalSettingGroups[tabId]) ?? 'General';
const shouldShowSettingGroup = (tabIds: string[], index: number) =>
index === 0 || settingGroupTitle(tabIds[index]) !== settingGroupTitle(tabIds[index - 1]);
const settingGroupHeadingClass = (first: boolean) =>
`hidden md:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 ${
first ? 'mt-0.5' : 'mt-2'
} mb-0.5`;
const allSettings: SettingsTab[] = [ const allSettings: SettingsTab[] = [
{ {
@@ -628,6 +667,11 @@
title: 'Code Execution', title: 'Code Execution',
keywords: ['code execution', 'python', 'sandbox', 'compiler', 'jupyter', 'interpreter'] keywords: ['code execution', 'python', 'sandbox', 'compiler', 'jupyter', 'interpreter']
}, },
{
id: 'admin:pipelines',
title: 'Pipelines',
keywords: ['pipelines', 'workflows', 'filters', 'valves', 'middleware']
},
{ {
id: 'admin:interface', id: 'admin:interface',
title: 'Interface', title: 'Interface',
@@ -643,19 +687,12 @@
title: 'Images', title: 'Images',
keywords: ['images', 'generation', 'dalle', 'stable diffusion', 'comfyui', 'automatic1111'] keywords: ['images', 'generation', 'dalle', 'stable diffusion', 'comfyui', 'automatic1111']
}, },
{
id: 'admin:pipelines',
title: 'Pipelines',
keywords: ['pipelines', 'workflows', 'filters', 'valves', 'middleware']
},
{ {
id: 'admin:db', id: 'admin:db',
title: 'Database', title: 'Database',
keywords: ['database', 'export', 'import', 'backup', 'chats', 'users'] keywords: ['database', 'export', 'import', 'backup', 'chats', 'users']
} }
]; ];
const allKnownSettings = [...allSettings, ...adminSettings];
let availableSettings: SettingsTab[] = []; let availableSettings: SettingsTab[] = [];
let filteredSettings: string[] = []; let filteredSettings: string[] = [];
let filteredPersonalSettings: string[] = []; let filteredPersonalSettings: string[] = [];
@@ -823,7 +860,13 @@
</span> </span>
{#if filteredPersonalSettings.length > 0} {#if filteredPersonalSettings.length > 0}
{#each filteredPersonalSettings as tabId (tabId)} {#each filteredPersonalSettings as tabId, index (tabId)}
{#if shouldShowSettingGroup(filteredPersonalSettings, index)}
<span class={settingGroupHeadingClass(index === 0)}>
{$i18n.t(settingGroupTitle(tabId))}
</span>
{/if}
{#if tabId === 'general'} {#if tabId === 'general'}
<button <button
role="tab" role="tab"
@@ -977,12 +1020,18 @@
{#if $user?.role === 'admin' && filteredAdminSettings.length > 0} {#if $user?.role === 'admin' && filteredAdminSettings.length > 0}
<span <span
class="hidden md:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mt-2 mb-0.5" class="hidden md:block border-t border-gray-300/80 dark:border-white/15 text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 pt-2 mt-2 mb-0.5"
> >
{$i18n.t('Admin')} {$i18n.t('Admin')}
</span> </span>
{#each filteredAdminSettings as tabId (tabId)} {#each filteredAdminSettings as tabId, index (tabId)}
{#if shouldShowSettingGroup(filteredAdminSettings, index)}
<span class={settingGroupHeadingClass(index === 0)}>
{$i18n.t(settingGroupTitle(tabId))}
</span>
{/if}
{@const tab = adminSettings.find((setting) => setting.id === tabId)} {@const tab = adminSettings.find((setting) => setting.id === tabId)}
{#if tab} {#if tab}
<button <button
+1
View File
@@ -89,6 +89,7 @@ export const workspaceCounts: Writable<Record<WorkspaceSection, number | null>>
tools: null tools: null
}); });
export const workspaceActions: Writable<WorkspaceAction[]> = writable([]); export const workspaceActions: Writable<WorkspaceAction[]> = writable([]);
export const adminUserCount: Writable<number | null> = writable(null);
export const toolServers = writable([]); export const toolServers = writable([]);
export const terminalServers = writable([]); export const terminalServers = writable([]);
+44 -3
View File
@@ -2,15 +2,40 @@
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { WEBUI_NAME, config, mobile, showSettings, showSidebar, user } from '$lib/stores'; import {
WEBUI_NAME,
adminUserCount,
config,
mobile,
showSettings,
showSidebar,
user
} from '$lib/stores';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Sidebar from '$lib/components/icons/Sidebar.svelte'; import Sidebar from '$lib/components/icons/Sidebar.svelte';
import { formatNumber } from '$lib/utils';
import { getUsers } from '$lib/apis/users';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
let loaded = false; let loaded = false;
$: usersCountVisible = $page.url.pathname.includes('/admin');
$: usersTabSelected = $page.url.pathname.includes('/admin/users');
$: usersSeatLimit = $config?.license_metadata?.seats ?? null;
$: usersCountExceeded =
usersCountVisible && usersSeatLimit !== null && ($adminUserCount ?? 0) > usersSeatLimit;
$: if (loaded && usersCountVisible) {
loadUserCount();
}
const loadUserCount = async () => {
const res = await getUsers(localStorage.token, undefined, 'created_at', 'asc', 1).catch(
() => null
);
adminUserCount.set(res?.total ?? null);
};
onMount(async () => { onMount(async () => {
if ($user?.role !== 'admin') { if ($user?.role !== 'admin') {
@@ -67,10 +92,26 @@
> >
<a <a
draggable="false" draggable="false"
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/users') class="min-w-fit p-1.5 flex items-center gap-1.5 {$page.url.pathname.includes(
'/admin/users'
)
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
href="/admin">{$i18n.t('Users')}</a href="/admin"
><span>{$i18n.t('Users')}</span>
{#if usersCountVisible}
<span
class="text-sm {usersCountExceeded
? `text-red-500 ${usersTabSelected ? '' : 'opacity-50'}`
: 'opacity-60'}"
>
{#if usersSeatLimit !== null}
{formatNumber($adminUserCount ?? 0)} of {formatNumber(usersSeatLimit)}
{:else}
{formatNumber($adminUserCount ?? 0)}
{/if}
</span>
{/if}</a
> >
<a <a
+5 -5
View File
@@ -156,7 +156,7 @@
href="/workspace/models" href="/workspace/models"
> >
<span>{$i18n.t('Models')}</span> <span>{$i18n.t('Models')}</span>
<span class="text-sm text-gray-500 dark:text-gray-500"> <span class="text-sm opacity-60">
{formatCount($workspaceCounts.models)} {formatCount($workspaceCounts.models)}
</span> </span>
</a> </a>
@@ -173,7 +173,7 @@
href="/workspace/knowledge" href="/workspace/knowledge"
> >
<span>{$i18n.t('Knowledge')}</span> <span>{$i18n.t('Knowledge')}</span>
<span class="text-sm text-gray-500 dark:text-gray-500"> <span class="text-sm opacity-60">
{formatCount($workspaceCounts.knowledge)} {formatCount($workspaceCounts.knowledge)}
</span> </span>
</a> </a>
@@ -190,7 +190,7 @@
href="/workspace/prompts" href="/workspace/prompts"
> >
<span>{$i18n.t('Prompts')}</span> <span>{$i18n.t('Prompts')}</span>
<span class="text-sm text-gray-500 dark:text-gray-500"> <span class="text-sm opacity-60">
{formatCount($workspaceCounts.prompts)} {formatCount($workspaceCounts.prompts)}
</span> </span>
</a> </a>
@@ -207,7 +207,7 @@
href="/workspace/skills" href="/workspace/skills"
> >
<span>{$i18n.t('Skills')}</span> <span>{$i18n.t('Skills')}</span>
<span class="text-sm text-gray-500 dark:text-gray-500"> <span class="text-sm opacity-60">
{formatCount($workspaceCounts.skills)} {formatCount($workspaceCounts.skills)}
</span> </span>
</a> </a>
@@ -224,7 +224,7 @@
href="/workspace/tools" href="/workspace/tools"
> >
<span>{$i18n.t('Tools')}</span> <span>{$i18n.t('Tools')}</span>
<span class="text-sm text-gray-500 dark:text-gray-500"> <span class="text-sm opacity-60">
{formatCount($workspaceCounts.tools)} {formatCount($workspaceCounts.tools)}
</span> </span>
</a> </a>