Skip to content

Commit bdcbe0e

Browse files
Add ai usage section to dashboard (#7902)
Co-authored-by: Cursor Agent <[email protected]>
1 parent 30e2178 commit bdcbe0e

File tree

7 files changed

+421
-0
lines changed

7 files changed

+421
-0
lines changed

apps/dashboard/src/@/api/analytics.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { unstable_cache } from "next/cache";
44
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
55
import { normalizeTime } from "@/lib/time";
66
import type {
7+
AIUsageStats,
78
AnalyticsQueryParams,
89
EcosystemWalletStats,
910
EngineCloudStats,
@@ -204,6 +205,41 @@ export function getInAppWalletUsage(
204205
return cached_getInAppWalletUsage(normalizedParams(params), authToken);
205206
}
206207

208+
const cached_getAiUsage = unstable_cache(
209+
async (
210+
params: AnalyticsQueryParams,
211+
authToken: string,
212+
): Promise<AIUsageStats[]> => {
213+
const searchParams = buildSearchParams(params);
214+
const res = await fetchAnalytics({
215+
authToken,
216+
url: `v2/nebula/usage?${searchParams.toString()}`,
217+
init: {
218+
method: "GET",
219+
},
220+
});
221+
222+
if (res?.status !== 200) {
223+
const reason = await res?.text();
224+
console.error(
225+
`Failed to fetch AI usage, ${res?.status} - ${res.statusText} - ${reason}`,
226+
);
227+
return [];
228+
}
229+
230+
const json = await res.json();
231+
return json.data as AIUsageStats[];
232+
},
233+
["getAiUsage"],
234+
{
235+
revalidate: 60 * 60, // 1 hour
236+
},
237+
);
238+
239+
export function getAiUsage(params: AnalyticsQueryParams, authToken: string) {
240+
return cached_getAiUsage(normalizedParams(params), authToken);
241+
}
242+
207243
const cached_getUserOpUsage = unstable_cache(
208244
async (
209245
params: AnalyticsQueryParams,

apps/dashboard/src/@/types/analytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export interface WebhookSummaryStats {
7878
errorBreakdown: Record<string, number>;
7979
}
8080

81+
export interface AIUsageStats {
82+
date: string;
83+
totalPromptTokens: number;
84+
totalCompletionTokens: number;
85+
totalSessions: number;
86+
totalRequests: number;
87+
}
88+
8189
export interface AnalyticsQueryParams {
8290
teamId: string;
8391
projectId?: string;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"use client";
2+
import { format } from "date-fns";
3+
import { useMemo } from "react";
4+
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
5+
import { DocLink } from "@/components/blocks/DocLink";
6+
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
7+
import type { ChartConfig } from "@/components/ui/chart";
8+
import { NebulaIcon } from "@/icons/NebulaIcon";
9+
import type { AIUsageStats } from "@/types/analytics";
10+
11+
type ChartData = Record<string, number> & {
12+
time: string; // human readable date
13+
tokens: number;
14+
};
15+
16+
export function AiTokenUsageChartCardUI(props: {
17+
aiUsageStats: AIUsageStats[];
18+
isPending: boolean;
19+
title: string;
20+
description: string;
21+
}) {
22+
const { aiUsageStats } = props;
23+
24+
const { chartConfig, chartData } = useMemo(() => {
25+
const _chartConfig: ChartConfig = {
26+
tokens: {
27+
color: "hsl(var(--chart-1))",
28+
label: "Tokens",
29+
},
30+
};
31+
32+
const _chartData: ChartData[] = aiUsageStats
33+
.filter((stat) => stat.totalPromptTokens + stat.totalCompletionTokens > 0)
34+
.map(
35+
(stat) =>
36+
({
37+
time: stat.date,
38+
tokens: stat.totalPromptTokens + stat.totalCompletionTokens,
39+
}) as ChartData,
40+
);
41+
42+
return {
43+
chartConfig: _chartConfig,
44+
chartData: _chartData.sort(
45+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
46+
),
47+
};
48+
}, [aiUsageStats]);
49+
50+
const disableActions =
51+
props.isPending ||
52+
chartData.length === 0 ||
53+
chartData.every((data) => data.tokens === 0);
54+
55+
return (
56+
<ThirdwebBarChart
57+
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
58+
config={chartConfig}
59+
customHeader={
60+
<div className="relative px-6 pt-6">
61+
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
62+
{props.title}
63+
</h3>
64+
<p className="mb-3 text-muted-foreground text-sm">
65+
{props.description}
66+
</p>
67+
68+
<ExportToCSVButton
69+
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
70+
disabled={disableActions}
71+
fileName="AI Token Usage"
72+
getData={async () => {
73+
const header = ["Date", "Tokens"];
74+
const rows = chartData.map((data) => [
75+
data.time,
76+
data.tokens.toString(),
77+
]);
78+
return { header, rows };
79+
}}
80+
/>
81+
</div>
82+
}
83+
data={chartData}
84+
emptyChartState={<AiTokenUsageEmptyChartState />}
85+
hideLabel={false}
86+
isPending={props.isPending}
87+
showLegend={false}
88+
toolTipLabelFormatter={(_v, item) => {
89+
if (Array.isArray(item)) {
90+
const time = item[0].payload.time as string;
91+
return format(new Date(time), "MMM d, yyyy");
92+
}
93+
return undefined;
94+
}}
95+
variant="stacked"
96+
/>
97+
);
98+
}
99+
100+
function AiTokenUsageEmptyChartState() {
101+
return (
102+
<div className="flex flex-col items-center justify-center px-4">
103+
<span className="mb-6 text-center text-lg">
104+
Integrate thirdweb AI to interact with any EVM chain using natural
105+
language
106+
</span>
107+
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
108+
<DocLink
109+
icon={NebulaIcon}
110+
label="Get Started"
111+
link="https://portal.thirdweb.com/ai/chat"
112+
/>
113+
</div>
114+
</div>
115+
);
116+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ActivityIcon, MessageSquareIcon } from "lucide-react";
2+
import { Suspense } from "react";
3+
import { getAiUsage } from "@/api/analytics";
4+
import type { Range } from "@/components/analytics/date-range-selector";
5+
import { StatCard } from "@/components/analytics/stat";
6+
import type { AIUsageStats } from "@/types/analytics";
7+
8+
function AiSummaryInner(props: {
9+
stats: AIUsageStats[] | undefined;
10+
isPending: boolean;
11+
}) {
12+
const totalSessions = props.stats?.reduce((acc, curr) => {
13+
return acc + curr.totalSessions;
14+
}, 0);
15+
16+
const totalTokens = props.stats?.reduce((acc, curr) => {
17+
return acc + curr.totalPromptTokens + curr.totalCompletionTokens;
18+
}, 0);
19+
20+
return (
21+
<div className="grid grid-cols-2 gap-4">
22+
<StatCard
23+
icon={ActivityIcon}
24+
isPending={props.isPending}
25+
label="Sessions"
26+
value={totalSessions || 0}
27+
/>
28+
<StatCard
29+
icon={MessageSquareIcon}
30+
isPending={props.isPending}
31+
label="Tokens"
32+
value={totalTokens || 0}
33+
/>
34+
</div>
35+
);
36+
}
37+
38+
async function AsyncAiSummary(props: {
39+
teamId: string;
40+
projectId: string;
41+
authToken: string;
42+
range: Range;
43+
}) {
44+
const { teamId, projectId, authToken, range } = props;
45+
const aggregatedStatsPromise = getAiUsage(
46+
{
47+
from: range.from,
48+
period: "all",
49+
projectId,
50+
teamId,
51+
to: range.to,
52+
},
53+
authToken,
54+
);
55+
56+
const aggregatedStats = await aggregatedStatsPromise.catch(() => null);
57+
58+
return (
59+
<AiSummaryInner stats={aggregatedStats || undefined} isPending={false} />
60+
);
61+
}
62+
63+
export function AiSummary(props: {
64+
teamId: string;
65+
projectId: string;
66+
authToken: string;
67+
range: Range;
68+
}) {
69+
return (
70+
<Suspense fallback={<AiSummaryInner stats={undefined} isPending={true} />}>
71+
<AsyncAiSummary
72+
projectId={props.projectId}
73+
teamId={props.teamId}
74+
authToken={props.authToken}
75+
range={props.range}
76+
/>
77+
</Suspense>
78+
);
79+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { ResponsiveSuspense } from "responsive-rsc";
2+
import { getAiUsage } from "@/api/analytics";
3+
import {
4+
getLastNDaysRange,
5+
type Range,
6+
} from "@/components/analytics/date-range-selector";
7+
import type { AIUsageStats } from "@/types/analytics";
8+
import { AiTokenUsageChartCardUI } from "./AiTokenUsageChartCard";
9+
10+
type AiAnalyticsProps = {
11+
interval: "day" | "week";
12+
range: Range;
13+
stats: AIUsageStats[];
14+
isPending: boolean;
15+
};
16+
17+
function AiAnalyticsUI({ stats, isPending }: AiAnalyticsProps) {
18+
return (
19+
<AiTokenUsageChartCardUI
20+
title="Token Usage"
21+
description="The total number of tokens used for AI interactions on your project."
22+
aiUsageStats={stats || []}
23+
isPending={isPending}
24+
/>
25+
);
26+
}
27+
28+
type AsyncAiAnalyticsProps = Omit<AiAnalyticsProps, "stats" | "isPending"> & {
29+
teamId: string;
30+
projectId: string;
31+
authToken: string;
32+
};
33+
34+
async function AsyncAiAnalytics(props: AsyncAiAnalyticsProps) {
35+
const range = props.range ?? getLastNDaysRange("last-30");
36+
37+
const stats = await getAiUsage(
38+
{
39+
from: range.from,
40+
period: props.interval,
41+
projectId: props.projectId,
42+
teamId: props.teamId,
43+
to: range.to,
44+
},
45+
props.authToken,
46+
).catch((error) => {
47+
console.error(error);
48+
return [];
49+
});
50+
51+
return (
52+
<AiAnalyticsUI {...props} isPending={false} range={range} stats={stats} />
53+
);
54+
}
55+
56+
export function AiAnalytics(props: AsyncAiAnalyticsProps) {
57+
return (
58+
<ResponsiveSuspense
59+
searchParamsUsed={["from", "to", "interval"]}
60+
fallback={<AiAnalyticsUI {...props} isPending={true} stats={[]} />}
61+
>
62+
<AsyncAiAnalytics {...props} />
63+
</ResponsiveSuspense>
64+
);
65+
}

0 commit comments

Comments
 (0)