ai / frontend /src /lib /components /admin /Analytics /Dashboard.svelte
fdrah
initial-commit
9e93b10
Raw
History Blame Contribute Delete
18.9 kB
<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}