Skip to content

Commit 6a20bc3

Browse files
committed
[NEB-92] Dashboard: Add Nebula Analytics dashboard
1 parent cbfe9ec commit 6a20bc3

File tree

12 files changed

+613
-14
lines changed

12 files changed

+613
-14
lines changed

apps/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"react-table": "^7.8.0",
9090
"recharts": "2.15.1",
9191
"remark-gfm": "^4.0.0",
92+
"responsive-rsc": "0.0.7",
9293
"server-only": "^0.0.1",
9394
"shiki": "1.27.0",
9495
"sonner": "^1.7.4",

apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function DatePickerWithRange(props: {
2727
header?: React.ReactNode;
2828
footer?: React.ReactNode;
2929
labelOverride?: string;
30+
popoverAlign?: "start" | "end" | "center";
3031
}) {
3132
const [screen, setScreen] = React.useState<"from" | "to">("from");
3233
const { from, to, setFrom, setTo } = props;
@@ -65,7 +66,11 @@ export function DatePickerWithRange(props: {
6566
</PopoverTrigger>
6667

6768
{/* Popover */}
68-
<PopoverContent className="w-auto p-0" align="start">
69+
<PopoverContent
70+
className="w-auto p-0"
71+
align={props.popoverAlign || "start"}
72+
sideOffset={10}
73+
>
6974
<DynamicHeight>
7075
<div>
7176
{!isValid && (

apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import Link from "next/link";
5252
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
5353
import { toTokens } from "thirdweb";
5454
import { FormLabel, LinkButton, Text } from "tw-components";
55+
import { normalizeTime } from "../../../../../../../../../../lib/time";
5556
import { TransactionTimeline } from "./transaction-timeline";
5657

5758
export type EngineStatus =
@@ -496,9 +497,7 @@ export function TransactionCharts(props: {
496497
if (!tx.queuedAt || !tx.status) {
497498
continue;
498499
}
499-
const normalizedDate = new Date(tx.queuedAt);
500-
normalizedDate.setHours(0, 0, 0, 0); // normalize time
501-
const time = normalizedDate.getTime();
500+
const time = normalizeTime(new Date(tx.queuedAt)).getTime();
502501
const entry = dayToTxCountMap.get(time) ?? {};
503502
entry[tx.status] = (entry[tx.status] ?? 0) + 1;
504503
uniqueStatuses.add(tx.status);

apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,45 @@
11
import { getTeamBySlug } from "@/api/team";
2-
import { redirect } from "next/navigation";
2+
import { getValidAccount } from "../../../../../account/settings/getAccount";
3+
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
34
import { loginRedirect } from "../../../../../login/loginRedirect";
5+
import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-ui";
46
import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page";
57

68
export default async function Page(props: {
79
params: Promise<{
810
team_slug: string;
911
}>;
12+
searchParams: Promise<{
13+
from: string | undefined | string[];
14+
to: string | undefined | string[];
15+
interval: string | undefined | string[];
16+
}>;
1017
}) {
11-
const params = await props.params;
12-
const team = await getTeamBySlug(params.team_slug);
18+
const [params, searchParams] = await Promise.all([
19+
props.params,
20+
props.searchParams,
21+
]);
22+
23+
const [account, authToken, team] = await Promise.all([
24+
getValidAccount(),
25+
getAuthToken(),
26+
getTeamBySlug(params.team_slug),
27+
]);
1328

14-
if (!team) {
29+
if (!team || !authToken) {
1530
loginRedirect(`/team/${params.team_slug}/~/nebula`);
1631
}
1732

18-
// if nebula access is already granted, redirect to nebula web app
1933
const hasNebulaAccess = team.enabledScopes.includes("nebula");
2034

2135
if (hasNebulaAccess) {
22-
redirect("https://nebula.thirdweb.com");
36+
return (
37+
<NebulaAnalyticsPage
38+
accountId={account.id}
39+
authToken={authToken}
40+
searchParams={searchParams}
41+
/>
42+
);
2343
}
2444

2545
return <NebulaWaitListPage team={team} />;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import "server-only";
2+
3+
import { isProd } from "@/constants/env";
4+
import { unstable_cache } from "next/cache";
5+
6+
export type NebulaAnalyticsDataItem = {
7+
date: string;
8+
totalPromptTokens: number;
9+
totalCompletionTokens: number;
10+
totalSessions: number;
11+
totalRequests: number;
12+
};
13+
14+
const devEndpoint = "https://analytics-service-dev-brg8.chainsaw-dev.zeet.app";
15+
const prodEndpoint = "https://analytics-service-dev-ldna.zeet-nftlabs.zeet.app";
16+
17+
export const fetchNebulaAnalytics = unstable_cache(
18+
async (params: {
19+
accountId: string;
20+
authToken: string;
21+
from: string;
22+
to: string;
23+
interval: "day" | "week";
24+
}) => {
25+
const nebulaAnalyticsEndpoint = isProd ? prodEndpoint : devEndpoint;
26+
27+
const url = new URL(`${nebulaAnalyticsEndpoint}/v1/nebula/usage`);
28+
url.searchParams.set("accountId", params.accountId);
29+
url.searchParams.set("from", params.from);
30+
url.searchParams.set("to", params.to);
31+
url.searchParams.set("interval", params.interval);
32+
33+
const res = await fetch(url, {
34+
headers: {
35+
Authorization: `Bearer ${params.authToken}`,
36+
},
37+
});
38+
39+
if (!res.ok) {
40+
const error = await res.text();
41+
return {
42+
ok: false as const,
43+
error: error,
44+
};
45+
}
46+
47+
const resData = await res.json();
48+
49+
return {
50+
ok: true as const,
51+
data: resData.data as NebulaAnalyticsDataItem[],
52+
};
53+
},
54+
["nebula-analytics"],
55+
{
56+
revalidate: 60 * 60, // 1 hour
57+
},
58+
);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client";
2+
3+
import {
4+
useResponsiveSearchParams,
5+
useSetResponsiveSearchParams,
6+
} from "responsive-rsc";
7+
import { DateRangeSelector } from "../../../../../../../components/analytics/date-range-selector";
8+
import { IntervalSelector } from "../../../../../../../components/analytics/interval-selector";
9+
import {
10+
getNebulaFiltersFromSearchParams,
11+
normalizeTimeISOString,
12+
} from "../../../../../../../lib/time";
13+
14+
export function NebulaAnalyticsFilter() {
15+
const responsiveSearchParams = useResponsiveSearchParams();
16+
const setResponsiveSearchParams = useSetResponsiveSearchParams();
17+
18+
const { range, interval } = getNebulaFiltersFromSearchParams({
19+
from: responsiveSearchParams.from,
20+
to: responsiveSearchParams.to,
21+
interval: responsiveSearchParams.interval,
22+
});
23+
24+
return (
25+
<div className="flex items-center gap-3">
26+
<DateRangeSelector
27+
range={range}
28+
popoverAlign="end"
29+
setRange={(newRange) => {
30+
setResponsiveSearchParams((v) => {
31+
return {
32+
...v,
33+
from: normalizeTimeISOString(newRange.from),
34+
to: normalizeTimeISOString(newRange.to),
35+
};
36+
});
37+
}}
38+
/>
39+
40+
<IntervalSelector
41+
intervalType={interval}
42+
setIntervalType={(newInterval) => {
43+
setResponsiveSearchParams((v) => {
44+
return {
45+
...v,
46+
interval: newInterval,
47+
};
48+
});
49+
}}
50+
/>
51+
</div>
52+
);
53+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { TabButtons } from "@/components/ui/tabs";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { subDays } from "date-fns";
4+
import { useState } from "react";
5+
import { mobileViewport } from "../../../../../../../stories/utils";
6+
import type { NebulaAnalyticsDataItem } from "./fetch-nebula-analytics";
7+
import { NebulaAnalyticsDashboardUI } from "./nebula-analytics-ui";
8+
9+
const meta = {
10+
title: "Nebula/Analytics",
11+
component: Story,
12+
parameters: {
13+
nextjs: {
14+
appDirectory: true,
15+
},
16+
},
17+
} satisfies Meta<typeof Story>;
18+
19+
export default meta;
20+
type Story = StoryObj<typeof meta>;
21+
22+
export const Desktop: Story = {
23+
args: {},
24+
};
25+
26+
export const Mobile: Story = {
27+
args: {},
28+
parameters: {
29+
viewport: mobileViewport("iphone14"),
30+
},
31+
};
32+
33+
type VariantTab = "30-day" | "7-day" | "pending" | "60-day";
34+
35+
function Story() {
36+
const [tab, setTab] = useState<VariantTab>("60-day");
37+
return (
38+
<div className="container flex flex-col gap-8 py-10">
39+
<div>
40+
<h2 className="mb-1 font-semibold text-xl tracking-tight">
41+
Story Variants
42+
</h2>
43+
<TabButtons
44+
tabs={[
45+
{
46+
name: "60 Days",
47+
onClick: () => setTab("60-day"),
48+
isActive: tab === "60-day",
49+
isEnabled: true,
50+
},
51+
{
52+
name: "30 Days",
53+
onClick: () => setTab("30-day"),
54+
isActive: tab === "30-day",
55+
isEnabled: true,
56+
},
57+
{
58+
name: "7 Days",
59+
onClick: () => setTab("7-day"),
60+
isActive: tab === "7-day",
61+
isEnabled: true,
62+
},
63+
{
64+
name: "Pending",
65+
onClick: () => setTab("pending"),
66+
isActive: tab === "pending",
67+
isEnabled: true,
68+
},
69+
]}
70+
/>
71+
</div>
72+
73+
{tab === "60-day" && (
74+
<NebulaAnalyticsDashboardUI
75+
data={generateRandomNebulaAnalyticsData(60)}
76+
isPending={false}
77+
/>
78+
)}
79+
80+
{tab === "30-day" && (
81+
<NebulaAnalyticsDashboardUI
82+
data={generateRandomNebulaAnalyticsData(30)}
83+
isPending={false}
84+
/>
85+
)}
86+
87+
{tab === "7-day" && (
88+
<NebulaAnalyticsDashboardUI
89+
data={generateRandomNebulaAnalyticsData(7)}
90+
isPending={false}
91+
/>
92+
)}
93+
94+
{tab === "pending" && (
95+
<NebulaAnalyticsDashboardUI data={[]} isPending={true} />
96+
)}
97+
</div>
98+
);
99+
}
100+
101+
function generateRandomNebulaAnalyticsData(
102+
days: number,
103+
): NebulaAnalyticsDataItem[] {
104+
return Array.from({ length: days }, (_, i) => ({
105+
date: subDays(new Date(), i).toISOString(),
106+
totalPromptTokens: randomInt(1000),
107+
totalCompletionTokens: randomInt(1000),
108+
totalSessions: randomInt(100),
109+
totalRequests: randomInt(4000),
110+
}));
111+
}
112+
113+
function randomInt(max: number) {
114+
return Math.floor(Math.random() * max);
115+
}

0 commit comments

Comments
 (0)