| <script lang="ts"> |
| import { onMount, getContext } from 'svelte'; |
| import { models } from '$lib/stores'; |
| import { getModelAnalytics } from '$lib/apis/analytics'; |
| import Spinner from '$lib/components/common/Spinner.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'; |
| |
| const i18n = getContext('i18n'); |
| |
| let modelStats: Array<{ model_id: string; count: number; name?: string }> = []; |
| let loading = true; |
| let orderBy = 'count'; |
| let direction: 'asc' | 'desc' = 'desc'; |
| |
| const toggleSort = (key: string) => { |
| if (orderBy === key) { |
| direction = direction === 'asc' ? 'desc' : 'asc'; |
| } else { |
| orderBy = key; |
| direction = key === 'name' ? 'asc' : 'desc'; |
| } |
| }; |
| |
| const loadAnalytics = async () => { |
| loading = true; |
| try { |
| const result = await getModelAnalytics(localStorage.token); |
| const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id])); |
| |
| modelStats = (result?.models ?? []).map((entry) => ({ |
| ...entry, |
| name: modelsMap.get(entry.model_id) || entry.model_id |
| })); |
| } catch (err) { |
| console.error('Analytics load failed:', err); |
| } |
| loading = false; |
| }; |
| |
| $: sortedModels = [...modelStats].sort((a, b) => { |
| if (orderBy === 'name') { |
| return direction === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); |
| } |
| return direction === 'asc' ? a.count - b.count : b.count - a.count; |
| }); |
| |
| $: totalMessages = modelStats.reduce((sum, m) => sum + m.count, 0); |
| |
| onMount(loadAnalytics); |
| </script> |
|
|
| <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-medium px-0.5 gap-2 shrink-0"> |
| {$i18n.t('Model Usage')} |
| <span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span> |
| </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 !modelStats.length && !loading} |
| <div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div> |
| {:else if modelStats.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"> |
| <th scope="col" class="px-2.5 py-2 w-8">#</th> |
| <th |
| scope="col" |
| class="px-2.5 py-2 cursor-pointer select-none" |
| on:click={() => toggleSort('name')} |
| > |
| <div class="flex gap-1.5 items-center"> |
| {$i18n.t('Model')} |
| {#if orderBy === 'name'} |
| {#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> |
| <th |
| scope="col" |
| class="px-2.5 py-2 cursor-pointer select-none text-right" |
| on:click={() => toggleSort('count')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Messages')} |
| {#if orderBy === 'count'} |
| {#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> |
| <th |
| scope="col" |
| class="px-2.5 py-2 cursor-pointer select-none text-right w-24" |
| on:click={() => toggleSort('percentage')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Share')} |
| {#if orderBy === 'percentage'} |
| {#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> |
| </tr> |
| </thead> |
| <tbody> |
| {#each sortedModels as model, idx (model.model_id)} |
| <tr |
| class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" |
| > |
| <td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white"> |
| {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.model_id}" |
| alt={model.name} |
| class="size-5 rounded-full object-cover shrink-0" |
| on:error={(e) => { |
| e.target.src = '/favicon.png'; |
| }} |
| /> |
| <span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span> |
| </div> |
| </td> |
| <td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white"> |
| {model.count.toLocaleString()} |
| </td> |
| <td class="px-3 py-1.5 text-right font-medium text-blue-500"> |
| {((model.count / totalMessages) * 100).toFixed(1)}% |
| </td> |
| </tr> |
| {/each} |
| </tbody> |
| </table> |
| {/if} |
| </div> |
|
|
| <div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end"> |
| <div class="text-right"> |
| ⓘ {$i18n.t('Message counts are based on assistant responses.')} |
| </div> |
| </div> |
|
|