Skip to content

Commit 3c2790e

Browse files
authored
feat: [ENG-3586] AI Gateway Stats page MVP (#5427)
* basic leaderboard and market share chart in stats page * cache stats endpoints * fix clickhouse stats page calculations * provider chart * undo
1 parent 8772988 commit 3c2790e

25 files changed

Lines changed: 8475 additions & 4539 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import {
5+
BarChart,
6+
Bar,
7+
XAxis,
8+
YAxis,
9+
CartesianGrid,
10+
Tooltip,
11+
ResponsiveContainer,
12+
} from "recharts";
13+
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
14+
import { CHART_COLOR_PALETTE } from "@/lib/chartColors";
15+
import { Skeleton } from "@/components/ui/skeleton";
16+
import { formatTokens, formatTooltipDate } from "@/utils/formatters";
17+
18+
interface AuthorTokens {
19+
author: string;
20+
totalTokens: number;
21+
percentage: number;
22+
}
23+
24+
interface TimeSeriesDataPoint {
25+
time: string;
26+
authors: AuthorTokens[];
27+
}
28+
29+
interface MarketShareChartProps {
30+
data: TimeSeriesDataPoint[];
31+
isLoading: boolean;
32+
}
33+
34+
function formatTimeLabel(time: string): string {
35+
const date = new Date(time);
36+
return date.toLocaleDateString([], { month: "short", day: "numeric" });
37+
}
38+
39+
interface CustomTooltipProps {
40+
active?: boolean;
41+
payload?: Array<{
42+
dataKey: string;
43+
value: number;
44+
fill: string;
45+
payload: Record<string, unknown>;
46+
}>;
47+
chartConfig: ChartConfig;
48+
rawData: TimeSeriesDataPoint[];
49+
}
50+
51+
function CustomTooltip({
52+
active,
53+
payload,
54+
chartConfig,
55+
rawData,
56+
}: CustomTooltipProps) {
57+
if (!active || !payload?.length) return null;
58+
59+
const rawTime = payload[0]?.payload?.rawTime as string | undefined;
60+
const originalPoint = rawData.find((p) => p.time === rawTime);
61+
const sortedPayload = [...payload]
62+
.filter((item) => item.value > 0)
63+
.sort((a, b) => b.value - a.value);
64+
65+
return (
66+
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 shadow-lg rounded-lg p-3 min-w-[220px]">
67+
<div className="mb-3">
68+
<span className="text-sm font-medium text-foreground">
69+
{rawTime ? formatTooltipDate(rawTime) : ""}
70+
</span>
71+
</div>
72+
<div className="space-y-2">
73+
{sortedPayload.map((item) => {
74+
const author = chartConfig[item.dataKey]?.label || item.dataKey;
75+
const originalAuthor = originalPoint?.authors.find(
76+
(a) => a.author === author
77+
);
78+
const tokens = originalAuthor?.totalTokens ?? 0;
79+
const percentage = item.value;
80+
81+
return (
82+
<div
83+
key={item.dataKey}
84+
className="flex items-center justify-between gap-4"
85+
>
86+
<div className="flex items-center gap-2">
87+
<div
88+
className="w-1 h-4 rounded-sm"
89+
style={{ backgroundColor: item.fill }}
90+
/>
91+
<span className="text-sm text-gray-700 dark:text-gray-300">
92+
{author}
93+
</span>
94+
</div>
95+
<span className="text-xs font-medium tabular-nums text-gray-900 dark:text-gray-100">
96+
{formatTokens(tokens)} ({percentage.toFixed(1)}%)
97+
</span>
98+
</div>
99+
);
100+
})}
101+
</div>
102+
</div>
103+
);
104+
}
105+
106+
export function MarketShareChart({ data, isLoading }: MarketShareChartProps) {
107+
const { chartData, authors, chartConfig } = useMemo(() => {
108+
const authorSet = new Set<string>();
109+
data.forEach((point) => {
110+
point.authors.forEach((a) => authorSet.add(a.author));
111+
});
112+
const authors = Array.from(authorSet);
113+
114+
const chartData = data.map((point) => {
115+
const entry: Record<string, string | number> = {
116+
time: formatTimeLabel(point.time),
117+
rawTime: point.time,
118+
};
119+
120+
const totalPercentage = point.authors.reduce(
121+
(sum, a) => sum + a.percentage,
122+
0
123+
);
124+
125+
authors.forEach((author) => {
126+
const found = point.authors.find((a) => a.author === author);
127+
const rawPercentage = found?.percentage ?? 0;
128+
const normalizedPercentage =
129+
totalPercentage > 0 ? (rawPercentage / totalPercentage) * 100 : 0;
130+
entry[author] = normalizedPercentage;
131+
});
132+
return entry;
133+
});
134+
135+
const chartConfig: ChartConfig = {};
136+
authors.forEach((author, index) => {
137+
chartConfig[author] = {
138+
label: author,
139+
color: CHART_COLOR_PALETTE[index % CHART_COLOR_PALETTE.length],
140+
};
141+
});
142+
143+
return { chartData, authors, chartConfig };
144+
}, [data]);
145+
146+
if (isLoading) {
147+
return <Skeleton className="h-[400px] w-full" />;
148+
}
149+
150+
if (data.length === 0) {
151+
return (
152+
<div className="flex h-[400px] items-center justify-center text-gray-500 dark:text-gray-400">
153+
No data available for this time period
154+
</div>
155+
);
156+
}
157+
158+
return (
159+
<ChartContainer config={chartConfig} className="h-[400px] w-full">
160+
<ResponsiveContainer width="100%" height="100%">
161+
<BarChart data={chartData} barGap={1} barCategoryGap="8%">
162+
<CartesianGrid
163+
vertical={false}
164+
stroke="#e5e7eb"
165+
strokeOpacity={0.5}
166+
/>
167+
<XAxis
168+
dataKey="time"
169+
tickLine={false}
170+
axisLine={false}
171+
tickMargin={8}
172+
minTickGap={50}
173+
tick={{ fill: "#9ca3af", fontSize: 12 }}
174+
/>
175+
<YAxis
176+
tickLine={false}
177+
axisLine={false}
178+
tickFormatter={(value) => `${Math.round(value)}%`}
179+
width={50}
180+
tick={{ fill: "#9ca3af", fontSize: 12 }}
181+
domain={[0, 100]}
182+
allowDataOverflow={true}
183+
ticks={[0, 25, 50, 75, 100]}
184+
/>
185+
<Tooltip
186+
cursor={{ fill: "rgba(0, 0, 0, 0.03)" }}
187+
content={<CustomTooltip chartConfig={chartConfig} rawData={data} />}
188+
/>
189+
{authors.map((author, index) => (
190+
<Bar
191+
key={author}
192+
dataKey={author}
193+
stackId="a"
194+
fill={CHART_COLOR_PALETTE[index % CHART_COLOR_PALETTE.length]}
195+
radius={0}
196+
/>
197+
))}
198+
</BarChart>
199+
</ResponsiveContainer>
200+
</ChartContainer>
201+
);
202+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"use client";
2+
3+
import { Skeleton } from "@/components/ui/skeleton";
4+
import { CHART_COLOR_PALETTE } from "@/lib/chartColors";
5+
import { ChevronUp, ChevronDown } from "lucide-react";
6+
import { formatTokens } from "@/utils/formatters";
7+
8+
interface LeaderboardEntry {
9+
rank: number;
10+
author: string;
11+
totalTokens: number;
12+
marketShare: number;
13+
rankChange: number | null;
14+
marketShareChange: number | null;
15+
}
16+
17+
interface MarketShareLeaderboardProps {
18+
data: LeaderboardEntry[];
19+
isLoading: boolean;
20+
}
21+
22+
function RankChangeIndicator({ rankChange }: { rankChange: number | null }) {
23+
if (rankChange === null || rankChange === 0 || !isFinite(rankChange)) {
24+
return null;
25+
}
26+
27+
if (rankChange > 0) {
28+
return (
29+
<span className="flex items-center text-green-600 dark:text-green-400">
30+
<ChevronUp className="h-4 w-4" />
31+
<span className="text-xs tabular-nums">{rankChange}</span>
32+
</span>
33+
);
34+
}
35+
36+
return (
37+
<span className="flex items-center text-red-600 dark:text-red-400">
38+
<ChevronDown className="h-4 w-4" />
39+
<span className="text-xs tabular-nums">{Math.abs(rankChange)}</span>
40+
</span>
41+
);
42+
}
43+
44+
function MarketShareChangeIndicator({ change }: { change: number | null }) {
45+
if (change === null || !isFinite(change) || isNaN(change)) {
46+
return null;
47+
}
48+
49+
if (Math.abs(change) < 0.05) {
50+
return <span className="text-xs text-gray-400 tabular-nums">0.0%</span>;
51+
}
52+
53+
const isPositive = change > 0;
54+
const displayValue = Math.abs(change).toFixed(1);
55+
56+
return (
57+
<span
58+
className={`text-xs tabular-nums ${
59+
isPositive
60+
? "text-green-600 dark:text-green-400"
61+
: "text-red-600 dark:text-red-400"
62+
}`}
63+
>
64+
{isPositive ? "+" : "-"}
65+
{displayValue}%
66+
</span>
67+
);
68+
}
69+
70+
function LeaderboardItem({
71+
entry,
72+
colorIndex,
73+
}: {
74+
entry: LeaderboardEntry;
75+
colorIndex: number;
76+
}) {
77+
const isOther = entry.author.toLowerCase() === "others";
78+
const color = CHART_COLOR_PALETTE[colorIndex % CHART_COLOR_PALETTE.length];
79+
const marketShare = isFinite(entry.marketShare) ? entry.marketShare : 0;
80+
const totalTokens = isFinite(entry.totalTokens) ? entry.totalTokens : 0;
81+
82+
return (
83+
<div className="flex items-start gap-3 py-3">
84+
<span className="text-sm text-gray-500 dark:text-gray-400 w-6 text-right tabular-nums">
85+
{entry.rank}.
86+
</span>
87+
<div
88+
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
89+
style={{ backgroundColor: color }}
90+
/>
91+
<div className="flex-1 min-w-0">
92+
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
93+
{entry.author}
94+
</span>
95+
</div>
96+
<div className="w-10 flex justify-end">
97+
{!isOther && <RankChangeIndicator rankChange={entry.rankChange} />}
98+
</div>
99+
<div className="text-right min-w-[70px]">
100+
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 tabular-nums">
101+
{marketShare.toFixed(1)}%
102+
</div>
103+
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
104+
{formatTokens(totalTokens)}
105+
</div>
106+
<MarketShareChangeIndicator change={entry.marketShareChange} />
107+
</div>
108+
</div>
109+
);
110+
}
111+
112+
function LoadingSkeleton() {
113+
return (
114+
<div className="grid grid-cols-2 gap-x-8">
115+
{[...Array(10)].map((_, i) => (
116+
<div key={i} className="flex items-center gap-3 py-3">
117+
<Skeleton className="w-6 h-4" />
118+
<Skeleton className="w-3 h-3 rounded-full" />
119+
<Skeleton className="flex-1 h-4" />
120+
<Skeleton className="w-10 h-4" />
121+
<div className="text-right">
122+
<Skeleton className="w-12 h-4 mb-1" />
123+
<Skeleton className="w-10 h-3 mb-1" />
124+
<Skeleton className="w-8 h-3" />
125+
</div>
126+
</div>
127+
))}
128+
</div>
129+
);
130+
}
131+
132+
export function MarketShareLeaderboard({
133+
data,
134+
isLoading,
135+
}: MarketShareLeaderboardProps) {
136+
if (isLoading) {
137+
return <LoadingSkeleton />;
138+
}
139+
140+
if (data.length === 0) {
141+
return (
142+
<div className="flex h-[200px] items-center justify-center text-gray-500 dark:text-gray-400">
143+
No data available
144+
</div>
145+
);
146+
}
147+
148+
const leftColumn = data.slice(0, 5);
149+
const rightColumn = data.slice(5, 10);
150+
151+
return (
152+
<div className="grid grid-cols-2 gap-x-8">
153+
<div className="divide-y divide-gray-100 dark:divide-gray-800">
154+
{leftColumn.map((entry, index) => (
155+
<LeaderboardItem key={entry.author} entry={entry} colorIndex={index} />
156+
))}
157+
</div>
158+
<div className="divide-y divide-gray-100 dark:divide-gray-800">
159+
{rightColumn.map((entry, index) => (
160+
<LeaderboardItem
161+
key={entry.author}
162+
entry={entry}
163+
colorIndex={index + 5}
164+
/>
165+
))}
166+
</div>
167+
</div>
168+
);
169+
}

0 commit comments

Comments
 (0)