implement date picker and additional columns for analytics

This commit is contained in:
Taylor Wilsdon 2026-06-10 17:50:38 -04:00
parent cdfcbc4af6
commit bbdd8a8be3
4 changed files with 154 additions and 9 deletions

View file

@ -3,7 +3,7 @@ import time
import uuid
from typing import Any, Optional
from sqlalchemy import select, delete, func, cast, Integer
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
@ -346,6 +346,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;
});
@ -187,6 +225,12 @@
localStorage.setItem('analyticsPeriod', selectedPeriod);
}
// Persist custom date range
$: if (typeof localStorage !== 'undefined') {
localStorage.setItem('analyticsCustomStart', customStart);
localStorage.setItem('analyticsCustomEnd', customEnd);
}
onMount(loadDashboard);
</script>
@ -209,6 +253,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"
@ -339,6 +398,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"
@ -401,6 +496,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
>
@ -413,7 +510,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
>

View file

@ -4975,10 +4975,10 @@
},
"black": {
"name": "black",
"version": "26.3.1",
"file_name": "black-26.3.1-py3-none-any.whl",
"version": "26.5.1",
"file_name": "black-26.5.1-py3-none-any.whl",
"install_dir": "site",
"sha256": "2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b",
"sha256": "4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2",
"package_type": "package",
"imports": [
"black"