mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-15 21:19:39 -05:00
implement date picker and additional columns for analytics (#25922)
Co-authored-by: Tim Baek <tim@openwebui.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user