mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-29 06:32:52 +00:00
refac
This commit is contained in:
parent
a26dda3eaf
commit
06c17834ee
2 changed files with 1026 additions and 402 deletions
File diff suppressed because it is too large
Load diff
479
src/lib/components/admin/Analytics/UsageChart.svelte
Normal file
479
src/lib/components/admin/Analytics/UsageChart.svelte
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
<script context="module" lang="ts">
|
||||
export type UsageBucket = { date: string; models: Record<string, number> };
|
||||
|
||||
export const usageShare = (value: number, total: number) => {
|
||||
if (total <= 0 || value <= 0) return '0%';
|
||||
const share = (value / total) * 100;
|
||||
return share < 0.1 ? '<0.1%' : `${share.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const chartSeries = (data: UsageBucket[], modelId: string | null = null) =>
|
||||
data
|
||||
.map((bucket) => ({
|
||||
date: bucket.date,
|
||||
models: modelId ? { [modelId]: bucket.models[modelId] ?? 0 } : bucket.models,
|
||||
// Calendar keys have no timezone. UTC is only a plotting coordinate, not a query timestamp.
|
||||
time: Date.parse(
|
||||
`${bucket.date.length === 10 ? `${bucket.date}T00:00` : bucket.date.replace(' ', 'T')}Z`
|
||||
),
|
||||
value: modelId
|
||||
? (bucket.models[modelId] ?? 0)
|
||||
: Object.values(bucket.models).reduce((total, value) => total + value, 0)
|
||||
}))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
export const chartCeiling = (max: number) => {
|
||||
if (max <= 4) return 4;
|
||||
const step = 10 ** Math.floor(Math.log10(max));
|
||||
return Math.ceil(max / step) * step;
|
||||
};
|
||||
|
||||
export const modelTotals = (data: UsageBucket[]) => {
|
||||
const totals: Record<string, number> = Object.create(null);
|
||||
for (const bucket of data) {
|
||||
for (const [id, value] of Object.entries(bucket.models))
|
||||
totals[id] = (totals[id] ?? 0) + value;
|
||||
}
|
||||
return totals;
|
||||
};
|
||||
|
||||
// Identity-based colors remain stable when the range, metric, or ranking changes.
|
||||
export const modelColor = (id: string) => {
|
||||
let hash = 0;
|
||||
for (const character of id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0;
|
||||
return `hsl(${((hash % 360) + 360) % 360} 65% 52%)`;
|
||||
};
|
||||
|
||||
export const intervalMinutes = {
|
||||
'10m': 10,
|
||||
hourly: 60,
|
||||
'3h': 180,
|
||||
'6h': 360,
|
||||
daily: 1440
|
||||
} as const;
|
||||
export type UsageInterval = keyof typeof intervalMinutes;
|
||||
|
||||
export const defaultInterval = (period: string): UsageInterval =>
|
||||
period === '24h' ? '10m' : period === '7d' ? 'hourly' : period === '30d' ? '6h' : 'daily';
|
||||
|
||||
export const aggregateUsage = (data: UsageBucket[], interval: UsageInterval): UsageBucket[] => {
|
||||
if (interval !== '3h' && interval !== '6h') return data;
|
||||
const hours = intervalMinutes[interval] / 60;
|
||||
const buckets = new Map<string, Record<string, number>>();
|
||||
for (const point of data) {
|
||||
// Aggregate the server's calendar keys without converting their timezone.
|
||||
const hour = Math.floor(Number(point.date.slice(11, 13)) / hours) * hours;
|
||||
const date = `${point.date.slice(0, 10)} ${String(hour).padStart(2, '0')}:00`;
|
||||
const models = buckets.get(date) ?? Object.create(null);
|
||||
for (const [id, value] of Object.entries(point.models))
|
||||
models[id] = (models[id] ?? 0) + value;
|
||||
buckets.set(date, models);
|
||||
}
|
||||
return [...buckets]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, models]) => ({ date, models }));
|
||||
};
|
||||
|
||||
export const intervalBreakdown = (
|
||||
models: Record<string, number>,
|
||||
modelIds: string[],
|
||||
modelId: string | null = null
|
||||
) => (modelId === null ? modelIds : [modelId]).map((id) => ({ id, value: models[id] ?? 0 }));
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import tippy, { type Instance } from 'tippy.js';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export let data: UsageBucket[] = [];
|
||||
export let granularity: UsageInterval = 'daily';
|
||||
export let metricLabel = '';
|
||||
export let modelNames: Record<string, string> = {};
|
||||
export let selectedModel: string | null = null;
|
||||
export let view: 'volume' | 'activity' = 'volume';
|
||||
const i18n = getContext<any>('i18n');
|
||||
let active: number | null = null;
|
||||
let keyboardIndex = 0;
|
||||
let plot: HTMLDivElement;
|
||||
let chartWidth = 0;
|
||||
let tooltip: Instance | undefined;
|
||||
let tooltipContent: HTMLDivElement;
|
||||
let tooltipId = '';
|
||||
let anchor: HTMLElement | null = null;
|
||||
let activeModel: string | null = null;
|
||||
$: {
|
||||
data;
|
||||
selectedModel;
|
||||
view;
|
||||
tooltip?.hide();
|
||||
active = null;
|
||||
activeModel = null;
|
||||
keyboardIndex = 0;
|
||||
}
|
||||
$: totals = modelTotals(data);
|
||||
$: ids = Object.keys(totals).sort((a, b) => totals[b] - totals[a] || a.localeCompare(b));
|
||||
$: visibleIds = selectedModel ? [selectedModel] : ids;
|
||||
$: series = chartSeries(data, selectedModel);
|
||||
$: ceiling = chartCeiling(series.reduce((max, point) => Math.max(max, point.value), 0));
|
||||
$: step = intervalMinutes[granularity] * 60000;
|
||||
$: start = series[0]?.time ?? 0;
|
||||
$: span = Math.max(step, (series[series.length - 1]?.time ?? start) - start + step);
|
||||
$: barWidth = Math.min((100 * step) / span, 12);
|
||||
$: hovered = active !== null ? series[active] : null;
|
||||
$: tooltipRows = hovered ? intervalBreakdown(hovered.models, visibleIds, activeModel) : [];
|
||||
$: hoveredModels = hovered ? data.find((bucket) => bucket.date === hovered.date)?.models : null;
|
||||
$: labels = [
|
||||
...new Set(
|
||||
chartWidth >= 480
|
||||
? [0, Math.floor((series.length - 1) / 2), series.length - 1]
|
||||
: [0, series.length - 1]
|
||||
)
|
||||
].filter((i) => i >= 0 && i < series.length);
|
||||
$: lanes = visibleIds.map((id) => {
|
||||
const values = series.map((point) => point.models[id] ?? 0);
|
||||
return { id, peak: Math.max(0, ...values), values };
|
||||
});
|
||||
$: stacks = series.map((point) => visibleIds.filter((id) => (point.models[id] ?? 0) > 0));
|
||||
const name = (id: string) => modelNames[id] || id;
|
||||
const number = (value: number) =>
|
||||
new Intl.NumberFormat($i18n.language, { notation: 'compact', maximumFractionDigits: 1 }).format(
|
||||
value
|
||||
);
|
||||
const dateLabel = (date: string) =>
|
||||
dayjs(date)
|
||||
.locale($i18n.language)
|
||||
.format(granularity === 'daily' ? 'MMM D' : 'MMM D, HH:mm');
|
||||
const position = (time: number) => ((time - start + step / 2) / span) * 100;
|
||||
function navigate(event: KeyboardEvent, index: number) {
|
||||
const buttons = plot.querySelectorAll<HTMLButtonElement>('button[data-point]');
|
||||
const destinations: Record<string, number> = {
|
||||
ArrowRight: index + 1,
|
||||
ArrowLeft: index - 1,
|
||||
Home: 0,
|
||||
End: buttons.length - 1
|
||||
};
|
||||
if (view === 'activity') {
|
||||
destinations.ArrowDown = index + series.length;
|
||||
destinations.ArrowUp = index - series.length;
|
||||
}
|
||||
const next = destinations[event.key];
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
keyboardIndex = Math.max(0, Math.min(buttons.length - 1, next));
|
||||
buttons[keyboardIndex]?.focus();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
tooltip = tippy(plot, {
|
||||
content: tooltipContent,
|
||||
trigger: 'manual',
|
||||
theme: 'dark',
|
||||
arrow: false,
|
||||
placement: 'right-start',
|
||||
offset: [0, 12],
|
||||
popperOptions: {
|
||||
modifiers: [{ name: 'flip', options: { fallbackPlacements: ['left-start'] } }]
|
||||
},
|
||||
duration: 0,
|
||||
interactive: true,
|
||||
maxWidth: 'min(20rem, calc(100vw - 1rem))',
|
||||
appendTo: () => document.body,
|
||||
aria: { content: null, expanded: false },
|
||||
onHidden: () => {
|
||||
active = null;
|
||||
activeModel = null;
|
||||
},
|
||||
getReferenceClientRect: () => {
|
||||
const rect = (anchor ?? plot).getBoundingClientRect();
|
||||
const height = tooltip?.popper.offsetHeight || tooltipContent.offsetHeight;
|
||||
return new DOMRect(
|
||||
rect.x + rect.width / 2,
|
||||
plot.getBoundingClientRect().top - height / 2,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
});
|
||||
tooltipId = tooltip.popper.id;
|
||||
return () => tooltip?.destroy();
|
||||
});
|
||||
async function showTooltip(
|
||||
event: MouseEvent | FocusEvent,
|
||||
index: number,
|
||||
modelId: string | null = null
|
||||
) {
|
||||
anchor = event.currentTarget as HTMLElement;
|
||||
activeModel = modelId;
|
||||
active = index;
|
||||
await tick();
|
||||
if (
|
||||
active !== index ||
|
||||
activeModel !== modelId ||
|
||||
!anchor?.isConnected ||
|
||||
tooltip?.state.isDestroyed
|
||||
)
|
||||
return;
|
||||
tooltip?.clearDelayTimeouts();
|
||||
tooltip?.show();
|
||||
tooltip?.popperInstance?.update();
|
||||
}
|
||||
function leaveTooltip(event: MouseEvent) {
|
||||
tooltip?.hideWithInteractivity(event);
|
||||
}
|
||||
function hideTooltip() {
|
||||
tooltip?.hide();
|
||||
active = null;
|
||||
activeModel = null;
|
||||
}
|
||||
const intervalLabel = (date: string) => {
|
||||
const start = dayjs(date).locale($i18n.language);
|
||||
if (granularity === 'daily') return start.format('MMM D, YYYY');
|
||||
const end = start.add(intervalMinutes[granularity], 'minute');
|
||||
return `${start.format('MMM D, YYYY · HH:mm')}–${end.format(start.isSame(end, 'day') ? 'HH:mm' : 'MMM D, HH:mm')}`;
|
||||
};
|
||||
|
||||
function isolate(id: string | null) {
|
||||
selectedModel = selectedModel === id ? null : id;
|
||||
active = null;
|
||||
keyboardIndex = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown|capture={(event) => {
|
||||
if (event.key === 'Escape' && tooltip?.state.isVisible) {
|
||||
event.stopPropagation();
|
||||
hideTooltip();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:clientWidth={chartWidth}
|
||||
class="min-w-0 text-xs font-normal text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<div
|
||||
class="h-5 mb-2 flex min-w-0 items-center justify-between gap-3 text-[0.6875rem] tabular-nums"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
title={hovered ? dateLabel(hovered.date) : selectedModel ? name(selectedModel) : undefined}
|
||||
>{hovered
|
||||
? dateLabel(hovered.date)
|
||||
: selectedModel
|
||||
? name(selectedModel)
|
||||
: $i18n.t('All models')}</span
|
||||
>
|
||||
<span class="shrink-0 whitespace-nowrap"
|
||||
>{hovered
|
||||
? `${hovered.value.toLocaleString($i18n.language)} ${metricLabel.toLocaleLowerCase($i18n.language)}`
|
||||
: metricLabel}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
bind:this={plot}
|
||||
class="relative"
|
||||
role="group"
|
||||
aria-label={`${metricLabel} — ${$i18n.t('Use arrow keys to explore the chart')}`}
|
||||
>
|
||||
{#if view === 'volume'}
|
||||
<div class="relative h-40 pl-12">
|
||||
{#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as tick}
|
||||
<div
|
||||
class="absolute inset-x-0 flex items-center gap-2 pointer-events-none translate-y-1/2"
|
||||
style:bottom={`${(tick / ceiling) * 100}%`}
|
||||
>
|
||||
<span class="w-10 shrink-0 text-right text-[0.6875rem] tabular-nums"
|
||||
>{number(tick)}</span
|
||||
>
|
||||
<span class="h-px flex-1 bg-gray-100 dark:bg-gray-850/50"></span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="relative h-full">
|
||||
{#each series as point, index (point.date)}
|
||||
<button
|
||||
type="button"
|
||||
data-point
|
||||
class="absolute bottom-0 top-0 flex justify-center items-end focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
|
||||
style:left={`${position(point.time)}%`}
|
||||
style:width={`${barWidth}%`}
|
||||
style:transform="translateX(-50%)"
|
||||
tabindex={keyboardIndex === index ? 0 : -1}
|
||||
aria-describedby={active === index && activeModel === null ? tooltipId : undefined}
|
||||
aria-label={`${dateLabel(point.date)}: ${point.value.toLocaleString($i18n.language)} ${metricLabel}. ${stacks[index].map((id) => `${name(id)}: ${point.models[id].toLocaleString($i18n.language)}`).join(', ')}`}
|
||||
on:mouseenter={(event) => showTooltip(event, index)}
|
||||
on:mouseleave={leaveTooltip}
|
||||
on:focus={(event) => {
|
||||
keyboardIndex = index;
|
||||
showTooltip(event, index);
|
||||
}}
|
||||
on:blur={hideTooltip}
|
||||
on:click={(event) => showTooltip(event, index)}
|
||||
on:keydown={(event) => navigate(event, index)}
|
||||
>
|
||||
<span
|
||||
class="w-3/4 max-w-12 h-full flex flex-col-reverse justify-start"
|
||||
style:opacity={active === null || active === index ? 1 : 0.45}
|
||||
>
|
||||
{#each stacks[index] as id}
|
||||
<span
|
||||
class="block shrink-0 w-full"
|
||||
style:height={`${(point.models[id] / ceiling) * 100}%`}
|
||||
style:background-color={modelColor(id)}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each lanes as lane, row (lane.id)}
|
||||
<div class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-left truncate py-1"
|
||||
title={name(lane.id)}
|
||||
aria-pressed={selectedModel === lane.id}
|
||||
on:click={() => isolate(lane.id)}>{name(lane.id)}</button
|
||||
>
|
||||
<div class="relative h-6 bg-gray-100/70 dark:bg-gray-850/40">
|
||||
{#each series as point, index (point.date)}
|
||||
{@const cellIndex = row * series.length + index}
|
||||
<button
|
||||
type="button"
|
||||
data-point
|
||||
class="absolute inset-y-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
|
||||
style:left={`${position(point.time)}%`}
|
||||
style:width={`${(100 * step) / span}%`}
|
||||
style:transform="translateX(-50%)"
|
||||
tabindex={keyboardIndex === cellIndex ? 0 : -1}
|
||||
aria-label={`${name(lane.id)}, ${dateLabel(point.date)}: ${lane.values[index].toLocaleString($i18n.language)} ${metricLabel}`}
|
||||
aria-describedby={active === index && activeModel === lane.id
|
||||
? tooltipId
|
||||
: undefined}
|
||||
on:mouseenter={(event) => showTooltip(event, index, lane.id)}
|
||||
on:mouseleave={leaveTooltip}
|
||||
on:focus={(event) => {
|
||||
keyboardIndex = cellIndex;
|
||||
showTooltip(event, index, lane.id);
|
||||
}}
|
||||
on:blur={hideTooltip}
|
||||
on:click={(event) => showTooltip(event, index, lane.id)}
|
||||
on:keydown={(event) => navigate(event, cellIndex)}
|
||||
>
|
||||
<span
|
||||
class="block absolute inset-0"
|
||||
style:background-color={modelColor(lane.id)}
|
||||
style:opacity={lane.values[index] > 0
|
||||
? 0.25 + (0.75 * lane.values[index]) / lane.peak
|
||||
: 0}
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hovered}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10"
|
||||
style:left={view === 'volume' ? '3rem' : '7.5rem'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 w-px bg-gray-400/70 dark:bg-gray-300/70"
|
||||
style:left={`${position(hovered.time)}%`}
|
||||
style:transform="translateX(-50%)"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="relative mt-2 h-4 text-[0.625rem] tabular-nums"
|
||||
style:margin-left={view === 'volume' ? '3rem' : '7.5rem'}
|
||||
>
|
||||
{#each labels as index}
|
||||
<span
|
||||
class="absolute whitespace-nowrap"
|
||||
style:left={`${position(series[index].time)}%`}
|
||||
style:transform={series.length === 1
|
||||
? 'translateX(-50%)'
|
||||
: index === 0
|
||||
? 'none'
|
||||
: index === series.length - 1
|
||||
? 'translateX(-100%)'
|
||||
: 'translateX(-50%)'}>{dateLabel(series[index].date)}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.6875rem]">
|
||||
<button
|
||||
type="button"
|
||||
class="py-1"
|
||||
class:text-gray-900={selectedModel === null}
|
||||
class:dark:text-gray-200={selectedModel === null}
|
||||
aria-pressed={selectedModel === null}
|
||||
on:click={() => isolate(null)}>{$i18n.t('All models')}</button
|
||||
>
|
||||
{#each ids as id (id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 max-w-full items-center gap-1.5 py-1 text-left hover:text-gray-900 dark:hover:text-gray-100"
|
||||
style:opacity={selectedModel && selectedModel !== id ? 0.45 : 1}
|
||||
aria-label={`${name(id)} — ${$i18n.t('Select to isolate model')}`}
|
||||
aria-pressed={selectedModel === id}
|
||||
on:click={() => isolate(id)}
|
||||
>
|
||||
<span class="size-2 shrink-0" style:background-color={modelColor(id)}></span>
|
||||
<span class="max-w-40 truncate" title={name(id)}>{name(id)}</span>
|
||||
<span
|
||||
class="w-14 shrink-0 text-right tabular-nums text-gray-400 dark:text-gray-500"
|
||||
title={(hovered ? (hoveredModels?.[id] ?? 0) : totals[id]).toLocaleString($i18n.language)}
|
||||
>{number(hovered ? (hoveredModels?.[id] ?? 0) : totals[id])}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-500">
|
||||
{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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden">
|
||||
<div
|
||||
bind:this={tooltipContent}
|
||||
class="w-64 max-w-full py-0.5 text-[0.625rem] leading-4 font-normal text-gray-100"
|
||||
>
|
||||
{#if hovered}
|
||||
<div class="truncate text-gray-400" title={intervalLabel(hovered.date)}>
|
||||
{intervalLabel(hovered.date)}
|
||||
</div>
|
||||
<div class="mt-1 max-h-48 overflow-y-auto scrollbar-hidden space-y-0.5">
|
||||
{#each tooltipRows as row (row.id)}
|
||||
<div
|
||||
class="flex h-4 items-center justify-between gap-3"
|
||||
class:text-gray-500={row.value === 0}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1.5"
|
||||
><span class="size-1.5 shrink-0" style:background-color={modelColor(row.id)}
|
||||
></span><span class="min-w-0 truncate" title={name(row.id)}>{name(row.id)}</span
|
||||
></span
|
||||
>
|
||||
<span class="shrink-0 whitespace-nowrap tabular-nums"
|
||||
>{row.value.toLocaleString($i18n.language)}
|
||||
<span class="text-gray-400">({usageShare(row.value, hovered.value)})</span></span
|
||||
>
|
||||
</div>
|
||||
{:else}<div class="text-gray-400">{$i18n.t('No activity in this period')}</div>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Reference in a new issue