implement date picker and additional columns for analytics (#25922)
Co-authored-by: Tim Baek <tim@openwebui.com>
This commit is contained in:
1 parent
e473ab1231
commit
6cd0ba0b6b
3 files changed
+152
-5
No files matched your search
@@ -3,6 +3,8 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Optional
|
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.internal.db import Base, get_async_db_context
|
||||||
from open_webui.utils.response import normalize_usage
|
from open_webui.utils.response import normalize_usage
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
@@ -440,6 +442,44 @@ class ChatMessageTable:
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return {row.model_id: row.count for row in result.all()}
|
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(
|
async def get_token_usage_by_model(
|
||||||
self,
|
self,
|
||||||
start_date: Optional[int] = None,
|
start_date: Optional[int] = None,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ router = APIRouter()
|
|||||||
class ModelAnalyticsEntry(BaseModel):
|
class ModelAnalyticsEntry(BaseModel):
|
||||||
model_id: str
|
model_id: str
|
||||||
count: int
|
count: int
|
||||||
|
unique_users: int = 0
|
||||||
|
unique_chats: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ModelAnalyticsResponse(BaseModel):
|
class ModelAnalyticsResponse(BaseModel):
|
||||||
@@ -65,8 +67,16 @@ async def get_model_analytics(
|
|||||||
counts = await ChatMessages.get_message_count_by_model(
|
counts = await ChatMessages.get_message_count_by_model(
|
||||||
start_date=start_date, end_date=end_date, group_id=group_id, db=db
|
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 = [
|
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])
|
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
|
||||||
]
|
]
|
||||||
return ModelAnalyticsResponse(models=models)
|
return ModelAnalyticsResponse(models=models)
|
||||||
|
|||||||
@@ -24,12 +24,20 @@
|
|||||||
// Time period - persist in localStorage
|
// Time period - persist in localStorage
|
||||||
let selectedPeriod =
|
let selectedPeriod =
|
||||||
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
|
(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 = [
|
$: periods = [
|
||||||
{ value: '24h', label: $i18n.t('Last 24 hours') },
|
{ value: '24h', label: $i18n.t('Last 24 hours') },
|
||||||
{ value: '7d', label: $i18n.t('Last 7 days') },
|
{ value: '7d', label: $i18n.t('Last 7 days') },
|
||||||
{ value: '30d', label: $i18n.t('Last 30 days') },
|
{ value: '30d', label: $i18n.t('Last 30 days') },
|
||||||
{ value: '90d', label: $i18n.t('Last 90 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
|
// User group filter
|
||||||
@@ -48,6 +56,14 @@
|
|||||||
return { start: now - 30 * day, end: now };
|
return { start: now - 30 * day, end: now };
|
||||||
case '90d':
|
case '90d':
|
||||||
return { start: now - 90 * day, end: now };
|
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:
|
default:
|
||||||
return { start: null, end: null };
|
return { start: null, end: null };
|
||||||
}
|
}
|
||||||
@@ -55,7 +71,13 @@
|
|||||||
|
|
||||||
// Data
|
// Data
|
||||||
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
|
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 userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
|
||||||
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
|
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
|
||||||
let tokenStats: Record<
|
let tokenStats: Record<
|
||||||
@@ -140,7 +162,13 @@
|
|||||||
loading = false;
|
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();
|
loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +191,16 @@
|
|||||||
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
|
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
|
||||||
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
|
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;
|
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +224,12 @@
|
|||||||
$: if (typeof localStorage !== 'undefined' && selectedPeriod) {
|
$: if (typeof localStorage !== 'undefined' && selectedPeriod) {
|
||||||
localStorage.setItem('analyticsPeriod', selectedPeriod);
|
localStorage.setItem('analyticsPeriod', selectedPeriod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist custom date range
|
||||||
|
$: if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('analyticsCustomStart', customStart);
|
||||||
|
localStorage.setItem('analyticsCustomEnd', customEnd);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Header with title and period selector -->
|
<!-- Header with title and period selector -->
|
||||||
@@ -207,6 +251,21 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{/if}
|
{/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
|
<select
|
||||||
bind:value={selectedPeriod}
|
bind:value={selectedPeriod}
|
||||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||||
@@ -337,6 +396,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</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
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||||
@@ -399,6 +494,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</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"
|
<td class="px-3 py-1 text-right"
|
||||||
>{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td
|
>{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td
|
||||||
>
|
>
|
||||||
@@ -411,7 +508,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{#if sortedModels.length === 0}
|
{#if sortedModels.length === 0}
|
||||||
<tr
|
<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
|
>{$i18n.t('No data')}</td
|
||||||
></tr
|
></tr
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in new issue
Block a user