diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index a6f2819e1c6..fb04bbadfac 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -13,6 +13,9 @@ import type { UserOpStats, WalletStats, WalletUserStats, + WebhookLatencyStats, + WebhookRequestStats, + WebhookSummaryStats, } from "@/types/analytics"; import { getAuthToken } from "./auth-token"; import { getChains } from "./chain"; @@ -424,3 +427,69 @@ export async function getEngineCloudMethodUsage( const json = await res.json(); return json.data as EngineCloudStats[]; } + +export async function getWebhookRequests( + params: AnalyticsQueryParams & { webhookId?: string }, +): Promise { + const searchParams = buildSearchParams(params); + if (params.webhookId) { + searchParams.append("webhookId", params.webhookId); + } + const res = await fetchAnalytics( + `v2/webhook/requests?${searchParams.toString()}`, + ); + if (res.status !== 200) { + const reason = await res.text(); + console.error( + `Failed to fetch webhook request stats: ${res.status} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as WebhookRequestStats[]; +} + +export async function getWebhookLatency( + params: AnalyticsQueryParams & { webhookId?: string }, +): Promise { + const searchParams = buildSearchParams(params); + if (params.webhookId) { + searchParams.append("webhookId", params.webhookId); + } + const res = await fetchAnalytics( + `v2/webhook/latency?${searchParams.toString()}`, + ); + if (res.status !== 200) { + const reason = await res.text(); + console.error( + `Failed to fetch webhook latency stats: ${res.status} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as WebhookLatencyStats[]; +} + +export async function getWebhookSummary( + params: AnalyticsQueryParams & { webhookId?: string }, +): Promise { + const searchParams = buildSearchParams(params); + if (params.webhookId) { + searchParams.append("webhookId", params.webhookId); + } + const res = await fetchAnalytics( + `v2/webhook/summary?${searchParams.toString()}`, + ); + if (res.status !== 200) { + const reason = await res.text(); + console.error( + `Failed to fetch webhook summary stats: ${res.status} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as WebhookSummaryStats[]; +} diff --git a/apps/dashboard/src/@/api/webhooks.ts b/apps/dashboard/src/@/api/webhooks.ts new file mode 100644 index 00000000000..536111778bd --- /dev/null +++ b/apps/dashboard/src/@/api/webhooks.ts @@ -0,0 +1,49 @@ +"use server"; +import "server-only"; + +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; +import { getAuthToken } from "./auth-token"; + +export type WebhookConfig = { + id: string; + description: string | null; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + teamId: string; + projectId: string; + destinationUrl: string; + pausedAt: Date | null; + webhookSecret: string; +}; + +export async function getWebhookConfigs( + teamIdOrSlug: string, + projectIdOrSlug: string, +): Promise<{ data: WebhookConfig[] } | { error: string }> { + const token = await getAuthToken(); + if (!token) { + return { error: "Unauthorized." }; + } + + const res = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/projects/${projectIdOrSlug}/webhook-configs`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + try { + const json = (await res.json()) as { + data: WebhookConfig[]; + error: { message: string }; + }; + if (json.error) { + return { error: json.error.message }; + } + return { data: json.data }; + } catch { + return { error: "Failed to fetch webhooks." }; + } +} diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index 895da7b95df..0dac63dbb7c 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -79,3 +79,28 @@ export interface AnalyticsQueryParams { to?: Date; period?: "day" | "week" | "month" | "year" | "all"; } + +export interface WebhookRequestStats { + date: string; + webhookId: string; + httpStatusCode: number; + totalRequests: number; +} + +export interface WebhookLatencyStats { + date: string; + webhookId: string; + p50LatencyMs: number; + p90LatencyMs: number; + p99LatencyMs: number; +} + +export interface WebhookSummaryStats { + webhookId: string; + totalRequests: number; + successRequests: number; + errorRequests: number; + successRate: number; + avgLatencyMs: number; + errorBreakdown: Record; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/analytics-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/analytics-page.tsx new file mode 100644 index 00000000000..f9bb1dc807c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/analytics-page.tsx @@ -0,0 +1,169 @@ +import { subHours } from "date-fns"; +import { AlertTriangleIcon, ClockIcon } from "lucide-react"; +import { toast } from "sonner"; +import { + getWebhookLatency, + getWebhookRequests, + getWebhookSummary, +} from "@/api/analytics"; +import type { Project } from "@/api/projects"; +import { getWebhookConfigs, type WebhookConfig } from "@/api/webhooks"; +import { + getLastNDaysRange, + type Range, +} from "@/components/analytics/date-range-selector"; +import { RangeSelector } from "@/components/analytics/range-selector"; +import { StatCard } from "@/components/analytics/stat"; +import type { + WebhookLatencyStats, + WebhookRequestStats, + WebhookSummaryStats, +} from "@/types/analytics"; +import { LatencyChart } from "./latency-chart"; +import { StatusCodesChart } from "./status-codes-chart"; +import { WebhookSelector } from "./webhook-selector"; + +type WebhookAnalyticsProps = { + interval: "day" | "week"; + range: Range; + selectedWebhookId: string | null; + webhooksConfigs: WebhookConfig[]; + requestStats: WebhookRequestStats[]; + latencyStats: WebhookLatencyStats[]; + summaryStats: WebhookSummaryStats[]; +}; + +function WebhookAnalytics({ + interval, + range, + selectedWebhookId, + webhooksConfigs, + requestStats, + latencyStats, + summaryStats, +}: WebhookAnalyticsProps) { + // Calculate overview metrics for the last 24 hours + const errorRate = 100 - (summaryStats[0]?.successRate || 0); + const avgLatency = summaryStats[0]?.avgLatencyMs || 0; + + // Transform request data for combined chart. + const allRequestsData = requestStats + .reduce((acc, stat) => { + const statusCode = stat.httpStatusCode.toString(); + const existingEntry = acc.find((entry) => entry.time === stat.date); + if (existingEntry) { + existingEntry[statusCode] = + (existingEntry[statusCode] || 0) + stat.totalRequests; + } else { + acc.push({ + time: stat.date, + [statusCode]: stat.totalRequests, + }); + } + return acc; + }, [] as any[]) + .sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + // Transform latency data for line chart. + const latencyData = latencyStats + .map((stat) => ({ ...stat, time: stat.date })) + .sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + return ( +
+ + +
+ `${value.toFixed(2)}%`} + icon={AlertTriangleIcon} + isPending={false} + label="Error Rate (24h)" + value={errorRate} + /> + `${value.toFixed(0)}ms`} + icon={ClockIcon} + isPending={false} + label="P50 Latency (24h)" + value={avgLatency} + /> +
+ + + +
+ + +
+
+ ); +} + +const DEFAULT_RANGE = getLastNDaysRange("last-120"); +const DEFAULT_INTERVAL = "week" as const; + +export async function AnalyticsPageContent({ + teamSlug, + project, + searchParams, +}: { + teamSlug: string; + project: Project; + searchParams?: { [key: string]: string | string[] | undefined }; +}) { + // Parse search params for filters + const selectedWebhookId = searchParams?.webhookId as string | undefined; + const interval = + (searchParams?.interval as "day" | "week") || DEFAULT_INTERVAL; + const range = DEFAULT_RANGE; // Could be enhanced to parse from search params + + // Get webhook configs. + const webhooksConfigsResponse = await getWebhookConfigs(teamSlug, project.id); + if ("error" in webhooksConfigsResponse) { + toast.error(webhooksConfigsResponse.error); + return null; + } + + // Get webhook analytics. + const [requestStats, latencyStats, summaryStats] = await Promise.all([ + getWebhookRequests({ + teamId: project.teamId, + projectId: project.id, + from: range.from, + period: interval, + to: range.to, + webhookId: selectedWebhookId || undefined, + }).catch(() => []), + getWebhookLatency({ + teamId: project.teamId, + projectId: project.id, + from: range.from, + period: interval, + to: range.to, + webhookId: selectedWebhookId || undefined, + }).catch(() => []), + getWebhookSummary({ + teamId: project.teamId, + projectId: project.id, + from: subHours(new Date(), 24), + to: new Date(), + webhookId: selectedWebhookId || undefined, + }).catch(() => []), + ]); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/latency-chart.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/latency-chart.tsx new file mode 100644 index 00000000000..4991a88eec7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/latency-chart.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import type { ChartConfig } from "@/components/ui/chart"; + +interface LatencyChartProps { + data: Array<{ + time: string; + p50LatencyMs: number; + p90LatencyMs: number; + p99LatencyMs: number; + }>; + isPending: boolean; +} + +const latencyChartConfig = { + p50LatencyMs: { + color: "hsl(142, 76%, 36%)", // Green for best performance + label: "P50", + }, + p90LatencyMs: { + color: "hsl(45, 93%, 47%)", // Yellow for warning level + label: "P90", + }, + p99LatencyMs: { + color: "hsl(0, 84%, 60%)", // Red for critical level + label: "P99", + }, +} satisfies ChartConfig; + +export function LatencyChart({ data, isPending }: LatencyChartProps) { + return ( + `${value}ms`} + xAxis={{ sameDay: false }} + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/status-codes-chart.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/status-codes-chart.tsx new file mode 100644 index 00000000000..c593ea8f09a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/status-codes-chart.tsx @@ -0,0 +1,40 @@ +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import type { ChartConfig } from "@/components/ui/chart"; + +const stackedRequestChartConfig = { + "200": { + color: "hsl(142, 76%, 36%)", // Green for success + label: "Success (2xx)", + }, + "400": { + color: "hsl(45, 93%, 47%)", // Yellow for client errors + label: "Client Error (4xx)", + }, + "500": { + color: "hsl(0, 84%, 60%)", // Red for server errors + label: "Server Error (5xx)", + }, +} satisfies ChartConfig; + +interface StatusCodesChartProps { + data: any[]; + isPending: boolean; +} + +export function StatusCodesChart({ data, isPending }: StatusCodesChartProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/webhook-selector.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/webhook-selector.tsx new file mode 100644 index 00000000000..21ea9df51dd --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/webhook-selector.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import type { WebhookConfig } from "@/api/webhooks"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface WebhookSelectorProps { + webhooks: WebhookConfig[]; + selectedWebhookId: string | null; +} + +export function WebhookSelector({ + webhooks, + selectedWebhookId, +}: WebhookSelectorProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + return ( +
+
Webhook Filter
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/contract/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/contract/page.tsx new file mode 100644 index 00000000000..9ff20b639f8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/contract/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/projects"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page"; + +export default async function Page({ + params, +}: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const [authToken, resolvedParams] = await Promise.all([ + getAuthToken(), + params, + ]); + + const project = await getProject( + resolvedParams.team_slug, + resolvedParams.project_slug, + ); + + if (!project || !authToken) { + notFound(); + } + + return ( +
+

+ Contract Webhooks +

+

+ Get notified about blockchain events, transactions and more.{" "} + + Learn more + +

+
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx index c76bc502dfb..2472c9ff151 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx @@ -25,9 +25,14 @@ export default async function WebhooksLayout(props: { links={[ { exactMatch: true, - name: "Contract", + name: "Analytics", path: `/team/${params.team_slug}/${params.project_slug}/webhooks`, }, + { + exactMatch: true, + name: "Contract", + path: `/team/${params.team_slug}/${params.project_slug}/webhooks/contract`, + }, { name: "Universal Bridge", path: `/team/${params.team_slug}/${params.project_slug}/webhooks/universal-bridge`, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx index e37517b0622..1b930b65ec7 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/page.tsx @@ -1,17 +1,19 @@ import { notFound } from "next/navigation"; import { getAuthToken } from "@/api/auth-token"; import { getProject } from "@/api/projects"; -import { UnderlineLink } from "@/components/ui/UnderlineLink"; -import { ContractsWebhooksPageContent } from "./contract-webhooks/contract-webhooks-page"; +import { AnalyticsPageContent } from "./analytics/analytics-page"; export default async function WebhooksPage({ params, + searchParams, }: { params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const [authToken, resolvedParams] = await Promise.all([ + const [authToken, resolvedParams, resolvedSearchParams] = await Promise.all([ getAuthToken(), params, + searchParams, ]); const project = await getProject( @@ -25,21 +27,16 @@ export default async function WebhooksPage({ return (
-

- Contract Webhooks -

+

Analytics

- Get notified about blockchain events, transactions and more.{" "} - - Learn more - + Review your webhooks usage and errors.

-
- +
+
); }