diff --git a/apps/dashboard/src/@/api/usage/rpc.ts b/apps/dashboard/src/@/api/usage/rpc.ts index 97112960966..e999ceb8fd7 100644 --- a/apps/dashboard/src/@/api/usage/rpc.ts +++ b/apps/dashboard/src/@/api/usage/rpc.ts @@ -47,8 +47,67 @@ export const fetchRPCUsage = unstable_cache( data: resData.data as RPCUsageDataItem[], }; }, - ["nebula-analytics"], + ["rpc-usage"], { revalidate: 60 * 60, // 1 hour }, ); + +type Last24HoursRPCUsageApiResponse = { + peakRate: { + date: string; + peakRPS: number; + }; + averageRate: { + date: string; + averageRate: number; + includedCount: number; + rateLimitedCount: number; + overageCount: number; + }[]; + totalCounts: { + includedCount: number; + rateLimitedCount: number; + overageCount: number; + }; +}; + +export const getLast24HoursRPCUsage = unstable_cache( + async (params: { + teamId: string; + projectId?: string; + authToken: string; + }) => { + const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string; + const url = new URL(`${analyticsEndpoint}/v2/rpc/24-hours`); + url.searchParams.set("teamId", params.teamId); + if (params.projectId) { + url.searchParams.set("projectId", params.projectId); + } + + 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 Last24HoursRPCUsageApiResponse, + }; + }, + ["rpc-usage-last-24-hours"], + { + revalidate: 60, // 1 minute + }, +); diff --git a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx index 8de8fff2ba9..a232b562042 100644 --- a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; @@ -17,7 +18,7 @@ import { } from "@/components/ui/chart"; import { formatDate } from "date-fns"; import { useMemo } from "react"; -import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { EmptyChartState, LoadingChartState, @@ -30,11 +31,17 @@ type ThirdwebAreaChartProps = { description?: string; titleClassName?: string; }; + footer?: React.ReactNode; customHeader?: React.ReactNode; // chart config config: TConfig; data: Array & { time: number | string | Date }>; showLegend?: boolean; + maxLimit?: number; + yAxis?: boolean; + xAxis?: { + sameDay?: boolean; + }; // chart className chartClassName?: string; @@ -70,17 +77,33 @@ export function ThirdwebAreaChart( ) : props.data.length === 0 ? ( ) : ( - + ({ + ...d, + maxLimit: props.maxLimit, + })) + : props.data + } + > + {props.yAxis && } formatDate(new Date(value), "MMM dd")} + tickFormatter={(value) => + formatDate( + new Date(value), + props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd", + ) + } /> ( stackId="a" /> ))} + {props.maxLimit && ( + + )} {props.showLegend && ( ( )} + {props.footer && ( + {props.footer} + )} ); } diff --git a/apps/dashboard/src/@/components/ui/chart.tsx b/apps/dashboard/src/@/components/ui/chart.tsx index a452dbddc36..0a07dd7a0af 100644 --- a/apps/dashboard/src/@/components/ui/chart.tsx +++ b/apps/dashboard/src/@/components/ui/chart.tsx @@ -243,7 +243,9 @@ const ChartTooltipContent = React.forwardRef<
{nestLabel ? tooltipLabel : null} - {itemConfig?.label || item.name} + {item.name === "maxLimit" + ? "Upper Limit" + : itemConfig?.label || item.name}
{item.value !== undefined && ( diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx index 7bd6c02a61f..a9b013f3908 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/layout.tsx @@ -23,6 +23,11 @@ export default async function Layout(props: { exactMatch: true, label: "Overview", }, + { + href: `/team/${params.team_slug}/~/usage/rpc`, + exactMatch: true, + label: "RPC", + }, { href: `/team/${params.team_slug}/~/usage/storage`, exactMatch: true, diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/components/count-graph.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/components/count-graph.tsx new file mode 100644 index 00000000000..688380501f0 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/components/count-graph.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import { formatDate } from "date-fns"; + +export function CountGraph(props: { + peakPercentage: number; + currentRateLimit: number; + data: { + date: string; + includedCount: number; + overageCount: number; + rateLimitedCount: number; + }[]; +}) { + return ( + { + return formatDate(new Date(label), "MMM dd, HH:mm"); + }} + data={props.data + .slice(1, -1) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .map((v) => ({ + time: v.date, + includedCount: v.includedCount + v.overageCount, + rateLimitedCount: v.rateLimitedCount, + }))} + isPending={false} + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/components/rate-graph.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/components/rate-graph.tsx new file mode 100644 index 00000000000..ad781219b81 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/components/rate-graph.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import { formatDate } from "date-fns"; +import { InfoIcon } from "lucide-react"; + +export function RateGraph(props: { + peakPercentage: number; + currentRateLimit: number; + data: { date: string; averageRate: number }[]; +}) { + return ( + 80 ? ( +
+ +

+ The red dashed line represents your current plan rate limit ( + {props.currentRateLimit} RPS) +

+
+ ) : undefined + } + config={{ + averageRate: { + label: "Average RPS", + color: "hsl(var(--chart-1))", + }, + }} + data={props.data + .slice(1, -1) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .map((v) => ({ + time: v.date, + averageRate: Number(v.averageRate.toFixed(2)), + }))} + yAxis + xAxis={{ + sameDay: true, + }} + hideLabel={false} + toolTipLabelFormatter={(label) => { + return formatDate(new Date(label), "MMM dd, HH:mm"); + }} + // only show the upper limit if the peak usage is greater than 80% + maxLimit={props.peakPercentage > 80 ? props.currentRateLimit : undefined} + isPending={false} + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/page.tsx new file mode 100644 index 00000000000..8d8479495d9 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/usage/rpc/page.tsx @@ -0,0 +1,209 @@ +import { getTeamBySlug } from "@/api/team"; +import { getLast24HoursRPCUsage } from "@/api/usage/rpc"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { format } from "date-fns"; +import { + AlertTriangleIcon, + CheckCircleIcon, + ClockIcon, + XCircleIcon, +} from "lucide-react"; +import { redirect } from "next/navigation"; +import { getAuthToken } from "../../../../../../api/lib/getAuthToken"; +import { TeamPlanBadge } from "../../../../../../components/TeamPlanBadge"; +import { loginRedirect } from "../../../../../../login/loginRedirect"; +import { CountGraph } from "./components/count-graph"; +import { RateGraph } from "./components/rate-graph"; + +export default async function RPCUsage(props: { + params: Promise<{ + team_slug: string; + }>; +}) { + const params = await props.params; + const authToken = await getAuthToken(); + + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/~/usage/storage`); + } + + const team = await getTeamBySlug(params.team_slug); + if (!team) { + redirect("/team"); + } + + const currentPlan = team.billingPlan; + const currentRateLimit = team.capabilities.rpc.rateLimit; + + const apiData = await getLast24HoursRPCUsage({ + teamId: team.id, + authToken, + }); + + if (!apiData.ok) { + return
Error fetching data, please try again later
; + } + + const { peakRate, totalCounts } = apiData.data; + + // Calculate percentage of limit for the peak + const peakPercentage = (peakRate.peakRPS / currentRateLimit) * 100; + + // Determine status based on peak percentage + const getStatusColor = (percentage: number) => { + if (percentage < 70) return "bg-green-500"; + if (percentage < 90) return "bg-yellow-500"; + return "bg-red-500"; + }; + + // Calculate total requests + const totalRequests = + Number(totalCounts.includedCount) + + Number(totalCounts.rateLimitedCount) + + Number(totalCounts.overageCount); + + return ( +
+
+

+ RPC Usage Dashboard +

+

+ Monitor your RPC usage and rate limits for the last 24 hours +

+
+ +
+ + + + Plan Rate Limit + + + +
+
+ {currentRateLimit} RPS +
+ +
+
+
+ + + + Peak Usage + + +
+
+ {peakRate.peakRPS.toFixed(1)} RPS +
+
+
+ + {peakPercentage > 100 + ? `${(peakPercentage - 100).toFixed(0)}% over limit` + : `${peakPercentage.toFixed(0)}% of limit`} + +
+
+

+ + {format(new Date(peakRate.date), "MMM d, HH:mm")} +

+ + + + + + + Total Requests + + + +
+ {totalRequests.toLocaleString()} +
+
+ + + {/* we count both included and overage as included */} + {( + ((Number(totalCounts.includedCount) + + Number(totalCounts.overageCount)) / + Number(totalRequests)) * + 100 + ).toFixed(1)} + % successful requests + +
+
+
+ + + + Rate Limited + + +
+ {totalCounts.rateLimitedCount.toLocaleString()} +
+
+ + + {/* if there are no requests, we show 100% success rate */} + {totalRequests === 0 + ? "100" + : ( + (Number(totalCounts.rateLimitedCount) / + Number(totalRequests)) * + 100 + ).toFixed(1)} + % of requests + +
+
+
+
+ + {peakPercentage > 100 && ( + + + Rate Limit Exceeded + + Your peak usage of {peakRate.peakRPS.toFixed(1)} RPS has exceeded + your plan limit of {currentRateLimit} RPS. Consider upgrading your + plan to avoid rate limiting. + + + )} + + {peakPercentage > 80 && peakPercentage <= 100 && ( + + + Approaching Rate Limit + + Your peak usage is at {peakPercentage.toFixed(0)}% of your plan + limit. You may experience rate limiting during high traffic periods. + + + )} + + + + +
+ ); +}