This commit is contained in:
Timothy Jaeryang Baek
2026-07-15 05:49:48 -04:00
parent f8ea15b84a
commit 727041da78
8 changed files with 302 additions and 282 deletions
+43 -5
View File
@@ -1,7 +1,9 @@
<script>
import { getContext, tick, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getContext, onMount } from 'svelte';
import { page } from '$app/stores';
import { adminFeedbackCount, adminLeaderboardCount, models as _models } from '$lib/stores';
import { getFeedbackItems, getLeaderboard } from '$lib/apis/evaluations';
import { formatNumber } from '$lib/utils';
import Leaderboard from './Evaluations/Leaderboard.svelte';
import Feedbacks from './Evaluations/Feedbacks.svelte';
@@ -28,8 +30,34 @@
};
let loaded = false;
$: formattedLeaderboardCount =
$adminLeaderboardCount === null ? null : formatNumber($adminLeaderboardCount);
$: formattedFeedbackCount =
$adminFeedbackCount === null ? null : formatNumber($adminFeedbackCount);
const getLeaderboardCount = (res) => {
const entries = res?.entries ?? [];
const modelMap = new Map(($_models ?? []).map((model) => [model.id, model]));
const activeModelCount = ($_models ?? []).filter(
(model) => model?.owned_by !== 'arena' && !model?.info?.meta?.hidden
).length;
const evaluatedModelCount = entries.filter((entry) => !modelMap.has(entry.model_id)).length;
return activeModelCount + evaluatedModelCount;
};
const loadCounts = async () => {
const [leaderboardRes, feedbackRes] = await Promise.all([
getLeaderboard(localStorage.token).catch(() => null),
getFeedbackItems(localStorage.token, 'updated_at', 'desc', 1).catch(() => null)
]);
adminLeaderboardCount.set(getLeaderboardCount(leaderboardRes));
adminFeedbackCount.set(feedbackRes?.total ?? null);
};
onMount(async () => {
await loadCounts();
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
@@ -52,30 +80,40 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class="tabs mx-2 sm:mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
class="tabs mx-2 px-2 sm:mx-2.5 lg:mx-0 lg:px-2.5 flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-0 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
>
<a
id="leaderboard"
href="/admin/evaluations/leaderboard"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'leaderboard'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
>
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
{#if formattedLeaderboardCount !== null}
<div class="self-center text-sm opacity-60">
{formattedLeaderboardCount}
</div>
{/if}
</a>
<a
id="feedback"
href="/admin/evaluations/feedback"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'feedback'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
>
<div class=" self-center">{$i18n.t('Feedback')}</div>
{#if formattedFeedbackCount !== null}
<div class="self-center text-sm opacity-60">
{formattedFeedbackCount}
</div>
{/if}
</a>
</div>
@@ -31,7 +31,7 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { config } from '$lib/stores';
import { adminFeedbackCount, config } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import Select from '$lib/components/common/Select.svelte';
import Check from '$lib/components/icons/Check.svelte';
@@ -90,6 +90,7 @@
if (res) {
items = res.items;
total = res.total;
adminFeedbackCount.set(total);
}
} catch (err) {
console.error(err);
@@ -216,27 +217,61 @@
<Spinner className="size-5" />
</div>
{:else}
<div class="flex flex-col gap-1 mt-0.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-normal px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Feedback History')}
<div class="space-y-1">
{#if modelIds.length > 0 || total > 0}
<div class="flex h-8 flex-1 items-center w-full gap-2">
<div
class="flex min-w-0 flex-1 bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
{#if modelIds.length > 0}
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={selectedModelId}
items={[
{ value: '', label: $i18n.t('All') },
...modelIds.map((mid) => ({ value: mid, label: mid }))
]}
placeholder={$i18n.t('All')}
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
onChange={() => {
page = 1;
getFeedbacks();
}}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
</div>
{/if}
</div>
<div class="text-lg font-normal text-gray-500 dark:text-gray-500">
{total}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
{#if total > 0}
<Dropdown align="end">
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
class="flex h-8 shrink-0 items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 dark:text-gray-200 transition text-xs"
>
<div class="self-center font-normal line-clamp-1">
{$i18n.t('Export')}
</div>
{$i18n.t('Export')}
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
@@ -262,59 +297,9 @@
</Dropdown>
{/if}
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
{#if modelIds.length > 0}
<div
class="px-2.5 flex w-full bg-transparent overflow-x-auto scrollbar-none mb-1"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={selectedModelId}
items={[
{ value: '', label: $i18n.t('All') },
...modelIds.map((mid) => ({ value: mid, label: mid }))
]}
placeholder={$i18n.t('All')}
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
onChange={() => {
page = 1;
getFeedbacks();
}}
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
</div>
</div>
{/if}
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full px-2">
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (items ?? []).length === 0}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
@@ -1,11 +1,12 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getContext } from 'svelte';
import { adminLeaderboardCount, models } from '$lib/stores';
import { getLeaderboard } from '$lib/apis/evaluations';
import ModelModal from './LeaderboardModal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.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 ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
@@ -83,6 +84,7 @@
if (b.rating === '-') return -1;
return b.rating - a.rating;
});
adminLeaderboardCount.set(rankedModels.length);
} catch (err) {
console.error('Leaderboard load failed:', err);
}
@@ -120,118 +122,133 @@
<ModelModal bind:show={showModal} model={selectedModel} onClose={closeModal} />
<div
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 items-center text-xl font-normal px-0.5 gap-2 shrink-0">
{$i18n.t('Leaderboard')}
<span class="text-lg text-gray-500">{rankedModels.length}</span>
</div>
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
<div class="flex flex-1">
<Search className="size-3 ml-1 mr-3 self-center" />
<input
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
/>
</div>
</Tooltip>
</div>
<div class="space-y-1">
<div class="pt-0.5 pb-1 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="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
>
{#if loading}
<div
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
>
<Spinner className="size-5" />
</div>
{/if}
{#if !rankedModels.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No models found')}</div>
{:else if rankedModels.length}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
? 'opacity-20'
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col}
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none {col.class}"
on:click={() => toggleSort(col.key)}
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
}}
>
<div
class="flex gap-1.5 items-center {col.class.includes('right') ? 'justify-end' : ''}"
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
>
{#if loading}
<div
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
>
<Spinner className="size-5" />
</div>
{/if}
{#if !rankedModels.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No models found')}</div>
{:else if rankedModels.length}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
? 'opacity-20'
: ''}"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col}
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none {col.class}"
on:click={() => toggleSort(col.key)}
>
{$i18n.t(col.label)}
{#if orderBy === col.key}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedModels as model, idx (model.id)}
<tr
class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openModal(model)}
>
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{model.rating !== '-' ? idx + 1 : '-'}
</td>
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-normal text-gray-800 dark:text-gray-200 line-clamp-1"
>{model.name}</span
>
</Tooltip>
</div>
</td>
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{model.rating}
</td>
<td class="px-3 py-1.5 text-right font-normal text-green-500 w-10">
{#if model.stats.won === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.won) / model.stats.count) * 100).toFixed(1)}%</span
<div
class="flex gap-1.5 items-center {col.class.includes('right')
? 'justify-end'
: ''}"
>
<span class="group-hover:hidden">{model.stats.won}</span>
{/if}
</td>
<td class="px-3 py-1.5 text-right font-normal text-red-500 w-10">
{#if model.stats.lost === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.lost) / model.stats.count) * 100).toFixed(1)}%</span
>
<span class="group-hover:hidden">{model.stats.lost}</span>
{/if}
</td>
{$i18n.t(col.label)}
{#if orderBy === col.key}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</thead>
<tbody>
{#each sortedModels as model, idx (model.id)}
<tr
class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openModal(model)}
>
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
{model.rating !== '-' ? idx + 1 : '-'}
</td>
<td class="px-3 py-1.5">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-normal text-gray-800 dark:text-gray-200 line-clamp-1"
>{model.name}</span
>
</Tooltip>
</div>
</td>
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
{model.rating}
</td>
<td class="px-3 py-1.5 text-right font-normal text-green-500 w-10">
{#if model.stats.won === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.won) / model.stats.count) * 100).toFixed(1)}%</span
>
<span class="group-hover:hidden">{model.stats.won}</span>
{/if}
</td>
<td class="px-3 py-1.5 text-right font-normal text-red-500 w-10">
{#if model.stats.lost === '-'}-{:else}
<span class="hidden group-hover:inline"
>{((Number(model.stats.lost) / model.stats.count) * 100).toFixed(1)}%</span
>
<span class="group-hover:hidden">{model.stats.lost}</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
+42 -5
View File
@@ -2,8 +2,11 @@
import { getContext, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { adminGroupCount, adminUserCount, config, user } from '$lib/stores';
import { page } from '$app/stores';
import { getGroups } from '$lib/apis/groups';
import { getUsers } from '$lib/apis/users';
import { formatNumber } from '$lib/utils';
import UserList from './Users/UserList.svelte';
import Groups from './Users/Groups.svelte';
@@ -30,12 +33,32 @@
};
let loaded = false;
$: usersSeatLimit = $config?.license_metadata?.seats ?? null;
$: usersCountExceeded = usersSeatLimit !== null && ($adminUserCount ?? 0) > usersSeatLimit;
$: formattedUserCount =
$adminUserCount === null
? null
: usersSeatLimit !== null
? `${formatNumber($adminUserCount)} of ${formatNumber(usersSeatLimit)}`
: formatNumber($adminUserCount);
$: formattedGroupCount = $adminGroupCount === null ? null : formatNumber($adminGroupCount);
const loadCounts = async () => {
const [usersRes, groupsRes] = await Promise.all([
getUsers(localStorage.token, undefined, 'created_at', 'asc', 1).catch(() => null),
getGroups(localStorage.token).catch(() => null)
]);
adminUserCount.set(usersRes?.total ?? null);
adminGroupCount.set(Array.isArray(groupsRes) ? groupsRes.length : null);
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
}
await loadCounts();
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
@@ -55,33 +78,47 @@
</script>
{#if loaded}
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div class="flex flex-col lg:flex-row w-full h-full pb-2">
<div
id="users-tabs-container"
class="tabs mx-2 sm:mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
class="tabs mx-2 px-2 sm:mx-2.5 lg:mx-0 lg:px-2.5 flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-0 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
>
<a
id="overview"
href="/admin/users/overview"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'overview'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
>
<div class=" self-center">{$i18n.t('Overview')}</div>
{#if formattedUserCount !== null}
<div
class="self-center text-sm {usersCountExceeded
? `text-red-500 ${selectedTab === 'overview' ? '' : 'opacity-50'}`
: 'opacity-60'}"
>
{formattedUserCount}
</div>
{/if}
</a>
<a
id="groups"
href="/admin/users/groups"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'groups'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
>
<div class=" self-center">{$i18n.t('Groups')}</div>
{#if formattedGroupCount !== null}
<div class="self-center text-sm opacity-60">
{formattedGroupCount}
</div>
{/if}
</a>
</div>
+27 -47
View File
@@ -3,7 +3,7 @@
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { adminGroupCount, user } from '$lib/stores';
import Plus from '$lib/components/icons/Plus.svelte';
import Search from '$lib/components/icons/Search.svelte';
@@ -55,6 +55,7 @@
const setGroups = async () => {
groups = await getGroups(localStorage.token);
adminGroupCount.set(groups.length);
};
const addGroupHandler = async (group) => {
@@ -65,7 +66,7 @@
if (res) {
toast.success($i18n.t('Group created successfully'));
groups = await getGroups(localStorage.token);
await setGroups();
}
};
@@ -106,50 +107,9 @@
onSubmit={addGroupHandler}
/>
<div class="flex flex-col gap-1 mt-0.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-normal px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Groups')}
</div>
<div class="text-lg font-normal text-gray-500 dark:text-gray-500">
{filteredGroups.length}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
aria-haspopup="dialog"
on:click={() => {
showDefaultPermissionsModal = true;
}}
>
<div class="self-center font-normal line-clamp-1">
{$i18n.t('Default permissions')}
</div>
</button>
<button
class="px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-normal text-sm flex items-center"
on:click={() => {
showAddGroupModal = !showAddGroupModal;
}}
>
<Plus className="size-3" strokeWidth="2.5" />
<div class="hidden md:block md:ml-1 text-xs">{$i18n.t('New Group')}</div>
</button>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class="px-2.5 flex h-8 flex-1 items-center w-full gap-2">
<div class="flex min-w-0 flex-1">
<div class="space-y-1">
<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="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
@@ -198,10 +158,30 @@
</div>
</svelte:fragment>
</Select>
<button
class="flex h-8 shrink-0 items-center px-2 py-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 dark:text-gray-200 transition text-xs"
aria-haspopup="dialog"
on:click={() => {
showDefaultPermissionsModal = true;
}}
>
{$i18n.t('Default permissions')}
</button>
<button
class="h-8 shrink-0 p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition font-normal text-sm flex items-center"
aria-label={$i18n.t('New Group')}
on:click={() => {
showAddGroupModal = !showAddGroupModal;
}}
>
<Plus className="size-3.5" strokeWidth="2.5" />
</button>
</div>
{#if filteredGroups.length !== 0}
<div class="mt-1 grid grid-cols-1 px-2">
<div class="mt-1 grid grid-cols-1">
{#each filteredGroups as group}
<GroupItem {group} {setGroups} {defaultPermissions} />
{/each}
+6 -5
View File
@@ -121,7 +121,7 @@
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 ${
`hidden md:block shrink-0 text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 ${
first ? 'mt-0.5' : 'mt-2'
} mb-0.5`;
@@ -789,7 +789,7 @@
const tabButtonClass = (active: boolean) =>
`flex items-center gap-1.5 h-7 px-2 md:w-full shrink-0 rounded-lg text-xs text-left transition-colors duration-75 ${
active
? 'font-medium text-gray-900 dark:text-white bg-gray-100 dark:bg-white/6'
? 'font-medium text-gray-900 dark:text-white bg-gray-50 dark:bg-white/[0.04]'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
}`;
@@ -1019,9 +1019,10 @@
{/if}
{#if $user?.role === 'admin' && filteredAdminSettings.length > 0}
<span
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"
>
<div
class="hidden md:block shrink-0 self-stretch h-px mx-1 my-2 bg-gray-100/40 dark:bg-white/[0.025]"
></div>
<span class="hidden md:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mb-0.5">
{$i18n.t('Admin')}
</span>
+3
View File
@@ -90,6 +90,9 @@ export const workspaceCounts: Writable<Record<WorkspaceSection, number | null>>
});
export const workspaceActions: Writable<WorkspaceAction[]> = writable([]);
export const adminUserCount: Writable<number | null> = writable(null);
export const adminGroupCount: Writable<number | null> = writable(null);
export const adminLeaderboardCount: Writable<number | null> = writable(null);
export const adminFeedbackCount: Writable<number | null> = writable(null);
export const toolServers = writable([]);
export const terminalServers = writable([]);
+3 -44
View File
@@ -2,40 +2,15 @@
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import {
WEBUI_NAME,
adminUserCount,
config,
mobile,
showSettings,
showSidebar,
user
} from '$lib/stores';
import { WEBUI_NAME, config, mobile, showSettings, showSidebar, user } from '$lib/stores';
import { page } from '$app/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Sidebar from '$lib/components/icons/Sidebar.svelte';
import { formatNumber } from '$lib/utils';
import { getUsers } from '$lib/apis/users';
const i18n = getContext('i18n');
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 () => {
if ($user?.role !== 'admin') {
@@ -92,26 +67,10 @@
>
<a
draggable="false"
class="min-w-fit p-1.5 flex items-center gap-1.5 {$page.url.pathname.includes(
'/admin/users'
)
class="min-w-fit p-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"
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
href="/admin">{$i18n.t('Users')}</a
>
<a