diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts new file mode 100644 index 0000000000..3aeb711396 --- /dev/null +++ b/src/lib/apis/analytics/index.ts @@ -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; +}; diff --git a/src/lib/components/admin/Analytics.svelte b/src/lib/components/admin/Analytics.svelte new file mode 100644 index 0000000000..4aa6ae86f6 --- /dev/null +++ b/src/lib/components/admin/Analytics.svelte @@ -0,0 +1,24 @@ + + +{#if loaded} +
+ +
+{/if} diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte new file mode 100644 index 0000000000..5c3ffabdf3 --- /dev/null +++ b/src/lib/components/admin/Analytics/ChartLine.svelte @@ -0,0 +1,129 @@ + + +
+ (hoveredIdx = null)} + > + {#each models as m} + + {/each} + {#if hoveredIdx !== null} + + {#each models as m} + {@const v = hovered?.models?.[m] || 0} + {#if v > 0} + + {/if} + {/each} + {/if} + + + {#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'} +
+ {#each Array(labelCount) as _, i} + {@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)} + {#if data[idx]} + {dayjs(data[idx].date).format(dateFormat)} + {/if} + {/each} +
+ {/if} + {#if hovered} + {@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)} +
+
+
+ {#if hovered.date?.includes(':')} + {dayjs(hovered.date).format('MMM D, h A')} + {:else} + {dayjs(hovered.date).format('MMM D, YYYY')} + {/if} +
+ {#each Object.entries(hovered.models || {}) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) as [n, c]} +
+ {n} + {c.toLocaleString()} + ({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%) +
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte new file mode 100644 index 0000000000..5efc41b551 --- /dev/null +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -0,0 +1,320 @@ + + + +
+
+ {$i18n.t('Analytics')} +
+ +
+ + +{#if !loading} +
+ {summary.total_messages.toLocaleString()} {$i18n.t('messages')} + {summary.total_chats.toLocaleString()} {$i18n.t('chats')} + {summary.total_users} {$i18n.t('users')} +
+ + + {#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' }} +
+
+ {$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')} +
+ +
+ {/if} +{/if} + +{#if loading} +
+ +
+{:else} +
+ +
+
+ {$i18n.t('Model Usage')} +
+
+ + + + + + + + + + + {#each sortedModels as model, idx (model.model_id)} + + + + + + + {/each} + {#if sortedModels.length === 0} + + {/if} + +
# toggleModelSort('name')} + > +
+ {$i18n.t('Model')} + {#if modelOrderBy === 'name'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('count')} + > +
+ {$i18n.t('Messages')} + {#if modelOrderBy === 'count'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
%
{idx + 1} +
+ {model.name} + {model.name} +
+
{model.count.toLocaleString()} + {totalModelMessages > 0 ? ((model.count / totalModelMessages) * 100).toFixed(1) : 0}% +
{$i18n.t('No data')}
+
+
+ + +
+
+ {$i18n.t('User Activity')} +
+
+ + + + + + + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + + {/each} + {#if sortedUsers.length === 0} + + {/if} + +
# toggleUserSort('name')} + > +
+ {$i18n.t('User')} + {#if userOrderBy === 'name'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort('count')} + > +
+ {$i18n.t('Messages')} + {#if userOrderBy === 'count'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{idx + 1} +
+ {user.name + {user.name || user.email || user.user_id.substring(0, 8)} +
+
{user.count.toLocaleString()}
{$i18n.t('No data')}
+
+
+
+ +
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')} +
+{/if} diff --git a/src/lib/components/admin/Analytics/ModelUsage.svelte b/src/lib/components/admin/Analytics/ModelUsage.svelte new file mode 100644 index 0000000000..d7212f721a --- /dev/null +++ b/src/lib/components/admin/Analytics/ModelUsage.svelte @@ -0,0 +1,141 @@ + + +
+
+ {$i18n.t('Model Usage')} + {totalMessages} {$i18n.t('messages')} +
+
+ +
+ {#if loading} +
+ +
+ {/if} + + {#if !modelStats.length && !loading} +
{$i18n.t('No data found')}
+ {:else if modelStats.length} + + + + + + + + + + + {#each sortedModels as model, idx (model.model_id)} + + + + + + + {/each} + +
# toggleSort('name')} + > +
+ {$i18n.t('Model')} + {#if orderBy === 'name'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
toggleSort('count')} + > +
+ {$i18n.t('Messages')} + {#if orderBy === 'count'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{$i18n.t('Share')}
+ {idx + 1} + +
+ {model.name} + {model.name} +
+
+ {model.count.toLocaleString()} + + {((model.count / totalMessages) * 100).toFixed(1)}% +
+ {/if} +
+ +
+
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')} +
+
diff --git a/src/lib/components/admin/Analytics/UserUsage.svelte b/src/lib/components/admin/Analytics/UserUsage.svelte new file mode 100644 index 0000000000..015ba195d1 --- /dev/null +++ b/src/lib/components/admin/Analytics/UserUsage.svelte @@ -0,0 +1,129 @@ + + +
+
+ {$i18n.t('User Activity')} + {userStats.length} {$i18n.t('users')} +
+
+ +
+ {#if loading} +
+ +
+ {/if} + + {#if !userStats.length && !loading} +
{$i18n.t('No data found')}
+ {:else if userStats.length} + + + + + + + + + + + {#each sortedUsers as user, idx (user.user_id)} + + + + + + + {/each} + +
# toggleSort('user_id')} + > +
+ {$i18n.t('User')} + {#if orderBy === 'user_id'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
toggleSort('count')} + > +
+ {$i18n.t('Messages')} + {#if orderBy === 'count'} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
{$i18n.t('Share')}
+ {idx + 1} + + + {user.user_id.substring(0, 8)}... + + + {user.count.toLocaleString()} + + {((user.count / totalMessages) * 100).toFixed(1)}% +
+ {/if} +
+ +
+
+ ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')} +
+
diff --git a/src/routes/(app)/admin/+layout.svelte b/src/routes/(app)/admin/+layout.svelte index ec3d931b41..6577cdabef 100644 --- a/src/routes/(app)/admin/+layout.svelte +++ b/src/routes/(app)/admin/+layout.svelte @@ -66,12 +66,12 @@ href="/admin">{$i18n.t('Users')} - + >