Skip to content

Commit 815b9b1

Browse files
committed
[NEB-92] Dashboard: Add Nebula Analytics dashboard (#6198)
<!-- start pr-codex --> ## PR-Codex overview This PR primarily focuses on enhancing the `Nebula` analytics functionality by adding new components and improving data handling for analytics. It introduces new utilities for managing date ranges and search parameters, along with updates to the UI for better user interaction. ### Detailed summary - Added `getNebulaAnalyticsRangeFromSearchParams` function in `utils.ts`. - Updated `ProjectTabs` to remove `isOnNebulaWaitList` prop. - Introduced `normalizeTime` function in `time.ts` for date normalization. - Created `NebulaAnalyticsFilter` component for filtering analytics data. - Implemented `NebulaAnalyticsPage` and `NebulaAnalyticDashboard` for displaying analytics. - Enhanced `DateRangeSelector` with popover alignment options. - Added `NebulaAnalyticsDashboardUI` for rendering analytics data and charts. - Integrated `responsive-rsc` for responsive search parameters. - Updated `package.json` to include `responsive-rsc` dependency. - Removed calls to `getTeamNebulaWaitList` in `layout.tsx` and associated logic. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent ae1baa5 commit 815b9b1

File tree

15 files changed

+610
-56
lines changed

15 files changed

+610
-56
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} />;

apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getProjects } from "@/api/projects";
2-
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
2+
import { getTeams } from "@/api/team";
33
import { notFound, redirect } from "next/navigation";
44
import { getValidAccount } from "../../../account/settings/getAccount";
55
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
@@ -45,9 +45,6 @@ export default async function TeamLayout(props: {
4545
redirect(`/team/${params.team_slug}`);
4646
}
4747

48-
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
49-
?.onWaitlist;
50-
5148
return (
5249
<div className="flex grow flex-col">
5350
<div className="bg-card">
@@ -59,7 +56,6 @@ export default async function TeamLayout(props: {
5956
/>
6057
<ProjectTabs
6158
layoutPath={`/team/${params.team_slug}/${params.project_slug}`}
62-
isOnNebulaWaitList={!!isOnNebulaWaitList}
6359
/>
6460
</div>
6561
<div className="flex grow flex-col">{props.children}</div>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import "server-only";
2+
import { unstable_cache } from "next/cache";
3+
4+
export type NebulaAnalyticsDataItem = {
5+
date: string;
6+
totalPromptTokens: number;
7+
totalCompletionTokens: number;
8+
totalSessions: number;
9+
totalRequests: number;
10+
};
11+
12+
export const fetchNebulaAnalytics = unstable_cache(
13+
async (params: {
14+
accountId: string;
15+
authToken: string;
16+
from: string;
17+
to: string;
18+
interval: "day" | "week";
19+
}) => {
20+
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
21+
const url = new URL(`${analyticsEndpoint}/v1/nebula/usage`);
22+
url.searchParams.set("accountId", params.accountId);
23+
url.searchParams.set("from", params.from);
24+
url.searchParams.set("to", params.to);
25+
url.searchParams.set("interval", params.interval);
26+
27+
const res = await fetch(url, {
28+
headers: {
29+
Authorization: `Bearer ${params.authToken}`,
30+
},
31+
});
32+
33+
if (!res.ok) {
34+
const error = await res.text();
35+
return {
36+
ok: false as const,
37+
error: error,
38+
};
39+
}
40+
41+
const resData = await res.json();
42+
43+
return {
44+
ok: true as const,
45+
data: resData.data as NebulaAnalyticsDataItem[],
46+
};
47+
},
48+
["nebula-analytics"],
49+
{
50+
revalidate: 60 * 60, // 1 hour
51+
},
52+
);
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)