feat: add Engine Cloud analytics to project overview (#7195)
# [Dashboard] Feature: Add Engine Cloud Analytics Charts Example screenshot:  ## Notes for the reviewer This PR adds Engine Cloud analytics to the dashboard, displaying request data in a bar chart visualization. The implementation includes: 1. A new API endpoint to fetch Engine Cloud method usage data 2. New UI components for displaying Engine Cloud analytics: - `EngineCloudBarChartCardUI` - Shows Engine Cloud requests by pathname - `EngineCloudChartCardUI` - Shows Engine Cloud methods usage with percentage breakdown 3. Integration of the new chart into the project analytics page ## How to test - View the Storybook component at "Analytics/EngineCloudBarChartCard" - Navigate to a project dashboard to see the Engine Cloud analytics chart - Verify data is properly fetched and displayed in the chart <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces a new feature to track and visualize `EngineCloud` statistics in the dashboard, enhancing analytics capabilities with new components and API integrations. ### Detailed summary - Added `EngineCloudStats` interface in `analytics.ts`. - Implemented `getEngineCloudMethodUsage` API function. - Created `EngineCloudChartCard` component for displaying cloud stats. - Developed `EngineCloudBarChartCardUI` for rendering bar charts. - Added storybook entries for `EngineCloudBarChartCardUI`. - Enhanced existing analytics with new data visualizations. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a stacked bar chart visualization for engine cloud request statistics in the analytics dashboard. - Added a chart card displaying engine cloud method usage data with loading state support. - Enhanced analytics with time series data for multiple API pathnames. - Included engine cloud method usage data in the project analytics view. - **Chores** - Updated `.gitignore` to exclude the `.pnpm-store/` directory from version control. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -16,6 +16,7 @@ yalc.lock
|
||||
./build/
|
||||
playwright-report/
|
||||
.env/
|
||||
.pnpm-store/
|
||||
|
||||
# codecov binary
|
||||
codecov
|
||||
|
||||
@@ -3,6 +3,7 @@ import "server-only";
|
||||
import type {
|
||||
AnalyticsQueryParams,
|
||||
EcosystemWalletStats,
|
||||
EngineCloudStats,
|
||||
InAppWalletStats,
|
||||
RpcMethodStats,
|
||||
TransactionStats,
|
||||
@@ -431,3 +432,24 @@ export async function getUniversalBridgeWalletUsage(args: {
|
||||
const json = await res.json();
|
||||
return json.data as UniversalBridgeWalletStats[];
|
||||
}
|
||||
|
||||
export async function getEngineCloudMethodUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<EngineCloudStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/engine-cloud/requests?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch Engine Cloud method usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineCloudStats[];
|
||||
}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer } from "stories/utils";
|
||||
import type { EngineCloudStats } from "types/analytics";
|
||||
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
|
||||
|
||||
const meta = {
|
||||
title: "Analytics/EngineCloudBarChartCard",
|
||||
component: Component,
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Variants: Story = {
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "desktop" },
|
||||
},
|
||||
};
|
||||
|
||||
const generateTimeSeriesData = (days: number, pathnames: string[]) => {
|
||||
const data: EngineCloudStats[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
for (const pathname of pathnames) {
|
||||
data.push({
|
||||
// biome-ignore lint/style/noNonNullAssertion: we know this is not null
|
||||
date: dateStr!,
|
||||
chainId: "84532",
|
||||
pathname,
|
||||
totalRequests: Math.floor(Math.random() * 1000) + 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container max-w-6xl space-y-10 py-10">
|
||||
<BadgeContainer label="Multiple Pathnames">
|
||||
<EngineCloudBarChartCardUI
|
||||
rawData={generateTimeSeriesData(30, [
|
||||
"/v1/write/transaction",
|
||||
"/v1/sign/transaction",
|
||||
"/v1/sign/message",
|
||||
])}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
BarChart as RechartsBarChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import type { EngineCloudStats } from "types/analytics";
|
||||
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
|
||||
|
||||
export function EngineCloudBarChartCardUI({
|
||||
rawData,
|
||||
}: { rawData: EngineCloudStats[] }) {
|
||||
const { data, pathnames, chartConfig, isAllEmpty } = useMemo(() => {
|
||||
// Dynamically collect all unique pathnames
|
||||
const pathnameSet = new Set<string>();
|
||||
for (const item of rawData) {
|
||||
// Ignore empty pathname ''.
|
||||
if (item.pathname) {
|
||||
pathnameSet.add(item.pathname);
|
||||
}
|
||||
}
|
||||
const pathnames = Array.from(pathnameSet);
|
||||
|
||||
// Group by date, then by pathname
|
||||
const dateMap = new Map<string, Record<string, number>>();
|
||||
for (const { date, pathname, totalRequests } of rawData) {
|
||||
const map = dateMap.get(date) ?? {};
|
||||
map[pathname] = Number(totalRequests) || 0;
|
||||
dateMap.set(date, map);
|
||||
}
|
||||
|
||||
// Build data array for recharts
|
||||
const data = Array.from(dateMap.entries()).map(([date, value]) => {
|
||||
let total = 0;
|
||||
for (const pathname of pathnames) {
|
||||
if (!value[pathname]) value[pathname] = 0;
|
||||
total += value[pathname];
|
||||
}
|
||||
return { date, ...value, total };
|
||||
});
|
||||
|
||||
// Chart config
|
||||
const chartConfig: ChartConfig = {};
|
||||
for (const pathname of pathnames) {
|
||||
chartConfig[pathname] = { label: pathname };
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
pathnames,
|
||||
chartConfig,
|
||||
isAllEmpty: data.every((d) => d.total === 0),
|
||||
};
|
||||
}, [rawData]);
|
||||
|
||||
if (data.length === 0 || isAllEmpty) {
|
||||
return <EmptyStateCard metric="RPC" link="https://portal.thirdweb.com/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
|
||||
<CardTitle className="font-semibold text-lg">
|
||||
Engine Cloud Requests
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6 sm:pl-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full pt-6"
|
||||
>
|
||||
<RechartsBarChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(d) => formatDate(new Date(d), "MMM d")}
|
||||
valueFormatter={(_value) => {
|
||||
const value = typeof _value === "number" ? _value : 0;
|
||||
return <span className="inline-flex gap-1.5">{value}</span>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{pathnames.map((pathname, idx) => (
|
||||
<Bar
|
||||
key={pathname}
|
||||
stackId="a"
|
||||
dataKey={pathname}
|
||||
radius={[
|
||||
idx === pathnames.length - 1 ? 4 : 0,
|
||||
idx === pathnames.length - 1 ? 4 : 0,
|
||||
idx === 0 ? 4 : 0,
|
||||
idx === 0 ? 4 : 0,
|
||||
]}
|
||||
fill={`hsl(var(--chart-${idx + 1}))`}
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { getEngineCloudMethodUsage } from "@/api/analytics";
|
||||
import { LoadingChartState } from "components/analytics/empty-chart-state";
|
||||
import { Suspense } from "react";
|
||||
import type { AnalyticsQueryParams } from "types/analytics";
|
||||
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
|
||||
|
||||
export function EngineCloudChartCard(props: AnalyticsQueryParams) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[400px]">
|
||||
<LoadingChartState />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EngineCloudChartCardAsync {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) {
|
||||
const rawData = await getEngineCloudMethodUsage(props);
|
||||
|
||||
return <EngineCloudBarChartCardUI rawData={rawData} />;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
|
||||
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
|
||||
import { EngineCloudChartCard } from "./components/EngineCloudChartCard";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsCharts } from "./components/Transactions";
|
||||
@@ -279,6 +280,13 @@ async function ProjectAnalytics(props: {
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
<EngineCloudChartCard
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
period={interval}
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,13 @@ export interface RpcMethodStats {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface EngineCloudStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
pathname: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface UniversalBridgeStats {
|
||||
date: string;
|
||||
chainId: number;
|
||||
|
||||
Reference in New Issue
Block a user