Files
open-webui/src/lib/components/admin/Analytics/Dashboard.svelte
T
Timothy Jaeryang Baek aedb6bef4e refac
2026-07-14 04:15:20 -04:00

625 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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');
// Time period - persist in localStorage
let selectedPeriod =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
// Custom date range (YYYY-MM-DD) - persist in localStorage
let customStart =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsCustomStart')) || '';
let customEnd =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsCustomEnd')) || '';
$: 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') },
{ value: 'custom', label: $i18n.t('Custom range') }
];
// User group filter
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 };
case 'custom': {
// Parse YYYY-MM-DD inputs; end date is inclusive (covers the full day)
const start = customStart ? Math.floor(new Date(customStart).getTime() / 1000) : null;
const end = customEnd ? Math.floor(new Date(customEnd).getTime() / 1000) + day - 1 : null;
return { start, end };
}
default:
return { start: null, end: null };
}
};
// Data
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
let modelStats: Array<{
model_id: string;
count: number;
unique_users?: number;
unique_chats?: 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;
// Selected model for drill-down
let selectedModel: { id: string; name: string } | null = null;
let showModelModal = false;
// Sorting
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 ?? [];
// Process token 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;
};
// Reload when the period, group, or custom range changes.
// In custom mode, wait until both dates are set to avoid a half-specified query.
$: if (selectedPeriod === 'custom' ? customStart && customEnd : selectedPeriod) {
// reference customStart/customEnd so this block reruns when they change
customStart;
customEnd;
selectedGroupId;
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;
}
if (modelOrderBy === 'users') {
const aUsers = a.unique_users ?? 0;
const bUsers = b.unique_users ?? 0;
return modelDirection === 'asc' ? aUsers - bUsers : bUsers - aUsers;
}
if (modelOrderBy === 'chats') {
const aChats = a.unique_chats ?? 0;
const bChats = b.unique_chats ?? 0;
return modelDirection === 'asc' ? aChats - bChats : bChats - aChats;
}
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);
// Persist period selection
$: if (typeof localStorage !== 'undefined' && selectedPeriod) {
localStorage.setItem('analyticsPeriod', selectedPeriod);
}
// Persist custom date range
$: if (typeof localStorage !== 'undefined') {
localStorage.setItem('analyticsCustomStart', customStart);
localStorage.setItem('analyticsCustomEnd', customEnd);
}
</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-normal 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}
{#if selectedPeriod === 'custom'}
<input
type="date"
bind:value={customStart}
max={customEnd || undefined}
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
/>
<span class="text-xs text-gray-400"></span>
<input
type="date"
bind:value={customEnd}
min={customStart || undefined}
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
/>
{/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-normal 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-normal text-gray-900 dark:text-gray-300"
>{formatNumber(totalTokens.total)}</span
>
{$i18n.t('tokens')}</span
>
</Tooltip>
<span
><span class="font-normal text-gray-900 dark:text-gray-300"
>{summary.total_chats.toLocaleString()}</span
>
{$i18n.t('chats')}</span
>
<span
><span class="font-normal 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-normal 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">
<!-- Model Usage Table -->
<div>
<div class="text-xs font-normal 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('users')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Users')}
{#if modelOrderBy === 'users'}
<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('chats')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Chats')}
{#if modelOrderBy === 'chats'}
<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-normal 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">{(model.unique_users ?? 0).toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_chats ?? 0).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="7" 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-normal 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-normal 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}