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:
1 parent
599cd2eeeb
commit
a4ad34841b
9 files changed
+944
-13
No files matched your search
@@ -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}
|
||||
Reference in new issue
Block a user