diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 8660e1e987..2e3d623733 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -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, diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index b525d26466..6567c7540a 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -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) diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index fd753381e1..d94dd4ee79 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -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 }> = []; 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); + } @@ -207,6 +251,21 @@ {/each} {/if} + {#if selectedPeriod === 'custom'} + + + + {/if}