diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index 39a89144148..7229331fd2e 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -6,7 +6,6 @@ import type { EcosystemWalletStats, EngineCloudStats, InAppWalletStats, - RpcMethodStats, TransactionStats, UniversalBridgeStats, UniversalBridgeWalletStats, @@ -40,6 +39,18 @@ interface InsightUsageStats { totalRequests: number; } +export interface RpcMethodStats { + date: string; + evmMethod: string; + count: number; +} + +export interface RpcUsageTypeStats { + date: string; + usageType: string; + count: number; +} + async function fetchAnalytics( input: string | URL, init?: RequestInit, @@ -251,6 +262,26 @@ export async function getRpcMethodUsage( return json.data as RpcMethodStats[]; } +export async function getRpcUsageByType( + params: AnalyticsQueryParams, +): Promise { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics( + `v2/rpc/usage-types?${searchParams.toString()}`, + { + method: "GET", + }, + ); + + if (res?.status !== 200) { + console.error("Failed to fetch RPC usage"); + return []; + } + + const json = await res.json(); + return json.data as RpcUsageTypeStats[]; +} + export async function getWalletUsers( params: AnalyticsQueryParams, ): Promise { diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index d294b537cde..1c91f29e00a 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -39,12 +39,6 @@ export interface TransactionStats { count: number; } -export interface RpcMethodStats { - date: string; - evmMethod: string; - count: number; -} - export interface EngineCloudStats { date: string; chainId: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx index 69c24ba4a42..1b8de7d71f8 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx @@ -7,6 +7,7 @@ import { CoinsIcon, HomeIcon, LockIcon, + RssIcon, SettingsIcon, WalletIcon, } from "lucide-react"; @@ -103,6 +104,11 @@ export function ProjectSidebarLayout(props: { icon: SmartAccountIcon, label: "Account Abstraction", }, + { + href: `${layoutPath}/rpc`, + icon: RssIcon, + label: "RPC", + }, { href: `${layoutPath}/vault`, icon: LockIcon, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx index 9d90c4569ec..0021be1fee5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { RpcMethodStats } from "@/api/analytics"; import { BadgeContainer } from "@/storybook/utils"; -import type { RpcMethodStats } from "@/types/analytics"; import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI"; const meta = { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx index 1b51463aaa4..0c43b5e2f08 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/RpcMethodBarChartCardUI.tsx @@ -7,6 +7,7 @@ import { BarChart as RechartsBarChart, XAxis, } from "recharts"; +import type { RpcMethodStats } from "@/api/analytics"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, @@ -14,7 +15,6 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { RpcMethodStats } from "@/types/analytics"; import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard"; export function RpcMethodBarChartCardUI({ diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx index 40f2b0b35cb..2e905a0ca98 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/insight/components/InsightAnalytics.tsx @@ -121,7 +121,7 @@ export async function InsightAnalytics(props: { if (!hasVolume) { return ( -
+
); @@ -134,22 +134,18 @@ export async function InsightAnalytics(props: {
+
- - -
- -
- - -
+ +
+ +
} searchParamsUsed={["from", "to", "interval"]} > -
+
-
+

Insight @@ -36,8 +36,6 @@ export default async function Layout(props: {

- -
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx new file mode 100644 index 00000000000..a49f84b2fcd --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/MethodsTable.tsx @@ -0,0 +1,132 @@ +"use client"; +import { useMemo, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { RpcMethodStats } from "@/api/analytics"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { Card } from "@/components/ui/card"; +import { SkeletonContainer } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CardHeading } from "../../universal-bridge/components/common"; + +export function TopRPCMethodsTable(props: { + data: RpcMethodStats[]; + client: ThirdwebClient; +}) { + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 30; + + const sortedData = useMemo(() => { + return props.data?.sort((a, b) => b.count - a.count) || []; + }, [props.data]); + + const totalPages = useMemo(() => { + return Math.ceil(sortedData.length / itemsPerPage); + }, [sortedData.length]); + + const tableData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return sortedData.slice(startIndex, endIndex); + }, [sortedData, currentPage]); + + const isEmpty = useMemo(() => sortedData.length === 0, [sortedData]); + + return ( + + {/* header */} +
+ Top EVM Methods Called +
+ +
+ + + + + Method + Requests + + + + {tableData.map((method, i) => { + return ( + + ); + })} + +
+ {isEmpty && ( +
+ No data available +
+ )} +
+ + {!isEmpty && totalPages > 1 && ( +
+ +
+ )} + + ); +} + +function MethodTableRow(props: { + method?: { + evmMethod: string; + count: number; + }; + client: ThirdwebClient; + rowIndex: number; +}) { + const delayAnim = { + animationDelay: `${props.rowIndex * 100}ms`, + }; + + return ( + + + ( +

+ {v} +

+ )} + skeletonData="..." + style={delayAnim} + /> +
+ + { + return

{shortenLargeNumber(v)}

; + }} + skeletonData={0} + style={delayAnim} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx new file mode 100644 index 00000000000..c1964d2c2f9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RequestsGraph.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { format } from "date-fns"; +import { shortenLargeNumber } from "thirdweb/utils"; +import type { RpcUsageTypeStats } from "@/api/analytics"; +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; + +export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) { + return ( + new Date(a.date).getTime() - new Date(b.date).getTime()) + .reduce( + (acc, curr) => { + const existingEntry = acc.find((e) => e.time === curr.date); + if (existingEntry) { + existingEntry.requests += curr.count; + } else { + acc.push({ + requests: curr.count, + time: curr.date, + }); + } + return acc; + }, + [] as { requests: number; time: string }[], + )} + header={{ + description: "Requests over time.", + title: "RPC Requests", + }} + hideLabel={false} + isPending={false} + showLegend + toolTipLabelFormatter={(label) => { + return format(label, "MMM dd, HH:mm"); + }} + toolTipValueFormatter={(value) => { + return shortenLargeNumber(value as number); + }} + xAxis={{ + sameDay: true, + }} + yAxis + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx new file mode 100644 index 00000000000..a3854c546f5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalytics.tsx @@ -0,0 +1,98 @@ +import { ActivityIcon } from "lucide-react"; +import { ResponsiveSuspense } from "responsive-rsc"; +import type { ThirdwebClient } from "thirdweb"; +import { getRpcMethodUsage, getRpcUsageByType } from "@/api/analytics"; +import type { Range } from "@/components/analytics/date-range-selector"; +import { StatCard } from "@/components/analytics/stat"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TopRPCMethodsTable } from "./MethodsTable"; +import { RequestsGraph } from "./RequestsGraph"; +import { RpcAnalyticsFilter } from "./RpcAnalyticsFilter"; +import { RpcFTUX } from "./RpcFtux"; + +export async function RPCAnalytics(props: { + projectClientId: string; + client: ThirdwebClient; + projectId: string; + teamId: string; + range: Range; + interval: "day" | "week"; +}) { + const { projectId, teamId, range, interval } = props; + + // TODO: add requests by status code, but currently not performant enough + const allRequestsByUsageTypePromise = getRpcUsageByType({ + from: range.from, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const requestsByUsageTypePromise = getRpcUsageByType({ + from: range.from, + period: interval, + projectId: projectId, + teamId: teamId, + to: range.to, + }); + const evmMethodsPromise = getRpcMethodUsage({ + from: range.from, + period: "all", + projectId: projectId, + teamId: teamId, + to: range.to, + }).catch((error) => { + console.error(error); + return []; + }); + + const [allUsageData, usageData, evmMethodsData] = await Promise.all([ + allRequestsByUsageTypePromise, + requestsByUsageTypePromise, + evmMethodsPromise, + ]); + + const totalRequests = allUsageData.reduce((acc, curr) => acc + curr.count, 0); + + if (totalRequests < 1) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ + + + +
+ } + searchParamsUsed={["from", "to", "interval"]} + > +
+
+ +
+ + +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx new file mode 100644 index 00000000000..2ebf01ba6c6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcAnalyticsFilter.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "@/components/analytics/date-range-selector"; +import { IntervalSelector } from "@/components/analytics/interval-selector"; +import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time"; + +type SearchParams = { + from?: string; + to?: string; + interval?: "day" | "week"; +}; + +export function RpcAnalyticsFilter() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: responsiveSearchParams.from, + interval: responsiveSearchParams.interval, + to: responsiveSearchParams.to, + }); + + return ( +
+ { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + return newParams; + }); + }} + /> + + { + setResponsiveSearchParams((v: SearchParams) => { + const newParams = { + ...v, + interval: newInterval, + }; + return newParams; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx new file mode 100644 index 00000000000..90d87f6bba7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/components/RpcFtux.tsx @@ -0,0 +1,104 @@ +import { CodeServer } from "@/components/ui/code/code.server"; +import { isProd } from "@/constants/env-utils"; +import { ClientIDSection } from "../../components/ProjectFTUX/ClientIDSection"; +import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard"; + +export function RpcFTUX(props: { clientId: string }) { + return ( + + ), + label: "JavaScript", + }, + { + code: ( + + ), + label: "Python", + }, + { + code: ( + + ), + label: "Curl", + }, + ]} + ctas={[ + { + href: "https://portal.thirdweb.com/rpc-edge", + label: "View Docs", + }, + ]} + title="Start Using RPC" + > + +
+ + ); +} + +const twDomain = isProd ? "thirdweb" : "thirdweb-dev"; + +const jsCode = (clientId: string) => `\ +// Example: Get latest block number on Ethereum +const res = await fetch("https://1.rpc.${twDomain}.com/${clientId}", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_blockNumber", + params: [], + id: 1, + }), +}); +const data = await res.json(); +console.log("Latest block number:", parseInt(data.result, 16)); +`; + +const curlCode = (clientId: string) => `\ +# Example: Get latest block number on Ethereum +curl -X POST "https://1.rpc.${twDomain}.com/${clientId}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + }' +`; + +const pythonCode = (clientId: string) => `\ +# Example: Get latest block number on Ethereum +import requests +import json + +response = requests.post( + "https://1.rpc.${twDomain}.com/${clientId}", + headers={"Content-Type": "application/json"}, + json={ + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + } +) +data = response.json() +print("Latest block number:", int(data["result"], 16)) +`; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx new file mode 100644 index 00000000000..1fbfa67c9f2 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/layout.tsx @@ -0,0 +1,46 @@ +import { redirect } from "next/navigation"; +import { getProject } from "@/api/projects"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; + +export default async function Layout(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; + children: React.ReactNode; +}) { + const params = await props.params; + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + return ( +
+
+
+

+ RPC +

+

+ Remote Procedure Call (RPC) provides reliable access to querying + data and interacting with the blockchain through global edge RPCs.{" "} + + Learn more + +

+
+
+ +
+
+ {props.children} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx new file mode 100644 index 00000000000..6730b6c1d22 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/rpc/page.tsx @@ -0,0 +1,61 @@ +import { loginRedirect } from "@app/login/loginRedirect"; +import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/projects"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getFiltersFromSearchParams } from "@/lib/time"; +import { RPCAnalytics } from "./components/RpcAnalytics"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + }>; + searchParams: Promise<{ + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + }>; +}) { + const [params, authToken] = await Promise.all([props.params, getAuthToken()]); + + const project = await getProject(params.team_slug, params.project_slug); + + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/${params.project_slug}/rpc`); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const searchParams = await props.searchParams; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + return ( + +
+ +
+
+ + ); +}