feat: analytics frontend dashboard

- Add Dashboard with summary stats, model/user tables
- Add ChartLine component with multi-model support
- Interactive hover tooltips and model breakdown
- Hourly granularity for 24h, daily for 7d+
This commit is contained in:
Tim Baek
2026-02-01 07:04:23 +04:00
parent 599cd2eeeb
commit a4ad34841b
9 changed files with 944 additions and 13 deletions
+195
View File
@@ -0,0 +1,195 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getModelAnalytics = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getUserAnalytics = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
limit: number = 50
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (limit) searchParams.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getMessages = async (
token: string = '',
modelId: string | null = null,
userId: string | null = null,
chatId: string | null = null,
startDate: number | null = null,
endDate: number | null = null,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const searchParams = new URLSearchParams();
if (modelId) searchParams.append('model_id', modelId);
if (userId) searchParams.append('user_id', userId);
if (chatId) searchParams.append('chat_id', chatId);
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (skip) searchParams.append('skip', skip.toString());
if (limit) searchParams.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/messages?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getSummary = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getDailyStats = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
granularity: 'hourly' | 'daily' = 'daily'
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
searchParams.append('granularity', granularity);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
+24
View File
@@ -0,0 +1,24 @@
<script>
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import Dashboard from './Analytics/Dashboard.svelte';
const i18n = getContext('i18n');
let loaded = false;
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
}
loaded = true;
});
</script>
{#if loaded}
<div class="w-full h-full pb-2 px-[16px]">
<Dashboard />
</div>
{/if}
@@ -0,0 +1,129 @@
<script lang="ts">
import dayjs from 'dayjs';
interface Props {
data: { date: string; models: Record<string, number> }[];
models: string[];
colors: string[];
height?: number;
period?: 'hour' | 'week' | 'month' | 'year' | 'all';
}
let { data, models, colors, height = 300, period = 'week' }: Props = $props();
let hoveredIdx: number | null = $state(null);
let mouseX = $state(0);
let colorMap = $derived(new Map(models.map((n, i) => [n, colors[i % colors.length]])));
let maxCount = $derived(Math.max(...data.flatMap((d) => Object.values(d.models || {})), 1));
const pad = { t: 8, r: 0, b: 20, l: 0 };
const w = 1000;
let cw = $derived(w - pad.l - pad.r);
let ch = $derived(height - pad.t - pad.b);
const getX = (i: number) =>
data.length <= 1 ? pad.l + cw / 2 : pad.l + (i / (data.length - 1)) * cw;
const getY = (v: number) => pad.t + ch - (v / maxCount) * ch;
const path = (m: string) => {
const pts = data.map((d, i) => `${getX(i)},${getY(d.models?.[m] || 0)}`);
return pts.length > 1 ? `M${pts.join('L')}` : '';
};
const onMove = (e: MouseEvent) => {
const svg = e.currentTarget as SVGSVGElement;
const r = svg.getBoundingClientRect();
mouseX = (e.clientX - r.left) * (w / r.width);
hoveredIdx = Math.max(
0,
Math.min(data.length - 1, Math.round(((mouseX - pad.l) / cw) * (data.length - 1)))
);
};
let hovered = $derived(hoveredIdx !== null ? data[hoveredIdx] : null);
</script>
<div class="relative w-full" style="height:{height}px">
<svg
viewBox="0 0 {w} {height - 20}"
class="h-[calc(100%-20px)] w-full"
preserveAspectRatio="none"
onmousemove={onMove}
onmouseleave={() => (hoveredIdx = null)}
>
{#each models as m}
<path
d={path(m)}
fill="none"
stroke={colorMap.get(m)}
stroke-width="1.5"
class={hovered && !hovered.models?.[m] ? 'opacity-20' : ''}
/>
{/each}
{#if hoveredIdx !== null}
<line
x1={getX(hoveredIdx)}
y1={pad.t}
x2={getX(hoveredIdx)}
y2={ch + pad.t}
stroke="#ddd"
stroke-width="1"
/>
{#each models as m}
{@const v = hovered?.models?.[m] || 0}
{#if v > 0}
<circle cx={getX(hoveredIdx)} cy={getY(v)} r="3" fill={colorMap.get(m)} />
{/if}
{/each}
{/if}
</svg>
<!-- X-axis labels as HTML -->
{#if data.length > 1}
{@const labelCount = Math.min(7, data.length)}
{@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1}
{@const isHourly = data[0]?.date?.includes(':')}
{@const dateFormat = isHourly ? 'h A' : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'}
<div class="flex justify-between px-0.5 text-[10px] text-gray-400">
{#each Array(labelCount) as _, i}
{@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)}
{#if data[idx]}
<span class={i === 0 ? 'text-left' : i === labelCount - 1 ? 'text-right' : 'text-center'}
>{dayjs(data[idx].date).format(dateFormat)}</span
>
{/if}
{/each}
</div>
{/if}
{#if hovered}
{@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
<div
class="pointer-events-none absolute top-1 text-[11px]"
style="left:{Math.min(Math.max((mouseX / w) * 100, 8), 92)}%"
>
<div
class="min-w-[140px] -translate-x-1/2 rounded border border-gray-100 bg-white px-2.5 py-1.5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="mb-1.5 text-[10px] text-gray-400">
{#if hovered.date?.includes(':')}
{dayjs(hovered.date).format('MMM D, h A')}
{:else}
{dayjs(hovered.date).format('MMM D, YYYY')}
{/if}
</div>
{#each Object.entries(hovered.models || {})
.sort(([, a], [, b]) => b - a)
.slice(0, 5) as [n, c]}
<div class="flex items-center justify-between gap-2 py-0.5">
<span class="min-w-0 truncate text-gray-600 dark:text-gray-300">{n}</span>
<span class="shrink-0 text-gray-900 tabular-nums dark:text-white"
>{c.toLocaleString()}
<span class="text-gray-400">({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%)</span
></span
>
</div>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,320 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getSummary, getModelAnalytics, getUserAnalytics, getDailyStats } 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 ChartLine from './ChartLine.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
const i18n = getContext('i18n');
// Time period
let selectedPeriod = '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' }
];
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 };
}
};
// Data
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 loading = true;
// 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] = await Promise.all([
getSummary(localStorage.token, start, end),
getModelAnalytics(localStorage.token, start, end),
getUserAnalytics(localStorage.token, start, end, 50),
getDailyStats(localStorage.token, start, end, granularity)
]);
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 ?? [];
} catch (err) {
console.error('Dashboard load failed:', err);
}
loading = false;
};
$: if (selectedPeriod) {
loadDashboard();
}
$: 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);
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>
<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>
<!-- 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>
<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">
<!-- Model Usage Table -->
<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 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">
<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 text-gray-400">
{totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}%
</td>
</tr>
{/each}
{#if sortedModels.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>
<!-- 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>
</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>
</tr>
{/each}
{#if sortedUsers.length === 0}
<tr><td colspan="3" 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}
@@ -0,0 +1,141 @@
<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 text-right w-24">{$i18n.t('Share')}</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"
/>
<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>
@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { getUserAnalytics } 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';
const i18n = getContext('i18n');
let userStats: Array<{ user_id: string; count: number }> = [];
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 === 'user_id' ? 'asc' : 'desc';
}
};
const loadAnalytics = async () => {
loading = true;
try {
const result = await getUserAnalytics(localStorage.token, null, null, 100);
userStats = result?.users ?? [];
} catch (err) {
console.error('User analytics load failed:', err);
}
loading = false;
};
$: sortedUsers = [...userStats].sort((a, b) => {
if (orderBy === 'user_id') {
return direction === 'asc'
? a.user_id.localeCompare(b.user_id)
: b.user_id.localeCompare(a.user_id);
}
return direction === 'asc' ? a.count - b.count : b.count - a.count;
});
$: totalMessages = userStats.reduce((sum, u) => sum + u.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('User Activity')}
<span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</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 !userStats.length && !loading}
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
{:else if userStats.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('user_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('User')}
{#if orderBy === 'user_id'}
{#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 text-right w-24">{$i18n.t('Share')}</th>
</tr>
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_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">
<span class="font-medium text-gray-800 dark:text-gray-200 font-mono text-xs">
{user.user_id.substring(0, 8)}...
</span>
</td>
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
{user.count.toLocaleString()}
</td>
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
{((user.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('Showing all messages (user + assistant) per user.')}
</div>
</div>
+2 -2
View File
@@ -66,12 +66,12 @@
href="/admin">{$i18n.t('Users')}</a
>
<!-- <a
<a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/analytics')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/admin/analytics">{$i18n.t('Analytics')}</a
> -->
>
<a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/admin/evaluations')
@@ -1,12 +1,5 @@
<script>
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Evaluations from '$lib/components/admin/Evaluations.svelte';
onMount(() => {
goto('/admin/evaluations/leaderboard');
});
import Analytics from '$lib/components/admin/Analytics.svelte';
</script>
<Evaluations />
<Analytics />
@@ -1,5 +1,5 @@
<script>
import Evaluations from '$lib/components/admin/Evaluations.svelte';
import Analytics from '$lib/components/admin/Analytics.svelte';
</script>
<Evaluations />
<Analytics />