From 131ddc32ecaeb06f9aa8740ff004710bacef72e0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 11:07:10 +0900 Subject: [PATCH] refac --- backend/open_webui/models/chat_messages.py | 103 ++--- backend/open_webui/routers/analytics.py | 23 +- src/lib/apis/analytics/index.ts | 6 +- .../admin/Analytics/ChartLine.svelte | 14 +- .../admin/Analytics/Dashboard.svelte | 33 +- .../admin/Settings/AdminSettingRow.svelte | 2 +- .../admin/Settings/Apps/AppModal.svelte | 3 +- .../components/admin/Settings/Database.svelte | 22 +- .../admin/Settings/Documents.svelte | 8 + .../admin/Settings/Pipelines.svelte | 2 + .../components/chat/Settings/Account.svelte | 6 +- .../chat/Settings/DataControls.svelte | 12 + .../components/chat/Settings/General.svelte | 2 + .../chat/Settings/UserSettingRow.svelte | 2 +- src/lib/components/chat/SettingsModal.svelte | 85 ++-- .../common/InterfaceSettings.svelte | 416 +++++++++++++----- src/lib/i18n/locales/en-US/translation.json | 5 + 17 files changed, 499 insertions(+), 245 deletions(-) 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 @@ })} - diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 7f5c4dd93a..a2809beac9 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -1370,10 +1370,12 @@ - -
-
+
+ +
- - +
+ {#if !$mobile || showSearch || search} +
+
+ +
+ + +
+ {/if} +
{search ? filteredSettings.length diff --git a/src/lib/components/common/InterfaceSettings.svelte b/src/lib/components/common/InterfaceSettings.svelte index e92a0cb439..5652531fea 100644 --- a/src/lib/components/common/InterfaceSettings.svelte +++ b/src/lib/components/common/InterfaceSettings.svelte @@ -119,8 +119,9 @@ let fontFamilyInput = ''; let showTextScaleSlider = false; let showFontFamilyInput = false; - const settingRowClass = 'flex items-center justify-between gap-2.5'; - const settingLabelClass = 'min-w-0 text-xs text-gray-600 dark:text-gray-400'; + const settingRowClass = 'group/setting-row flex items-center justify-between gap-2.5'; + const settingLabelClass = + 'min-w-0 text-xs text-gray-600 group-has-[button[id$=-control]:enabled]/setting-row:cursor-pointer group-has-[button[id$=-control]:disabled]/setting-row:cursor-not-allowed transition-colors duration-150 group-hover/setting-row:text-gray-900 group-focus-within/setting-row:text-gray-900 dark:text-gray-400 dark:group-hover/setting-row:text-white dark:group-focus-within/setting-row:text-white'; const settingControlClass = 'flex shrink-0 items-center justify-end gap-1.5'; const sectionHeadingClass = 'mt-4 text-xs text-gray-600 dark:text-gray-500'; const firstSectionHeadingClass = 'text-xs text-gray-600 dark:text-gray-500'; @@ -460,12 +461,13 @@
-