implement date picker and additional columns for analytics (#25922)

Co-authored-by: Tim Baek <tim@openwebui.com>
This commit is contained in:
Taylor WilsdonandTim Baek authored and GitHub committed 2026-06-17 03:03:18 +02:00
1 parent e473ab1231
commit 6cd0ba0b6b
3 files changed
+152 -5

No files matched your search

@@ -3,6 +3,8 @@ import time
import uuid import uuid
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import normalize_usage from open_webui.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -440,6 +442,44 @@ class ChatMessageTable:
result = await db.execute(stmt) result = await db.execute(stmt)
return {row.model_id: row.count for row in result.all()} return {row.model_id: row.count for row in result.all()}
async def get_unique_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict]:
"""Count distinct users and chats per model."""
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
stmt = select(
ChatMessage.model_id,
func.count(distinct(ChatMessage.user_id)).label('unique_users'),
func.count(distinct(ChatMessage.chat_id)).label('unique_chats'),
).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.model_id)
result = await db.execute(stmt)
return {
row.model_id: {
'unique_users': row.unique_users,
'unique_chats': row.unique_chats,
}
for row in result.all()
}
async def get_token_usage_by_model( async def get_token_usage_by_model(
self, self,
start_date: Optional[int] = None, start_date: Optional[int] = None,
+11 -1
View File
@@ -28,6 +28,8 @@ router = APIRouter()
class ModelAnalyticsEntry(BaseModel): class ModelAnalyticsEntry(BaseModel):
model_id: str model_id: str
count: int count: int
unique_users: int = 0
unique_chats: int = 0
class ModelAnalyticsResponse(BaseModel): class ModelAnalyticsResponse(BaseModel):
@@ -65,8 +67,16 @@ async def get_model_analytics(
counts = await ChatMessages.get_message_count_by_model( counts = await ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db start_date=start_date, end_date=end_date, group_id=group_id, db=db
) )
unique_counts = await ChatMessages.get_unique_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [ models = [
ModelAnalyticsEntry(model_id=model_id, count=count) ModelAnalyticsEntry(
model_id=model_id,
count=count,
unique_users=unique_counts.get(model_id, {}).get('unique_users', 0),
unique_chats=unique_counts.get(model_id, {}).get('unique_chats', 0),
)
for model_id, count in sorted(counts.items(), key=lambda x: -x[1]) for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
] ]
return ModelAnalyticsResponse(models=models) return ModelAnalyticsResponse(models=models)
@@ -24,12 +24,20 @@
// Time period - persist in localStorage // Time period - persist in localStorage
let selectedPeriod = let selectedPeriod =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d'; (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 = [ $: periods = [
{ value: '24h', label: $i18n.t('Last 24 hours') }, { value: '24h', label: $i18n.t('Last 24 hours') },
{ value: '7d', label: $i18n.t('Last 7 days') }, { value: '7d', label: $i18n.t('Last 7 days') },
{ value: '30d', label: $i18n.t('Last 30 days') }, { value: '30d', label: $i18n.t('Last 30 days') },
{ value: '90d', label: $i18n.t('Last 90 days') }, { value: '90d', label: $i18n.t('Last 90 days') },
{ value: 'all', label: $i18n.t('All time') } { value: 'all', label: $i18n.t('All time') },
{ value: 'custom', label: $i18n.t('Custom range') }
]; ];
// User group filter // User group filter
@@ -48,6 +56,14 @@
return { start: now - 30 * day, end: now }; return { start: now - 30 * day, end: now };
case '90d': case '90d':
return { start: now - 90 * day, end: now }; 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: default:
return { start: null, end: null }; return { start: null, end: null };
} }
@@ -55,7 +71,13 @@
// Data // Data
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 }; 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 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 userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
let dailyStats: Array<{ date: string; models: Record<string, number> }> = []; let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
let tokenStats: Record< let tokenStats: Record<
@@ -140,7 +162,13 @@
loading = false; loading = false;
}; };
$: if (selectedPeriod || selectedGroupId !== undefined) { // 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(); loadDashboard();
} }
@@ -163,6 +191,16 @@
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0; const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens; 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; return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
}); });
@@ -186,6 +224,12 @@
$: if (typeof localStorage !== 'undefined' && selectedPeriod) { $: if (typeof localStorage !== 'undefined' && selectedPeriod) {
localStorage.setItem('analyticsPeriod', selectedPeriod); localStorage.setItem('analyticsPeriod', selectedPeriod);
} }
// Persist custom date range
$: if (typeof localStorage !== 'undefined') {
localStorage.setItem('analyticsCustomStart', customStart);
localStorage.setItem('analyticsCustomEnd', customEnd);
}
</script> </script>
<!-- Header with title and period selector --> <!-- Header with title and period selector -->
@@ -207,6 +251,21 @@
{/each} {/each}
</select> </select>
{/if} {/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 <select
bind:value={selectedPeriod} bind:value={selectedPeriod}
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right" class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
@@ -337,6 +396,42 @@
{/if} {/if}
</div> </div>
</th> </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 <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right" class="px-2.5 py-2 cursor-pointer select-none text-right"
@@ -399,6 +494,8 @@
</div> </div>
</td> </td>
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</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" <td class="px-3 py-1 text-right"
>{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td >{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td
> >
@@ -411,7 +508,7 @@
{/each} {/each}
{#if sortedModels.length === 0} {#if sortedModels.length === 0}
<tr <tr
><td colspan="5" class="px-3 py-2 text-center text-gray-400" ><td colspan="7" class="px-3 py-2 text-center text-gray-400"
>{$i18n.t('No data')}</td >{$i18n.t('No data')}</td
></tr ></tr
> >