diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py
index de5366ec52..ad90a97b89 100644
--- a/backend/open_webui/models/chat_messages.py
+++ b/backend/open_webui/models/chat_messages.py
@@ -23,6 +23,7 @@ from sqlalchemy import (
cast,
delete,
func,
+ literal,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
@@ -961,20 +962,36 @@ class ChatMessageTable:
result = await db.execute(stmt)
return {row.chat_id: row.count for row in result.all()}
- async def get_daily_message_counts_by_model(
+ async def get_usage_over_time_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
+ granularity: str = 'daily',
+ metric: str = 'messages',
) -> dict[str, dict[str, int]]:
- """Get message counts grouped by day and model."""
- async with get_async_db_context(db) as db:
- from datetime import datetime, timedelta
+ """Get message counts or total tokens grouped by time bucket and model."""
+ bucket_minutes = {'10m': 10, 'hourly': 60, 'daily': 1440}[granularity]
+ date_format = '%Y-%m-%d' if granularity == 'daily' else '%Y-%m-%d %H:%M'
+ step = timedelta(minutes=bucket_minutes)
+ def bucket_start(timestamp):
+ date = datetime.fromtimestamp(_normalize_timestamp(timestamp))
+ minutes = date.hour * 60 + date.minute
+ minutes = minutes // bucket_minutes * bucket_minutes
+ return date.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0)
+
+ async with get_async_db_context(db) as db:
from open_webui.models.groups import group_user_memberships
- stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
+ value = literal(1)
+ if metric == 'tokens':
+ bind = await db.connection()
+ input_tokens, output_tokens = _token_columns(bind.dialect.name)
+ value = func.coalesce(input_tokens, 0) + func.coalesce(output_tokens, 0)
+
+ stmt = select(ChatMessage.created_at, ChatMessage.model_id, value).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
@@ -988,78 +1005,20 @@ class ChatMessageTable:
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
result = await db.execute(stmt)
- results = result.all()
+ counts: dict[str, dict[str, int]] = {}
+ for timestamp, model_id, value in result.all():
+ date_str = bucket_start(timestamp).strftime(date_format)
+ bucket = counts.setdefault(date_str, {})
+ bucket[model_id] = bucket.get(model_id, 0) + int(value)
- # Group by date -> model -> count
- daily_counts: dict[str, dict[str, int]] = {}
- for timestamp, model_id in results:
- date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d')
- if date_str not in daily_counts:
- daily_counts[date_str] = {}
- daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1
-
- # Fill in missing days
if start_date and end_date:
- current = datetime.fromtimestamp(_normalize_timestamp(start_date))
+ current = bucket_start(start_date)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
- date_str = current.strftime('%Y-%m-%d')
- if date_str not in daily_counts:
- daily_counts[date_str] = {}
- current += timedelta(days=1)
+ counts.setdefault(current.strftime(date_format), {})
+ current += step
- return daily_counts
-
- async def get_hourly_message_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[str, int]]:
- """Get message counts grouped by hour and model."""
- async with get_async_db_context(db) as db:
- from datetime import datetime, timedelta
-
- from open_webui.models.groups import group_user_memberships
-
- stmt = select(ChatMessage.created_at, ChatMessage.model_id).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(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
- stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
-
- result = await db.execute(stmt)
- results = result.all()
-
- # Group by hour -> model -> count
- hourly_counts: dict[str, dict[str, int]] = {}
- for timestamp, model_id in results:
- hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00')
- if hour_str not in hourly_counts:
- hourly_counts[hour_str] = {}
- hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1
-
- # Fill in missing hours
- if start_date and end_date:
- current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace(
- minute=0, second=0, microsecond=0
- )
- end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
- while current <= end_dt:
- hour_str = current.strftime('%Y-%m-%d %H:00')
- if hour_str not in hourly_counts:
- hourly_counts[hour_str] = {}
- current += timedelta(hours=1)
-
- return hourly_counts
+ return counts
ChatMessages = ChatMessageTable()
diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py
index 7d57ab3262..b6ee4b9f65 100644
--- a/backend/open_webui/routers/analytics.py
+++ b/backend/open_webui/routers/analytics.py
@@ -1,7 +1,7 @@
import logging
from collections import defaultdict
from datetime import datetime, timedelta
-from typing import Optional
+from typing import Literal, Optional
from fastapi import APIRouter, Depends, Query
from open_webui.internal.db import get_async_session
@@ -201,19 +201,20 @@ async def get_daily_stats(
start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
group_id: Optional[str] = Query(None, description='Filter by user group ID'),
- granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"),
+ granularity: Literal['10m', 'hourly', 'daily'] = Query('daily'),
+ metric: Literal['messages', 'tokens'] = Query('messages'),
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
- """Get message counts grouped by model for time-series chart."""
- if granularity == 'hourly':
- counts = await ChatMessages.get_hourly_message_counts_by_model(
- start_date=start_date, end_date=end_date, group_id=group_id, db=db
- )
- else:
- counts = await ChatMessages.get_daily_message_counts_by_model(
- start_date=start_date, end_date=end_date, group_id=group_id, db=db
- )
+ """Get message counts or total tokens grouped by model for the time-series chart."""
+ counts = await ChatMessages.get_usage_over_time_by_model(
+ start_date=start_date,
+ end_date=end_date,
+ group_id=group_id,
+ db=db,
+ granularity=granularity,
+ metric=metric,
+ )
return DailyStatsResponse(
data=[DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())]
)
diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts
index 88e16c48e1..a6489a62a5 100644
--- a/src/lib/apis/analytics/index.ts
+++ b/src/lib/apis/analytics/index.ts
@@ -166,8 +166,9 @@ export const getDailyStats = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
- granularity: 'hourly' | 'daily' = 'daily',
- groupId: string | null = null
+ granularity: '10m' | 'hourly' | 'daily' = 'daily',
+ groupId: string | null = null,
+ metric: 'messages' | 'tokens' = 'messages'
) => {
let error = null;
@@ -175,6 +176,7 @@ export const getDailyStats = async (
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
searchParams.append('granularity', granularity);
+ searchParams.append('metric', metric);
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, {
diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte
index c78f6dccca..0eafb887dc 100644
--- a/src/lib/components/admin/Analytics/ChartLine.svelte
+++ b/src/lib/components/admin/Analytics/ChartLine.svelte
@@ -86,7 +86,9 @@
{@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1}
{@const isHourly = data[0]?.date?.includes(':')}
{@const dateFormat = isHourly
- ? 'h A'
+ ? period === 'hour'
+ ? 'h:mm A'
+ : 'M/D h A'
: period === 'year' || period === 'all'
? 'M/D/YY'
: 'M/D'}
@@ -106,15 +108,17 @@
{#if hovered}
{@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
{#if hovered.date?.includes(':')}
- {dayjs(hovered.date).locale($i18n.language).format('MMM D, h A')}
+ {dayjs(hovered.date)
+ .locale($i18n.language)
+ .format(period === 'hour' ? 'MMM D, h:mm A' : 'MMM D, h A')}
{:else}
{dayjs(hovered.date).locale($i18n.language).format('MMM D, YYYY')}
{/if}
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte
index bb6cfe6483..6642c4a12c 100644
--- a/src/lib/components/admin/Analytics/Dashboard.svelte
+++ b/src/lib/components/admin/Analytics/Dashboard.svelte
@@ -25,6 +25,10 @@
// Time period - persist in localStorage
let selectedPeriod =
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
+ let granularity: '10m' | 'hourly' | 'daily';
+ $: granularity =
+ selectedPeriod === '24h' ? '10m' : ['7d', '30d'].includes(selectedPeriod) ? 'hourly' : 'daily';
+ let selectedMetric: 'messages' | 'tokens' = 'messages';
// Custom date range (YYYY-MM-DD) - persist in localStorage
let customStart =
@@ -130,12 +134,11 @@
loading = true;
try {
const { start, end } = getDateRange(selectedPeriod);
- const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily';
const [summaryRes, modelsRes, usersRes, dailyRes, tokensRes] = await Promise.all([
getSummary(localStorage.token, start, end, selectedGroupId),
getModelAnalytics(localStorage.token, start, end, selectedGroupId),
getUserAnalytics(localStorage.token, start, end, 50, selectedGroupId),
- getDailyStats(localStorage.token, start, end, granularity, selectedGroupId),
+ getDailyStats(localStorage.token, start, end, granularity, selectedGroupId, selectedMetric),
getTokenUsage(localStorage.token, start, end, selectedGroupId)
]);
@@ -181,6 +184,7 @@
customStart;
customEnd;
selectedGroupId;
+ selectedMetric;
loadDashboard();
}
@@ -354,7 +358,7 @@
>
-
+
{#if dailyStats.length > 1}
{@const allModels = [...new Set(dailyStats.flatMap((d) => Object.keys(d.models || {})))]}
{@const topModels = allModels.slice(0, 8)}
@@ -370,9 +374,26 @@
]}
{@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
-
- {selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')}
-
+
{label}
diff --git a/src/lib/components/admin/Settings/Apps/AppModal.svelte b/src/lib/components/admin/Settings/Apps/AppModal.svelte
index e5def45c87..39c5965d88 100644
--- a/src/lib/components/admin/Settings/Apps/AppModal.svelte
+++ b/src/lib/components/admin/Settings/Apps/AppModal.svelte
@@ -459,7 +459,7 @@
-
+
{$i18n.t('{{COUNT}} selected', {
@@ -467,6 +467,7 @@
})}