Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,7 +66,11 @@ export function DatePickerWithRange(props: {
</PopoverTrigger>

{/* Popover */}
<PopoverContent className="w-auto p-0" align="start">
<PopoverContent
className="w-auto p-0"
align={props.popoverAlign || "start"}
sideOffset={10}
>
<DynamicHeight>
<div>
{!isValid && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<NebulaAnalyticsPage
accountId={account.id}
authToken={authToken}
searchParams={searchParams}
/>
);
}

return <NebulaWaitListPage team={team} />;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -45,9 +45,6 @@ export default async function TeamLayout(props: {
redirect(`/team/${params.team_slug}`);
}

const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
?.onWaitlist;

return (
<div className="flex grow flex-col">
<div className="bg-card">
Expand All @@ -59,7 +56,6 @@ export default async function TeamLayout(props: {
/>
<ProjectTabs
layoutPath={`/team/${params.team_slug}/${params.project_slug}`}
isOnNebulaWaitList={!!isOnNebulaWaitList}
/>
</div>
<div className="flex grow flex-col">{props.children}</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
},
);
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3">
<DateRangeSelector
range={range}
popoverAlign="end"
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
return {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
});
}}
/>

<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
return {
...v,
interval: newInterval,
};
});
}}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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<typeof Story>;

export default meta;
type Story = StoryObj<typeof meta>;

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<VariantTab>("60-day");
return (
<div className="container flex flex-col gap-8 py-10">
<div>
<h2 className="mb-1 font-semibold text-xl tracking-tight">
Story Variants
</h2>
<TabButtons
tabs={[
{
name: "60 Days",
onClick: () => 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,
},
]}
/>
</div>

{tab === "60-day" && (
<NebulaAnalyticsDashboardUI
data={generateRandomNebulaAnalyticsData(60)}
isPending={false}
/>
)}

{tab === "30-day" && (
<NebulaAnalyticsDashboardUI
data={generateRandomNebulaAnalyticsData(30)}
isPending={false}
/>
)}

{tab === "7-day" && (
<NebulaAnalyticsDashboardUI
data={generateRandomNebulaAnalyticsData(7)}
isPending={false}
/>
)}

{tab === "pending" && (
<NebulaAnalyticsDashboardUI data={[]} isPending={true} />
)}
</div>
);
}

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);
}
Loading
Loading