| | <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'; |
| | const periods = [ |
| | { value: '24h', label: 'Last 24 hours' }, |
| | { value: '7d', label: 'Last 7 days' }, |
| | { value: '30d', label: 'Last 30 days' }, |
| | { value: '90d', label: 'Last 90 days' }, |
| | { value: 'all', label: '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); |
| | } |
| | 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); |
| | } |
| | 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"> |
| | {$i18n.t('Analytics')} |
| | </div> |
| | <div class="flex items-center gap-2"> |
| | {#if groups.length > 0} |
| | <select |
| | bind:value={selectedGroupId} |
| | class="dark:bg-gray-900 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="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right" |
| | > |
| | {#each periods as period} |
| | <option value={period.value}>{$i18n.t(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"> |
| | {$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : '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 text-right">{$i18n.t('Tokens')}</th> |
| | <th scope="col" class="px-2.5 py-2 text-right w-16">%</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" |
| | /> |
| | <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 text-right">{$i18n.t('Tokens')}</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" |
| | /> |
| | <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} |
| |
|