diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c8798e71178..0d322bb4b38 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -89,6 +89,7 @@ "react-table": "^7.8.0", "recharts": "2.15.1", "remark-gfm": "^4.0.0", + "responsive-rsc": "0.0.7", "server-only": "^0.0.1", "shiki": "1.27.0", "sonner": "^1.7.4", diff --git a/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx b/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx index a410737b434..793e1964fae 100644 --- a/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx +++ b/apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx @@ -27,6 +27,7 @@ export function DatePickerWithRange(props: { header?: React.ReactNode; footer?: React.ReactNode; labelOverride?: string; + popoverAlign?: "start" | "end" | "center"; }) { const [screen, setScreen] = React.useState<"from" | "to">("from"); const { from, to, setFrom, setTo } = props; @@ -65,7 +66,11 @@ export function DatePickerWithRange(props: { {/* Popover */} - +
{!isValid && ( diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx index f0f9c21c1de..493eabfb013 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table.tsx @@ -52,6 +52,7 @@ import Link from "next/link"; import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; import { toTokens } from "thirdweb"; import { FormLabel, LinkButton, Text } from "tw-components"; +import { normalizeTime } from "../../../../../../../../../../lib/time"; import { TransactionTimeline } from "./transaction-timeline"; export type EngineStatus = @@ -496,9 +497,7 @@ export function TransactionCharts(props: { if (!tx.queuedAt || !tx.status) { continue; } - const normalizedDate = new Date(tx.queuedAt); - normalizedDate.setHours(0, 0, 0, 0); // normalize time - const time = normalizedDate.getTime(); + const time = normalizeTime(new Date(tx.queuedAt)).getTime(); const entry = dayToTxCountMap.get(time) ?? {}; entry[tx.status] = (entry[tx.status] ?? 0) + 1; uniqueStatuses.add(tx.status); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx index fa58d1872d0..da234f8fb76 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/nebula/page.tsx @@ -1,25 +1,45 @@ import { getTeamBySlug } from "@/api/team"; -import { redirect } from "next/navigation"; +import { getValidAccount } from "../../../../../account/settings/getAccount"; +import { getAuthToken } from "../../../../../api/lib/getAuthToken"; import { loginRedirect } from "../../../../../login/loginRedirect"; +import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-ui"; import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page"; export default async function Page(props: { params: Promise<{ team_slug: string; }>; + searchParams: Promise<{ + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; + }>; }) { - const params = await props.params; - const team = await getTeamBySlug(params.team_slug); + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); + + const [account, authToken, team] = await Promise.all([ + getValidAccount(), + getAuthToken(), + getTeamBySlug(params.team_slug), + ]); - if (!team) { + if (!team || !authToken) { loginRedirect(`/team/${params.team_slug}/~/nebula`); } - // if nebula access is already granted, redirect to nebula web app const hasNebulaAccess = team.enabledScopes.includes("nebula"); if (hasNebulaAccess) { - redirect("https://nebula.thirdweb.com"); + return ( + + ); } return ; diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx index d4a045fc4cc..d4bfa3170e2 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx @@ -1,5 +1,5 @@ import { getProjects } from "@/api/projects"; -import { getTeamNebulaWaitList, getTeams } from "@/api/team"; +import { getTeams } from "@/api/team"; import { notFound, redirect } from "next/navigation"; import { getValidAccount } from "../../../account/settings/getAccount"; import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client"; @@ -45,9 +45,6 @@ export default async function TeamLayout(props: { redirect(`/team/${params.team_slug}`); } - const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug)) - ?.onWaitlist; - return (
@@ -59,7 +56,6 @@ export default async function TeamLayout(props: { />
{props.children}
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx new file mode 100644 index 00000000000..55ff572b72b --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/fetch-nebula-analytics.tsx @@ -0,0 +1,52 @@ +import "server-only"; +import { unstable_cache } from "next/cache"; + +export type NebulaAnalyticsDataItem = { + date: string; + totalPromptTokens: number; + totalCompletionTokens: number; + totalSessions: number; + totalRequests: number; +}; + +export const fetchNebulaAnalytics = unstable_cache( + async (params: { + accountId: string; + authToken: string; + from: string; + to: string; + interval: "day" | "week"; + }) => { + const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string; + const url = new URL(`${analyticsEndpoint}/v1/nebula/usage`); + url.searchParams.set("accountId", params.accountId); + url.searchParams.set("from", params.from); + url.searchParams.set("to", params.to); + url.searchParams.set("interval", params.interval); + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${params.authToken}`, + }, + }); + + if (!res.ok) { + const error = await res.text(); + return { + ok: false as const, + error: error, + }; + } + + const resData = await res.json(); + + return { + ok: true as const, + data: resData.data as NebulaAnalyticsDataItem[], + }; + }, + ["nebula-analytics"], + { + revalidate: 60 * 60, // 1 hour + }, +); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx new file mode 100644 index 00000000000..86e9345f0e7 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-filter.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "../../../../../../../components/analytics/date-range-selector"; +import { IntervalSelector } from "../../../../../../../components/analytics/interval-selector"; +import { + getNebulaFiltersFromSearchParams, + normalizeTimeISOString, +} from "../../../../../../../lib/time"; + +export function NebulaAnalyticsFilter() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getNebulaFiltersFromSearchParams({ + from: responsiveSearchParams.from, + to: responsiveSearchParams.to, + interval: responsiveSearchParams.interval, + }); + + return ( +
+ { + setResponsiveSearchParams((v) => { + return { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + }); + }} + /> + + { + setResponsiveSearchParams((v) => { + return { + ...v, + interval: newInterval, + }; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx new file mode 100644 index 00000000000..16a61969bf9 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.stories.tsx @@ -0,0 +1,115 @@ +import { TabButtons } from "@/components/ui/tabs"; +import type { Meta, StoryObj } from "@storybook/react"; +import { subDays } from "date-fns"; +import { useState } from "react"; +import { mobileViewport } from "../../../../../../../stories/utils"; +import type { NebulaAnalyticsDataItem } from "./fetch-nebula-analytics"; +import { NebulaAnalyticsDashboardUI } from "./nebula-analytics-ui"; + +const meta = { + title: "Nebula/Analytics", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +type VariantTab = "30-day" | "7-day" | "pending" | "60-day"; + +function Story() { + const [tab, setTab] = useState("60-day"); + return ( +
+
+

+ Story Variants +

+ setTab("60-day"), + isActive: tab === "60-day", + isEnabled: true, + }, + { + name: "30 Days", + onClick: () => setTab("30-day"), + isActive: tab === "30-day", + isEnabled: true, + }, + { + name: "7 Days", + onClick: () => setTab("7-day"), + isActive: tab === "7-day", + isEnabled: true, + }, + { + name: "Pending", + onClick: () => setTab("pending"), + isActive: tab === "pending", + isEnabled: true, + }, + ]} + /> +
+ + {tab === "60-day" && ( + + )} + + {tab === "30-day" && ( + + )} + + {tab === "7-day" && ( + + )} + + {tab === "pending" && ( + + )} +
+ ); +} + +function generateRandomNebulaAnalyticsData( + days: number, +): NebulaAnalyticsDataItem[] { + return Array.from({ length: days }, (_, i) => ({ + date: subDays(new Date(), i).toISOString(), + totalPromptTokens: randomInt(1000), + totalCompletionTokens: randomInt(1000), + totalSessions: randomInt(100), + totalRequests: randomInt(4000), + })); +} + +function randomInt(max: number) { + return Math.floor(Math.random() * max); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx new file mode 100644 index 00000000000..78bdb8cf9db --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/nebula-analytics-ui.tsx @@ -0,0 +1,257 @@ +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + ActivityIcon, + MessageCircleQuestionIcon, + MessageSquareIcon, + MessageSquareQuoteIcon, +} from "lucide-react"; +import { useMemo } from "react"; +import { + ResponsiveSearchParamsProvider, + ResponsiveSuspense, +} from "responsive-rsc"; +import { normalizeTimeISOString } from "../../../../../../../lib/time"; +import { + type NebulaAnalyticsDataItem, + fetchNebulaAnalytics, +} from "./fetch-nebula-analytics"; +import { NebulaAnalyticsFilter } from "./nebula-analytics-filter"; +import { getNebulaAnalyticsRangeFromSearchParams } from "./utils"; + +type ChartData = { + time: Date; + totalPromptTokens: number; + totalCompletionTokens: number; + totalSessions: number; + totalRequests: number; +}; + +export function NebulaAnalyticsPage(props: { + searchParams: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; + }; + accountId: string; + authToken: string; +}) { + return ( + +
+
+
+

+ Nebula +

+
+ +
+
+ +
+ } + > + + +
+
+ ); +} + +async function NebulaAnalyticDashboard(props: { + accountId: string; + authToken: string; + searchParams: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; + }; +}) { + const { range, interval } = getNebulaAnalyticsRangeFromSearchParams( + props.searchParams, + ); + + const res = await fetchNebulaAnalytics({ + accountId: props.accountId, + authToken: props.authToken, + from: normalizeTimeISOString(range.from), + to: normalizeTimeISOString(range.to), + interval, + }); + + if (!res.ok) { + return ( +
+
+

+ Failed to fetch Nebula analytics +

+

{res.error}

+
+
+ ); + } + + return ; +} + +export function NebulaAnalyticsDashboardUI(props: { + data: NebulaAnalyticsDataItem[]; + isPending: boolean; +}) { + const data = useMemo(() => { + const val: { + totalPromptTokens: number; + totalCompletionTokens: number; + totalSessions: number; + totalRequests: number; + chartData: ChartData[]; + } = { + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalSessions: 0, + totalRequests: 0, + chartData: [], + }; + + for (const item of props.data) { + val.totalPromptTokens += item.totalPromptTokens; + val.totalCompletionTokens += item.totalCompletionTokens; + val.totalSessions += item.totalSessions; + val.totalRequests += item.totalRequests; + val.chartData.push({ + totalPromptTokens: item.totalPromptTokens, + totalCompletionTokens: item.totalCompletionTokens, + totalSessions: item.totalSessions, + totalRequests: item.totalRequests, + time: new Date(item.date), + }); + } + + return val; + }, [props.data]); + + return ( +
+
+ + + + +
+ +
+ +
+ + + + + + + +
+
+ ); +} + +function StatCard(props: { + title: string; + value: number; + icon: React.FC<{ className?: string }>; + isPending: boolean; +}) { + return ( +
+
+

{props.title}

+ +
+ ( +

+ {v} +

+ )} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts new file mode 100644 index 00000000000..cecedcb8990 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/analytics/utils.ts @@ -0,0 +1,13 @@ +import { getNebulaFiltersFromSearchParams } from "../../../../../../../lib/time"; + +export function getNebulaAnalyticsRangeFromSearchParams(searchParams: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; +}) { + return getNebulaFiltersFromSearchParams({ + from: searchParams.from, + to: searchParams.to, + interval: searchParams.interval, + }); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx deleted file mode 100644 index b932c51e479..00000000000 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { getTeamBySlug } from "@/api/team"; -import { redirect } from "next/navigation"; -import { loginRedirect } from "../../../../login/loginRedirect"; -import { NebulaWaitListPage } from "./components/nebula-waitlist-page"; - -export default async function Page(props: { - params: Promise<{ - team_slug: string; - project_slug: string; - }>; -}) { - const params = await props.params; - const team = await getTeamBySlug(params.team_slug); - - if (!team) { - loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`); - } - - // if nebula access is already granted, redirect to nebula web app - const hasNebulaAccess = team.enabledScopes.includes("nebula"); - - if (hasNebulaAccess) { - redirect("https://nebula.thirdweb.com"); - } - - return ; -} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx index dfa2212db26..90da40e513d 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/tabs.tsx @@ -4,9 +4,8 @@ import { TabPathLinks } from "@/components/ui/tabs"; export function ProjectTabs(props: { layoutPath: string; - isOnNebulaWaitList: boolean; }) { - const { layoutPath, isOnNebulaWaitList } = props; + const { layoutPath } = props; return ( void; + popoverAlign?: "start" | "end" | "center"; }) { const { range, setRange } = props; + const daysDiff = differenceInCalendarDays(range.to, range.from); + const matchingRange = + normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime() + ? durationPresets.find((preset) => preset.days === daysDiff) + : undefined; + + const rangeType = matchingRange?.id || range.type; + const rangeLabel = matchingRange?.name || range.label; return ( setRange({ from, @@ -35,7 +46,7 @@ export function DateRangeSelector(props: { header={
} - labelOverride={range.label} + labelOverride={rangeLabel} className="w-auto bg-card" /> ); diff --git a/apps/dashboard/src/lib/time.ts b/apps/dashboard/src/lib/time.ts new file mode 100644 index 00000000000..2f774160934 --- /dev/null +++ b/apps/dashboard/src/lib/time.ts @@ -0,0 +1,53 @@ +import { differenceInCalendarDays } from "date-fns"; +import { + type Range, + getLastNDaysRange, +} from "../components/analytics/date-range-selector"; + +export function normalizeTime(date: Date) { + const newDate = new Date(date); + newDate.setHours(1, 0, 0, 0); + return newDate; +} + +export function getNebulaFiltersFromSearchParams(params: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; +}) { + const fromStr = params.from; + const toStr = params.to; + const defaultRange = getLastNDaysRange("last-30"); + + const range: Range = + fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string" + ? { + from: normalizeTime(new Date(fromStr)), + to: normalizeTime(new Date(toStr)), + type: "custom", + } + : { + from: normalizeTime(defaultRange.from), + to: normalizeTime(defaultRange.to), + type: defaultRange.type, + }; + + const defaultInterval = + differenceInCalendarDays(range.to, range.from) > 30 + ? "week" + : ("day" as const); + + return { + range, + interval: + params.interval === "day" + ? ("day" as const) + : params.interval === "week" + ? ("week" as const) + : defaultInterval, + }; +} + +export function normalizeTimeISOString(date: Date) { + return normalizeTime(date).toISOString(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcf11ed3560..db5a24b9581 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: remark-gfm: specifier: ^4.0.0 version: 4.0.0 + responsive-rsc: + specifier: 0.0.7 + version: 0.0.7(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -13097,6 +13100,12 @@ packages: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} + responsive-rsc@0.0.7: + resolution: {integrity: sha512-M0OxXCHJWL+QUUf3McCM5dd/7lmHXoUK1xoshVdInCGfRVq2L9MvhLiWfs6xcELutUwd6bjNQrTE3HLlpqFtZQ==} + peerDependencies: + next: '>=13' + react: '>=18' + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -32160,6 +32169,11 @@ snapshots: dependencies: lowercase-keys: 3.0.0 + responsive-rsc@0.0.7(next@15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): + dependencies: + next: 15.1.6(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1