enh: analytics

This commit is contained in:
Timothy Jaeryang Baek
2026-02-05 00:00:49 -06:00
parent 0e60c757ce
commit e62649f940
4 changed files with 95 additions and 28 deletions

View File

@@ -312,10 +312,12 @@ class ChatMessageTable:
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
@@ -329,6 +331,9 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
return {row.model_id: row.count for row in results}
@@ -337,11 +342,13 @@ class ChatMessageTable:
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by model using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
from open_webui.models.groups import GroupMember
dialect = db.bind.dialect.name
@@ -379,6 +386,9 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
@@ -455,10 +465,12 @@ class ChatMessageTable:
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
@@ -468,6 +480,9 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.user_id).all()
return {row.user_id: row.count for row in results}
@@ -476,10 +491,12 @@ class ChatMessageTable:
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
@@ -489,6 +506,9 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.chat_id).all()
return {row.chat_id: row.count for row in results}
@@ -497,11 +517,13 @@ class ChatMessageTable:
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by day and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
@@ -513,6 +535,9 @@ class ChatMessageTable:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery()
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.all()

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.models.feedbacks import Feedbacks
from open_webui.utils.auth import get_admin_user
@@ -56,12 +57,13 @@ class UserAnalyticsResponse(BaseModel):
async def get_model_analytics(
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"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts per model."""
counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [
ModelAnalyticsEntry(model_id=model_id, count=count)
@@ -74,15 +76,14 @@ async def get_model_analytics(
async def get_user_analytics(
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"),
limit: int = Query(50, description="Max users to return"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts and token usage per user with user info."""
from open_webui.models.users import Users
counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
token_usage = ChatMessages.get_token_usage_by_user(
start_date=start_date, end_date=end_date, db=db
@@ -153,18 +154,19 @@ class SummaryResponse(BaseModel):
async def get_summary(
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"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get summary statistics for the dashboard."""
model_counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
user_counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
chat_counts = ChatMessages.get_message_count_by_chat(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return SummaryResponse(
@@ -188,6 +190,7 @@ class DailyStatsResponse(BaseModel):
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'"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
@@ -199,7 +202,7 @@ async def get_daily_stats(
)
else:
counts = ChatMessages.get_daily_message_counts_by_model(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return DailyStatsResponse(
data=[
@@ -228,12 +231,13 @@ class TokenUsageResponse(BaseModel):
async def get_token_usage(
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get token usage aggregated by model."""
usage = ChatMessages.get_token_usage_by_model(
start_date=start_date, end_date=end_date, db=db
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [

View File

@@ -3,13 +3,15 @@ import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getModelAnalytics = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
endDate: number | null = null,
groupId: string | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models?${searchParams.toString()}`, {
method: 'GET',
@@ -40,7 +42,8 @@ export const getUserAnalytics = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
limit: number = 50
limit: number = 50,
groupId: string | null = null
) => {
let error = null;
@@ -48,6 +51,7 @@ export const getUserAnalytics = async (
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (limit) searchParams.append('limit', limit.toString());
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/users?${searchParams.toString()}`, {
method: 'GET',
@@ -123,13 +127,15 @@ export const getMessages = async (
export const getSummary = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
endDate: number | null = null,
groupId: string | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/summary?${searchParams.toString()}`, {
method: 'GET',
@@ -160,7 +166,8 @@ export const getDailyStats = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null,
granularity: 'hourly' | 'daily' = 'daily'
granularity: 'hourly' | 'daily' = 'daily',
groupId: string | null = null
) => {
let error = null;
@@ -168,6 +175,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);
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, {
method: 'GET',
@@ -197,13 +205,15 @@ export const getDailyStats = async (
export const getTokenUsage = async (
token: string = '',
startDate: number | null = null,
endDate: number | null = null
endDate: number | null = null,
groupId: string | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/tokens?${searchParams.toString()}`, {
method: 'GET',

View File

@@ -2,6 +2,7 @@
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import { getSummary, getModelAnalytics, getUserAnalytics, getDailyStats, getTokenUsage } from '$lib/apis/analytics';
import { getGroups } from '$lib/apis/groups';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
@@ -24,6 +25,10 @@
{ value: 'all', label: 'All time' }
];
// User group filter
let groups: Array<{ id: string; name: string }> = [];
let selectedGroupId: string | null = null;
const getDateRange = (period: string): { start: number | null; end: number | null } => {
const now = Math.floor(Date.now() / 1000);
const day = 86400;
@@ -80,11 +85,11 @@
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),
getModelAnalytics(localStorage.token, start, end),
getUserAnalytics(localStorage.token, start, end, 50),
getDailyStats(localStorage.token, start, end, granularity),
getTokenUsage(localStorage.token, start, end)
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),
getTokenUsage(localStorage.token, start, end, selectedGroupId)
]);
summary = summaryRes ?? summary;
@@ -120,10 +125,20 @@
loading = false;
};
$: if (selectedPeriod) {
$: if (selectedPeriod || selectedGroupId !== undefined) {
loadDashboard();
}
onMount(async () => {
// Load groups for filter
try {
const res = await getGroups(localStorage.token);
groups = res ?? [];
} catch (e) {
console.error('Failed to load groups:', e);
}
});
$: sortedModels = [...modelStats].sort((a, b) => {
if (modelOrderBy === 'name') {
return modelDirection === 'asc'
@@ -159,14 +174,27 @@
<div class="text-lg font-medium px-0.5">
{$i18n.t('Analytics')}
</div>
<select
bind:value={selectedPeriod}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
{#each periods as period}
<option value={period.value}>{$i18n.t(period.label)}</option>
{/each}
</select>
<div class="flex items-center gap-2">
{#if groups.length > 0}
<select
bind:value={selectedGroupId}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
<option value={null}>{$i18n.t('All Users')}</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
{/if}
<select
bind:value={selectedPeriod}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
{#each periods as period}
<option value={period.value}>{$i18n.t(period.label)}</option>
{/each}
</select>
</div>
</div>
<!-- Model Details Modal -->