mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
refac
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user