Spaces:
Runtime error
Runtime error
| <script lang="ts"> | |
| 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"; | |
| import ChartLine from "./ChartLine.svelte"; | |
| import AnalyticsModelModal from "./AnalyticsModelModal.svelte"; | |
| import Tooltip from "$lib/components/common/Tooltip.svelte"; | |
| import { REXPRO_API_BASE_URL } from "$lib/constants"; | |
| import { formatNumber } from "$lib/utils"; | |
| import { goto } from "$app/navigation"; | |
| const i18n = getContext("i18n"); | |
| // Time period - persist in localStorage | |
| let selectedPeriod = | |
| (typeof localStorage !== "undefined" && | |
| localStorage.getItem("analyticsPeriod")) || | |
| "7d"; | |
| $: periods = [ | |
| { value: "24h", label: $i18n.t("Last 24 hours") }, | |
| { value: "7d", label: $i18n.t("Last 7 days") }, | |
| { value: "30d", label: $i18n.t("Last 30 days") }, | |
| { value: "90d", label: $i18n.t("Last 90 days") }, | |
| { value: "all", label: $i18n.t("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; | |
| switch (period) { | |
| case "24h": | |
| return { start: now - day, end: now }; | |
| case "7d": | |
| return { start: now - 7 * day, end: now }; | |
| case "30d": | |
| return { start: now - 30 * day, end: now }; | |
| case "90d": | |
| return { start: now - 90 * day, end: now }; | |
| default: | |
| return { start: null, end: null }; | |
| } | |
| }; | |
| // Data | |
| 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 userStats: Array<{ | |
| user_id: string; | |
| name?: string; | |
| email?: string; | |
| count: number; | |
| }> = []; | |
| let dailyStats: Array<{ date: string; models: Record<string, number> }> = | |
| []; | |
| let tokenStats: Record< | |
| string, | |
| { input_tokens: number; output_tokens: number; total_tokens: number } | |
| > = {}; | |
| let totalTokens = { input: 0, output: 0, total: 0 }; | |
| let loading = true; | |
| // Selected model for drill-down | |
| let selectedModel: { id: string; name: string } | null = null; | |
| let showModelModal = false; | |
| // Sorting | |
| let modelOrderBy = "count"; | |
| let modelDirection: "asc" | "desc" = "desc"; | |
| let userOrderBy = "count"; | |
| let userDirection: "asc" | "desc" = "desc"; | |
| const toggleModelSort = (key: string) => { | |
| if (modelOrderBy === key) { | |
| modelDirection = modelDirection === "asc" ? "desc" : "asc"; | |
| } else { | |
| modelOrderBy = key; | |
| modelDirection = key === "name" ? "asc" : "desc"; | |
| } | |
| }; | |
| const toggleUserSort = (key: string) => { | |
| if (userOrderBy === key) { | |
| userDirection = userDirection === "asc" ? "desc" : "asc"; | |
| } else { | |
| userOrderBy = key; | |
| userDirection = key === "user_id" ? "asc" : "desc"; | |
| } | |
| }; | |
| const loadDashboard = async () => { | |
| 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, | |
| ), | |
| getTokenUsage( | |
| localStorage.token, | |
| start, | |
| end, | |
| selectedGroupId, | |
| ), | |
| ]); | |
| summary = summaryRes ?? summary; | |
| const modelsMap = new Map( | |
| $models.map((m) => [m.id, m.name || m.id]), | |
| ); | |
| modelStats = (modelsRes?.models ?? []).map((entry) => ({ | |
| ...entry, | |
| name: modelsMap.get(entry.model_id) || entry.model_id, | |
| })); | |
| userStats = usersRes?.users ?? []; | |
| dailyStats = dailyRes?.data ?? []; | |
| // Process token data | |
| if (tokensRes) { | |
| tokenStats = {}; | |
| for (const m of tokensRes.models) { | |
| tokenStats[m.model_id] = { | |
| input_tokens: m.input_tokens, | |
| output_tokens: m.output_tokens, | |
| total_tokens: m.total_tokens, | |
| }; | |
| } | |
| totalTokens = { | |
| input: tokensRes.total_input_tokens, | |
| output: tokensRes.total_output_tokens, | |
| total: tokensRes.total_tokens, | |
| }; | |
| } | |
| } catch (err) { | |
| console.error("Dashboard load failed:", err); | |
| } | |
| loading = false; | |
| }; | |
| $: 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" | |
| ? a.name.localeCompare(b.name) | |
| : b.name.localeCompare(a.name); | |
| } | |
| if (modelOrderBy === "tokens") { | |
| const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0; | |
| const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0; | |
| return modelDirection === "asc" | |
| ? aTokens - bTokens | |
| : bTokens - aTokens; | |
| } | |
| return modelDirection === "asc" ? a.count - b.count : b.count - a.count; | |
| }); | |
| $: sortedUsers = [...userStats].sort((a, b) => { | |
| if (userOrderBy === "name") { | |
| const nameA = a.name || a.user_id; | |
| const nameB = b.name || b.user_id; | |
| return userDirection === "asc" | |
| ? nameA.localeCompare(nameB) | |
| : nameB.localeCompare(nameA); | |
| } | |
| if (userOrderBy === "tokens") { | |
| const aTokens = a.total_tokens ?? 0; | |
| const bTokens = b.total_tokens ?? 0; | |
| return userDirection === "asc" | |
| ? aTokens - bTokens | |
| : bTokens - aTokens; | |
| } | |
| return userDirection === "asc" ? a.count - b.count : b.count - a.count; | |
| }); | |
| $: totalModelMessages = modelStats.reduce((sum, m) => sum + m.count, 0); | |
| // Persist period selection | |
| $: if (typeof localStorage !== "undefined" && selectedPeriod) { | |
| localStorage.setItem("analyticsPeriod", selectedPeriod); | |
| } | |
| onMount(loadDashboard); | |
| </script> | |
| <!-- Header with title and period selector --> | |
| <div | |
| class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900" | |
| > | |
| <div class="text-lg font-medium px-0.5 shrink-0"> | |
| {$i18n.t("Analytics")} | |
| </div> | |
| <div class="flex items-center gap-2 flex-wrap justify-end min-w-0"> | |
| {#if groups.length > 0} | |
| <select | |
| bind:value={selectedGroupId} | |
| class="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="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right" | |
| > | |
| {#each periods as period} | |
| <option value={period.value}>{period.label}</option> | |
| {/each} | |
| </select> | |
| </div> | |
| </div> | |
| <!-- Model Details Modal --> | |
| <AnalyticsModelModal | |
| bind:show={showModelModal} | |
| model={selectedModel} | |
| startDate={getDateRange(selectedPeriod).start} | |
| endDate={getDateRange(selectedPeriod).end} | |
| /> | |
| <!-- Summary stats --> | |
| {#if !loading} | |
| <div | |
| class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2" | |
| > | |
| <span | |
| ><span class="font-medium text-gray-900 dark:text-gray-300" | |
| >{summary.total_messages.toLocaleString()}</span | |
| > | |
| {$i18n.t("messages")}</span | |
| > | |
| <Tooltip | |
| content={$i18n.t( | |
| "Token counts are estimates and may not reflect actual API usage", | |
| )} | |
| > | |
| <span class="cursor-help" | |
| ><span class="font-medium text-gray-900 dark:text-gray-300" | |
| >{formatNumber(totalTokens.total)}</span | |
| > | |
| {$i18n.t("tokens")}</span | |
| > | |
| </Tooltip> | |
| <span | |
| ><span class="font-medium text-gray-900 dark:text-gray-300" | |
| >{summary.total_chats.toLocaleString()}</span | |
| > | |
| {$i18n.t("chats")}</span | |
| > | |
| <span | |
| ><span class="font-medium text-gray-900 dark:text-gray-300" | |
| >{summary.total_users}</span | |
| > | |
| {$i18n.t("users")}</span | |
| > | |
| </div> | |
| <!-- Daily usage chart --> | |
| {#if dailyStats.length > 1} | |
| {@const allModels = [ | |
| ...new Set(dailyStats.flatMap((d) => Object.keys(d.models || {}))), | |
| ]} | |
| {@const topModels = allModels.slice(0, 8)} | |
| {@const chartColors = [ | |
| "#3b82f6", | |
| "#10b981", | |
| "#f59e0b", | |
| "#ef4444", | |
| "#8b5cf6", | |
| "#ec4899", | |
| "#06b6d4", | |
| "#84cc16", | |
| ]} | |
| {@const periodMap = { | |
| "24h": "hour", | |
| "7d": "week", | |
| "30d": "month", | |
| "90d": "year", | |
| all: "all", | |
| }} | |
| <div class="mb-4"> | |
| <div | |
| class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5" | |
| > | |
| {selectedPeriod === "24h" | |
| ? $i18n.t("Hourly Messages") | |
| : $i18n.t("Daily Messages")} | |
| </div> | |
| <ChartLine | |
| data={dailyStats} | |
| models={topModels} | |
| colors={chartColors} | |
| height={200} | |
| period={periodMap[selectedPeriod] || "week"} | |
| /> | |
| </div> | |
| {/if} | |
| {/if} | |
| {#if loading} | |
| <div class="my-10 flex justify-center"> | |
| <Spinner className="size-5" /> | |
| </div> | |
| {:else} | |
| <div class="grid md:grid-cols-2 gap-4"> | |
| <!-- Model Usage Table --> | |
| <div> | |
| <div | |
| class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5" | |
| > | |
| {$i18n.t("Model Usage")} | |
| </div> | |
| <div | |
| class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full" | |
| > | |
| <table | |
| class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto" | |
| > | |
| <thead | |
| class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200" | |
| > | |
| <tr | |
| class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30" | |
| > | |
| <th scope="col" class="px-2.5 py-2 w-8">#</th> | |
| <th | |
| scope="col" | |
| class="px-2.5 py-2 cursor-pointer select-none" | |
| on:click={() => toggleModelSort("name")} | |
| > | |
| <div class="flex gap-1.5 items-center"> | |
| {$i18n.t("Model")} | |
| {#if modelOrderBy === "name"} | |
| <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("count")} | |
| > | |
| <div | |
| class="flex gap-1.5 items-center justify-end" | |
| > | |
| {$i18n.t("Messages")} | |
| {#if modelOrderBy === "count"} | |
| <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("tokens")} | |
| > | |
| <div | |
| class="flex gap-1.5 items-center justify-end" | |
| > | |
| {$i18n.t("Tokens")} | |
| {#if modelOrderBy === "tokens"} | |
| <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 w-16" | |
| on:click={() => toggleModelSort("percentage")} | |
| > | |
| <div | |
| class="flex gap-1.5 items-center justify-end" | |
| > | |
| % | |
| {#if modelOrderBy === "percentage"} | |
| <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> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {#each sortedModels as model, idx (model.model_id)} | |
| <tr | |
| class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" | |
| on:click={() => { | |
| selectedModel = { | |
| id: model.model_id, | |
| name: model.name, | |
| }; | |
| showModelModal = true; | |
| }} | |
| > | |
| <td class="px-3 py-1 text-gray-400" | |
| >{idx + 1}</td | |
| > | |
| <td | |
| class="px-3 py-1 font-medium text-gray-900 dark:text-white" | |
| > | |
| <div class="flex items-center gap-2"> | |
| <img | |
| src="{REXPRO_API_BASE_URL}/models/model/profile/image?id={model.model_id}" | |
| alt={model.name} | |
| class="size-5 rounded-full object-cover shrink-0" | |
| on:error={(e) => { | |
| e.target.src = "/favicon.png"; | |
| }} | |
| /> | |
| <span class="truncate max-w-[150px]" | |
| >{model.name}</span | |
| > | |
| </div> | |
| </td> | |
| <td class="px-3 py-1 text-right" | |
| >{model.count.toLocaleString()}</td | |
| > | |
| <td class="px-3 py-1 text-right" | |
| >{formatNumber( | |
| tokenStats[model.model_id] | |
| ?.total_tokens ?? 0, | |
| )}</td | |
| > | |
| <td class="px-3 py-1 text-right text-gray-400"> | |
| {totalModelMessages > 0 | |
| ? ( | |
| (model.count / | |
| totalModelMessages) * | |
| 100 | |
| ).toFixed(1) | |
| : 0}% | |
| </td> | |
| </tr> | |
| {/each} | |
| {#if sortedModels.length === 0} | |
| <tr | |
| ><td | |
| colspan="5" | |
| class="px-3 py-2 text-center text-gray-400" | |
| >{$i18n.t("No data")}</td | |
| ></tr | |
| > | |
| {/if} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- User Activity Table --> | |
| <div> | |
| <div | |
| class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5" | |
| > | |
| {$i18n.t("User Activity")} | |
| </div> | |
| <div | |
| class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full" | |
| > | |
| <table | |
| class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto" | |
| > | |
| <thead | |
| class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200" | |
| > | |
| <tr | |
| class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30" | |
| > | |
| <th scope="col" class="px-2.5 py-2 w-8">#</th> | |
| <th | |
| scope="col" | |
| class="px-2.5 py-2 cursor-pointer select-none" | |
| on:click={() => toggleUserSort("name")} | |
| > | |
| <div class="flex gap-1.5 items-center"> | |
| {$i18n.t("User")} | |
| {#if userOrderBy === "name"} | |
| <span class="font-normal"> | |
| {#if userDirection === "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={() => toggleUserSort("count")} | |
| > | |
| <div | |
| class="flex gap-1.5 items-center justify-end" | |
| > | |
| {$i18n.t("Messages")} | |
| {#if userOrderBy === "count"} | |
| <span class="font-normal"> | |
| {#if userDirection === "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={() => toggleUserSort("tokens")} | |
| > | |
| <div | |
| class="flex gap-1.5 items-center justify-end" | |
| > | |
| {$i18n.t("Tokens")} | |
| {#if userOrderBy === "tokens"} | |
| <span class="font-normal"> | |
| {#if userDirection === "asc"}<ChevronUp | |
| className="size-2" | |
| />{:else}<ChevronDown | |
| className="size-2" | |
| />{/if} | |
| </span> | |
| {:else} | |
| <span class="invisible" | |
| ><ChevronUp | |
| className="size-2" | |
| /></span | |
| > | |
| {/if} | |
| </div> | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {#each sortedUsers as user, idx (user.user_id)} | |
| <tr | |
| class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs" | |
| > | |
| <td class="px-3 py-1 text-gray-400" | |
| >{idx + 1}</td | |
| > | |
| <td | |
| class="px-3 py-1 font-medium text-gray-900 dark:text-white" | |
| > | |
| <div class="flex items-center gap-2"> | |
| <img | |
| src="{REXPRO_API_BASE_URL}/users/{user.user_id}/profile/image" | |
| alt={user.name || "User"} | |
| class="size-5 rounded-full object-cover shrink-0" | |
| on:error={(e) => { | |
| e.target.src = "/user.png"; | |
| }} | |
| /> | |
| <span class="truncate max-w-[150px]" | |
| >{user.name || | |
| user.email || | |
| user.user_id.substring( | |
| 0, | |
| 8, | |
| )}</span | |
| > | |
| </div> | |
| </td> | |
| <td class="px-3 py-1 text-right" | |
| >{user.count.toLocaleString()}</td | |
| > | |
| <td class="px-3 py-1 text-right" | |
| >{formatNumber(user.total_tokens ?? 0)}</td | |
| > | |
| </tr> | |
| {/each} | |
| {#if sortedUsers.length === 0} | |
| <tr | |
| ><td | |
| colspan="4" | |
| class="px-3 py-2 text-center text-gray-400" | |
| >{$i18n.t("No data")}</td | |
| ></tr | |
| > | |
| {/if} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="text-gray-500 text-xs mt-1.5 text-right"> | |
| ⓘ {$i18n.t("Message counts are based on assistant responses.")} | |
| </div> | |
| {/if} | |