diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index 97331f1ec3..573019d8d3 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -1,5 +1,6 @@ - { - if (event.key === 'Escape') tokenTooltip?.hide(); - }} -/> - -
-

+
+

{$i18n.t('Analytics')}

{#if groups.length > 0} @@ -290,12 +347,14 @@ {/if} - - - - + {metric.value} + +
+ {/each} + +
+
+
+ {#each [{ key: 'tokens', label: $i18n.t('Tokens') }, { key: 'messages', label: $i18n.t('Messages') }] as metric} + + {/each} +
+
+
+ {#each [{ key: 'volume', label: $i18n.t('Volume') }, { key: 'activity', label: $i18n.t('Activity') }] as view} + + {/each} +
+ +
- {/if} + {#if dailyStats.length && summary.total_messages > 0} + + {:else}
+ {$i18n.t('No activity in this period')} +
{/if} +
{/if} -{#if loading} +{#if !validRange} +
+ {$i18n.t('Choose a valid start and end date.')} +
+{:else if loadError} + +{:else if loading}
{:else} -
-
- {#each [{ key: 'models', label: $i18n.t('Models') }, { key: 'users', label: $i18n.t('Users') }] as tab} - - {/each} +
+
+
+ {#each [{ key: 'models', label: $i18n.t('Models') }, { key: 'users', label: $i18n.t('Users') }] as tab} + + {/each} +
-
-
- {#if selectedLeaderboard === 'models'} - - -
-
- - - - - - - - - - {#each tokenColumns as column} +
+ {#if selectedLeaderboard === 'models'} + + +
+
+
# toggleModelSort('name')} - > -
- {$i18n.t('Model')} - {#if modelOrderBy === 'name'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('count')} - > -
- {$i18n.t('Messages')} - {#if modelOrderBy === 'count'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('users')} - > -
- {$i18n.t('Users')} - {#if modelOrderBy === 'users'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleModelSort('chats')} - > -
- {$i18n.t('Chats')} - {#if modelOrderBy === 'chats'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
+ + + - {/each} - - - - - {#each sortedModels as model, idx (model.model_id)} - { - selectedModel = { id: model.model_id, name: model.name }; - showModelModal = true; - }} - > - - - - - - {#each tokenColumns as column} - - {/each} - - - {/each} - {#if sortedModels.length === 0} - - {/if} - -
# toggleModelSort(column.key)} + class="font-normal px-2.5 py-1.5 cursor-pointer select-none" + on:click={() => toggleModelSort('name')} > -
- {column.label} - {#if modelOrderBy === column.key} +
+ {$i18n.t('Model')} + {#if modelOrderBy === 'name'} {#if modelDirection === 'asc'}
toggleModelSort('percentage')} - > -
- % - {#if modelOrderBy === 'percentage'} - - {#if modelDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
{idx + 1} -
- {model.name} { - // LICENSE covers this Open WebUI fallback logo. - // Do not alter, remove, obscure, or replace it except as LICENSE permits: - // https://docs.openwebui.com/license. - e.target.src = '/favicon.png'; - }} - /> - {model.name} -
-
{model.count.toLocaleString()}{(model.unique_users ?? 0).toLocaleString()}{(model.unique_chats ?? 0).toLocaleString()}{formatNumber(tokenStats[model.model_id]?.[column.key] ?? 0)} - {totalModelMessages > 0 - ? ((model.count / totalModelMessages) * 100).toFixed(1) - : 0}% -
{$i18n.t('No data')}
-
-
- {:else} - - -
-
- - - - - - - - {#each tokenColumns as column} + + + + {#each tokenColumns as column} + + {/each} + + + + + {#each sortedModels as model, idx (model.model_id)} + {@const value = + selectedMetric === 'tokens' + ? (tokenStats[model.model_id]?.total_tokens ?? 0) + : model.count} + { + selectedModel = { id: model.model_id, name: model.name }; + showModelModal = true; + }} + > + + + + + + {#each tokenColumns as column} + + {/each} + + + {/each} + {#if sortedModels.length === 0} + + {/if} + +
# toggleUserSort('name')} - > -
- {$i18n.t('User')} - {#if userOrderBy === 'name'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleUserSort('count')} - > -
- {$i18n.t('Messages')} - {#if userOrderBy === 'count'} - - {#if userDirection === 'asc'}{:else}{/if} - - {:else} - - {/if} -
-
toggleUserSort(column.key)} + class="font-normal px-2.5 py-1.5 cursor-pointer select-none text-right" + on:click={() => toggleModelSort('count')} >
- {column.label} - {#if userOrderBy === column.key} + {$i18n.t('Messages')} + {#if modelOrderBy === 'count'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('users')} + > +
+ {$i18n.t('Users')} + {#if modelOrderBy === 'users'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('chats')} + > +
+ {$i18n.t('Chats')} + {#if modelOrderBy === 'chats'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort(column.key)} + > +
+ {column.label} + {#if modelOrderBy === column.key} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleModelSort('percentage')} + > +
+ {selectedMetric === 'tokens' + ? $i18n.t('Token share') + : $i18n.t('Message share')} + {#if modelOrderBy === 'percentage'} + + {#if modelDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{idx + 1} +
+ {model.name} { + // LICENSE covers this Open WebUI fallback logo. + // Do not alter, remove, obscure, or replace it except as LICENSE permits: + // https://docs.openwebui.com/license. + const image = e.currentTarget as HTMLImageElement; + if (!image.src.endsWith('/favicon.png')) image.src = '/favicon.png'; + }} + /> + {model.name} +
+
{model.count.toLocaleString()}{(model.unique_users ?? 0).toLocaleString()}{(model.unique_chats ?? 0).toLocaleString()}{formatNumber(tokenStats[model.model_id]?.[column.key] ?? 0)} +
+ + {usageShare(value, metricTotal)} +
+
{$i18n.t('No data')}
+
+
+ {:else} + + +
+
+ + + + + - {/each} - - - - {#each sortedUsers as user, idx (user.user_id)} - - - + + {#each tokenColumns as column} - + {/each} + - {/each} - {#if sortedUsers.length === 0} - - {/if} - -
# toggleUserSort('name')} + > +
+ {$i18n.t('User')} + {#if userOrderBy === 'name'} {#if userDirection === 'asc'}
{idx + 1} -
- - {user.name { - e.target.src = '/user.png'; - }} - /> - - {user.name || user.email || user.user_id.substring(0, 8)} +
toggleUserSort('count')} + > +
+ {$i18n.t('Messages')} + {#if userOrderBy === 'count'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if}
- -
{user.count.toLocaleString()}{formatNumber(user[column.key] ?? 0)} toggleUserSort(column.key)} + > +
+ {column.label} + {#if userOrderBy === column.key} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
toggleUserSort('percentage')} + > +
+ {selectedMetric === 'tokens' + ? $i18n.t('Token share') + : $i18n.t('Message share')} + {#if userOrderBy === 'percentage'} + + {#if userDirection === 'asc'}{:else}{/if} + + {:else} + + {/if} +
+
{$i18n.t('No data')}
-
-
- {/if} -
+ + + {#each sortedUsers as user, idx (user.user_id)} + {@const value = + selectedMetric === 'tokens' ? (user.total_tokens ?? 0) : user.count} + + {idx + 1} + +
+ + {user.name { + const image = e.currentTarget as HTMLImageElement; + if (!image.src.endsWith('/user.png')) image.src = '/user.png'; + }} + /> + + {user.name || user.email || user.user_id.substring(0, 8)} +
+ + {user.count.toLocaleString()} + {#each tokenColumns as column} + {formatNumber(user[column.key] ?? 0)} + {/each} -
- ⓘ {$i18n.t('Message counts are based on assistant responses.')} + +
+ + {usageShare(value, metricTotal)} +
+ + + {/each} + {#if sortedUsers.length === 0} + {$i18n.t('No data')} + {/if} + + +
+
+ {/if} +
+
+
+ {$i18n.t('Message counts are based on assistant responses.')} + {$i18n.t('Token counts are estimates and may not reflect actual API usage')}
{/if} + + diff --git a/src/lib/components/admin/Analytics/UsageChart.svelte b/src/lib/components/admin/Analytics/UsageChart.svelte new file mode 100644 index 0000000000..30d4fc4fdc --- /dev/null +++ b/src/lib/components/admin/Analytics/UsageChart.svelte @@ -0,0 +1,479 @@ + + + + + { + if (event.key === 'Escape' && tooltip?.state.isVisible) { + event.stopPropagation(); + hideTooltip(); + } + }} +/> + +
+
+ {hovered + ? dateLabel(hovered.date) + : selectedModel + ? name(selectedModel) + : $i18n.t('All models')} + {hovered + ? `${hovered.value.toLocaleString($i18n.language)} ${metricLabel.toLocaleLowerCase($i18n.language)}` + : metricLabel} +
+
+ {#if view === 'volume'} +
+ {#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as tick} +
+ {number(tick)} + +
+ {/each} +
+ {#each series as point, index (point.date)} + + {/each} +
+
+ {:else} +
+ {#each lanes as lane, row (lane.id)} +
+ +
+ {#each series as point, index (point.date)} + {@const cellIndex = row * series.length + index} + + {/each} +
+
+ {/each} +
+ {/if} + {#if hovered} + + {/if} +
+
+ {#each labels as index} + {dateLabel(series[index].date)} + {/each} +
+
+ + {#each ids as id (id)} + + {/each} +
+

+ {view === 'activity' + ? $i18n.t('Intensity relative to each model’s peak. Blank means no recorded usage.') + : $i18n.t('Select a model to isolate it.')} · {$i18n.t('Server time')} +

+
+ +