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

Co-authored-by: Tim Baek <tim@openwebui.com>
This commit is contained in:
Taylor Wilsdon
2026-06-16 21:03:18 -04:00
committed by GitHub
parent e473ab1231
commit 6cd0ba0b6b
3 changed files with 152 additions and 5 deletions

View File

@@ -3,6 +3,8 @@ import time
import uuid
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.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict
@@ -440,6 +442,44 @@ class ChatMessageTable:
result = await db.execute(stmt)
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(
self,
start_date: Optional[int] = None,

View File

@@ -28,6 +28,8 @@ router = APIRouter()
class ModelAnalyticsEntry(BaseModel):
model_id: str
count: int
unique_users: int = 0
unique_chats: int = 0
class ModelAnalyticsResponse(BaseModel):
@@ -65,8 +67,16 @@ async def get_model_analytics(
counts = await ChatMessages.get_message_count_by_model(
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 = [
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])
]
return ModelAnalyticsResponse(models=models)

View File

@@ -24,12 +24,20 @@
// 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: 'all', label: $i18n.t('All time') },
{ value: 'custom', label: $i18n.t('Custom range') }
];
// User group filter
@@ -48,6 +56,14 @@
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 };
}
@@ -55,7 +71,13 @@
// 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 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<
@@ -140,7 +162,13 @@
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();
}
@@ -163,6 +191,16 @@
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;
});
@@ -186,6 +224,12 @@
$: 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 -->
@@ -207,6 +251,21 @@
{/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"
@@ -337,6 +396,42 @@
{/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"
@@ -399,6 +494,8 @@
</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
>
@@ -411,7 +508,7 @@
{/each}
{#if sortedModels.length === 0}
<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
></tr
>