| <script lang="ts"> |
| import { onMount, getContext } from 'svelte'; |
| import { models } from '$lib/stores'; |
| import { |
| getSummary, |
| getModelAnalytics, |
| getUserAnalytics, |
| getDailyStats, |
| getTokenUsage |
| } from '$lib/apis/analytics'; |
| import { getGroups } from '$lib/apis/groups'; |
| 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 ChartLine from './ChartLine.svelte'; |
| import AnalyticsModelModal from './AnalyticsModelModal.svelte'; |
| import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| import { WEBUI_API_BASE_URL } from '$lib/constants'; |
| import { formatNumber } from '$lib/utils'; |
| import { goto } from '$app/navigation'; |
| |
| const i18n = getContext('i18n'); |
| |
| |
| let selectedPeriod = |
| (typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d'; |
| $: periods = [ |
| { value: '24h', label: $i18n.t('Last 24 hours') }, |
| { value: '7d', label: $i18n.t('Last 7 days') }, |
| { value: '30d', label: $i18n.t('Last 30 days') }, |
| { value: '90d', label: $i18n.t('Last 90 days') }, |
| { value: 'all', label: $i18n.t('All time') } |
| ]; |
| |
| |
| let groups: Array<{ id: string; name: string }> = []; |
| let selectedGroupId: string | null = null; |
| |
| const getDateRange = (period: string): { start: number | null; end: number | null } => { |
| const now = Math.floor(Date.now() / 1000); |
| const day = 86400; |
| switch (period) { |
| case '24h': |
| return { start: now - day, end: now }; |
| case '7d': |
| return { start: now - 7 * day, end: now }; |
| case '30d': |
| return { start: now - 30 * day, end: now }; |
| case '90d': |
| return { start: now - 90 * day, end: now }; |
| default: |
| return { start: null, end: null }; |
| } |
| }; |
| |
| |
| let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 }; |
| let modelStats: Array<{ model_id: string; count: number; name?: string }> = []; |
| let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = []; |
| let dailyStats: Array<{ date: string; models: Record<string, number> }> = []; |
| let tokenStats: Record< |
| string, |
| { input_tokens: number; output_tokens: number; total_tokens: number } |
| > = {}; |
| let totalTokens = { input: 0, output: 0, total: 0 }; |
| |
| let loading = true; |
| |
| |
| let selectedModel: { id: string; name: string } | null = null; |
| let showModelModal = false; |
| |
| |
| let modelOrderBy = 'count'; |
| let modelDirection: 'asc' | 'desc' = 'desc'; |
| let userOrderBy = 'count'; |
| let userDirection: 'asc' | 'desc' = 'desc'; |
| |
| const toggleModelSort = (key: string) => { |
| if (modelOrderBy === key) { |
| modelDirection = modelDirection === 'asc' ? 'desc' : 'asc'; |
| } else { |
| modelOrderBy = key; |
| modelDirection = key === 'name' ? 'asc' : 'desc'; |
| } |
| }; |
| |
| const toggleUserSort = (key: string) => { |
| if (userOrderBy === key) { |
| userDirection = userDirection === 'asc' ? 'desc' : 'asc'; |
| } else { |
| userOrderBy = key; |
| userDirection = key === 'user_id' ? 'asc' : 'desc'; |
| } |
| }; |
| |
| const loadDashboard = async () => { |
| loading = true; |
| try { |
| const { start, end } = getDateRange(selectedPeriod); |
| const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily'; |
| const [summaryRes, modelsRes, usersRes, dailyRes, tokensRes] = await Promise.all([ |
| getSummary(localStorage.token, start, end, selectedGroupId), |
| getModelAnalytics(localStorage.token, start, end, selectedGroupId), |
| getUserAnalytics(localStorage.token, start, end, 50, selectedGroupId), |
| getDailyStats(localStorage.token, start, end, granularity, selectedGroupId), |
| getTokenUsage(localStorage.token, start, end, selectedGroupId) |
| ]); |
| |
| summary = summaryRes ?? summary; |
| |
| const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id])); |
| modelStats = (modelsRes?.models ?? []).map((entry) => ({ |
| ...entry, |
| name: modelsMap.get(entry.model_id) || entry.model_id |
| })); |
| |
| userStats = usersRes?.users ?? []; |
| dailyStats = dailyRes?.data ?? []; |
| |
| |
| if (tokensRes) { |
| tokenStats = {}; |
| for (const m of tokensRes.models) { |
| tokenStats[m.model_id] = { |
| input_tokens: m.input_tokens, |
| output_tokens: m.output_tokens, |
| total_tokens: m.total_tokens |
| }; |
| } |
| totalTokens = { |
| input: tokensRes.total_input_tokens, |
| output: tokensRes.total_output_tokens, |
| total: tokensRes.total_tokens |
| }; |
| } |
| } catch (err) { |
| console.error('Dashboard load failed:', err); |
| } |
| loading = false; |
| }; |
| |
| $: if (selectedPeriod || selectedGroupId !== undefined) { |
| loadDashboard(); |
| } |
| |
| onMount(async () => { |
| // Load groups for filter |
| try { |
| const res = await getGroups(localStorage.token); |
| groups = res ?? []; |
| } catch (e) { |
| console.error('Failed to load groups:', e); |
| } |
| }); |
| |
| $: sortedModels = [...modelStats].sort((a, b) => { |
| if (modelOrderBy === 'name') { |
| return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); |
| } |
| if (modelOrderBy === 'tokens') { |
| const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0; |
| const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0; |
| return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; |
| } |
| return modelDirection === 'asc' ? a.count - b.count : b.count - a.count; |
| }); |
| |
| $: sortedUsers = [...userStats].sort((a, b) => { |
| if (userOrderBy === 'name') { |
| const nameA = a.name || a.user_id; |
| const nameB = b.name || b.user_id; |
| return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA); |
| } |
| if (userOrderBy === 'tokens') { |
| const aTokens = a.total_tokens ?? 0; |
| const bTokens = b.total_tokens ?? 0; |
| return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; |
| } |
| return userDirection === 'asc' ? a.count - b.count : b.count - a.count; |
| }); |
| |
| $: totalModelMessages = modelStats.reduce((sum, m) => sum + m.count, 0); |
| |
| |
| $: if (typeof localStorage !== 'undefined' && selectedPeriod) { |
| localStorage.setItem('analyticsPeriod', selectedPeriod); |
| } |
| |
| onMount(loadDashboard); |
| </script> |
|
|
| <!-- Header with title and period selector --> |
| <div |
| class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900" |
| > |
| <div class="text-lg font-medium px-0.5 shrink-0"> |
| {$i18n.t('Analytics')} |
| </div> |
| <div class="flex items-center gap-2 flex-wrap justify-end min-w-0"> |
| {#if groups.length > 0} |
| <select |
| bind:value={selectedGroupId} |
| class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right" |
| > |
| <option value={null}>{$i18n.t('All Users')}</option> |
| {#each groups as group} |
| <option value={group.id}>{group.name}</option> |
| {/each} |
| </select> |
| {/if} |
| <select |
| bind:value={selectedPeriod} |
| class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right" |
| > |
| {#each periods as period} |
| <option value={period.value}>{period.label}</option> |
| {/each} |
| </select> |
| </div> |
| </div> |
|
|
| <!-- Model Details Modal --> |
| <AnalyticsModelModal |
| bind:show={showModelModal} |
| model={selectedModel} |
| startDate={getDateRange(selectedPeriod).start} |
| endDate={getDateRange(selectedPeriod).end} |
| /> |
|
|
| <!-- Summary stats --> |
| {#if !loading} |
| <div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2"> |
| <span |
| ><span class="font-medium text-gray-900 dark:text-gray-300" |
| >{summary.total_messages.toLocaleString()}</span |
| > |
| {$i18n.t('messages')}</span |
| > |
| <Tooltip content={$i18n.t('Token counts are estimates and may not reflect actual API usage')}> |
| <span class="cursor-help" |
| ><span class="font-medium text-gray-900 dark:text-gray-300" |
| >{formatNumber(totalTokens.total)}</span |
| > |
| {$i18n.t('tokens')}</span |
| > |
| </Tooltip> |
| <span |
| ><span class="font-medium text-gray-900 dark:text-gray-300" |
| >{summary.total_chats.toLocaleString()}</span |
| > |
| {$i18n.t('chats')}</span |
| > |
| <span |
| ><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span> |
| {$i18n.t('users')}</span |
| > |
| </div> |
|
|
| <!-- Daily usage chart --> |
| {#if dailyStats.length > 1} |
| {@const allModels = [...new Set(dailyStats.flatMap((d) => Object.keys(d.models || {})))]} |
| {@const topModels = allModels.slice(0, 8)} |
| {@const chartColors = [ |
| '#3b82f6', |
| '#10b981', |
| '#f59e0b', |
| '#ef4444', |
| '#8b5cf6', |
| '#ec4899', |
| '#06b6d4', |
| '#84cc16' |
| ]} |
| {@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }} |
| <div class="mb-4"> |
| <div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5"> |
| {selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')} |
| </div> |
| <ChartLine |
| data={dailyStats} |
| models={topModels} |
| colors={chartColors} |
| height={200} |
| period={periodMap[selectedPeriod] || 'week'} |
| /> |
| </div> |
| {/if} |
| {/if} |
|
|
| {#if loading} |
| <div class="my-10 flex justify-center"> |
| <Spinner className="size-5" /> |
| </div> |
| {:else} |
| <div class="grid md:grid-cols-2 gap-4"> |
| |
| <div> |
| <div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5"> |
| {$i18n.t('Model Usage')} |
| </div> |
| <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full"> |
| <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto"> |
| <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={() => toggleModelSort('name')} |
| > |
| <div class="flex gap-1.5 items-center"> |
| {$i18n.t('Model')} |
| {#if modelOrderBy === 'name'} |
| <span class="font-normal"> |
| {#if modelDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {: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={() => toggleModelSort('count')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Messages')} |
| {#if modelOrderBy === 'count'} |
| <span class="font-normal"> |
| {#if modelDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {: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={() => toggleModelSort('tokens')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Tokens')} |
| {#if modelOrderBy === 'tokens'} |
| <span class="font-normal"> |
| {#if modelDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {: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-16" |
| on:click={() => toggleModelSort('percentage')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| % |
| {#if modelOrderBy === 'percentage'} |
| <span class="font-normal"> |
| {#if modelDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {: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 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" |
| on:click={() => { |
| selectedModel = { id: model.model_id, name: model.name }; |
| showModelModal = true; |
| }} |
| > |
| <td class="px-3 py-1 text-gray-400">{idx + 1}</td> |
| <td class="px-3 py-1 font-medium text-gray-900 dark:text-white"> |
| <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="truncate max-w-[150px]">{model.name}</span> |
| </div> |
| </td> |
| <td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td> |
| <td class="px-3 py-1 text-right" |
| >{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td |
| > |
| <td class="px-3 py-1 text-right text-gray-400"> |
| {totalModelMessages > 0 |
| ? ((model.count / totalModelMessages) * 100).toFixed(1) |
| : 0}% |
| </td> |
| </tr> |
| {/each} |
| {#if sortedModels.length === 0} |
| <tr |
| ><td colspan="5" class="px-3 py-2 text-center text-gray-400" |
| >{$i18n.t('No data')}</td |
| ></tr |
| > |
| {/if} |
| </tbody> |
| </table> |
| </div> |
| </div> |
|
|
| <!-- User Activity Table --> |
| <div> |
| <div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5"> |
| {$i18n.t('User Activity')} |
| </div> |
| <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full"> |
| <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto"> |
| <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={() => toggleUserSort('name')} |
| > |
| <div class="flex gap-1.5 items-center"> |
| {$i18n.t('User')} |
| {#if userOrderBy === 'name'} |
| <span class="font-normal"> |
| {#if userDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {: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={() => toggleUserSort('count')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Messages')} |
| {#if userOrderBy === 'count'} |
| <span class="font-normal"> |
| {#if userDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {: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={() => toggleUserSort('tokens')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Tokens')} |
| {#if userOrderBy === 'tokens'} |
| <span class="font-normal"> |
| {#if userDirection === 'asc'}<ChevronUp |
| className="size-2" |
| />{:else}<ChevronDown className="size-2" />{/if} |
| </span> |
| {:else} |
| <span class="invisible"><ChevronUp className="size-2" /></span> |
| {/if} |
| </div> |
| </th> |
| </tr> |
| </thead> |
| <tbody> |
| {#each sortedUsers as user, idx (user.user_id)} |
| <tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs"> |
| <td class="px-3 py-1 text-gray-400">{idx + 1}</td> |
| <td class="px-3 py-1 font-medium text-gray-900 dark:text-white"> |
| <div class="flex items-center gap-2"> |
| <img |
| src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image" |
| alt={user.name || 'User'} |
| class="size-5 rounded-full object-cover shrink-0" |
| on:error={(e) => { |
| e.target.src = '/user.png'; |
| }} |
| /> |
| <span class="truncate max-w-[150px]" |
| >{user.name || user.email || user.user_id.substring(0, 8)}</span |
| > |
| </div> |
| </td> |
| <td class="px-3 py-1 text-right">{user.count.toLocaleString()}</td> |
| <td class="px-3 py-1 text-right">{formatNumber(user.total_tokens ?? 0)}</td> |
| </tr> |
| {/each} |
| {#if sortedUsers.length === 0} |
| <tr |
| ><td colspan="4" class="px-3 py-2 text-center text-gray-400" |
| >{$i18n.t('No data')}</td |
| ></tr |
| > |
| {/if} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="text-gray-500 text-xs mt-1.5 text-right"> |
| ⓘ {$i18n.t('Message counts are based on assistant responses.')} |
| </div> |
| {/if} |
|
|