diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index e6ec6b3b632..438abb63e56 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY= # posthog API key # - not required for prod/staging -NEXT_PUBLIC_POSTHOG_API_KEY="ignored" +NEXT_PUBLIC_POSTHOG_KEY="" # Stripe Customer portal NEXT_PUBLIC_STRIPE_KEY= diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index cf51306e015..35ac85fda9d 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -55,6 +55,7 @@ "papaparse": "^5.5.3", "pluralize": "^8.0.0", "posthog-js": "1.256.1", + "posthog-node": "^5.4.0", "prettier": "3.6.2", "qrcode": "^1.5.3", "react": "19.1.0", diff --git a/apps/dashboard/src/@/analytics/posthog-server.ts b/apps/dashboard/src/@/analytics/posthog-server.ts new file mode 100644 index 00000000000..87167f267be --- /dev/null +++ b/apps/dashboard/src/@/analytics/posthog-server.ts @@ -0,0 +1,45 @@ +import "server-only"; +import { PostHog } from "posthog-node"; + +let posthogServer: PostHog | null = null; + +function getPostHogServer(): PostHog | null { + if (!posthogServer && process.env.NEXT_PUBLIC_POSTHOG_KEY) { + posthogServer = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + host: "https://us.i.posthog.com", + }); + } + return posthogServer; +} + +/** + * Check if a feature flag is enabled for a specific user + * @param flagKey - The feature flag key + * @param userEmail - The user's email address for filtering + */ +export async function isFeatureFlagEnabled( + flagKey: string, + userEmail?: string, +): Promise { + // For localdev environments where Posthog is not running, enable all feature flags. + if (!posthogServer) { + return true; + } + + try { + const client = getPostHogServer(); + if (client && userEmail) { + const isEnabled = await client.isFeatureEnabled(flagKey, userEmail, { + personProperties: { + email: userEmail, + }, + }); + if (isEnabled !== undefined) { + return isEnabled; + } + } + } catch (error) { + console.error(`Error checking feature flag ${flagKey}:`, error); + } + return false; +} diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index 7229331fd2e..90daeebea72 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -12,6 +12,9 @@ import type { UserOpStats, WalletStats, WalletUserStats, + WebhookLatencyStats, + WebhookRequestStats, + WebhookSummaryStats, } from "@/types/analytics"; import { getAuthToken } from "./auth-token"; import { getChains } from "./chain"; @@ -482,6 +485,60 @@ export async function getEngineCloudMethodUsage( return json.data as EngineCloudStats[]; } +export async function getWebhookSummary( + params: AnalyticsQueryParams & { webhookId: string }, +): Promise<{ data: WebhookSummaryStats[] } | { error: string }> { + const searchParams = buildSearchParams(params); + searchParams.append("webhookId", params.webhookId); + + const res = await fetchAnalytics( + `v2/webhook/summary?${searchParams.toString()}`, + ); + if (!res.ok) { + const reason = await res.text(); + return { error: reason }; + } + + return (await res.json()) as { data: WebhookSummaryStats[] }; +} + +export async function getWebhookRequests( + params: AnalyticsQueryParams & { webhookId?: string }, +): Promise<{ data: WebhookRequestStats[] } | { error: string }> { + const searchParams = buildSearchParams(params); + if (params.webhookId) { + searchParams.append("webhookId", params.webhookId); + } + + const res = await fetchAnalytics( + `v2/webhook/requests?${searchParams.toString()}`, + ); + if (!res.ok) { + const reason = await res.text(); + return { error: reason }; + } + + return (await res.json()) as { data: WebhookRequestStats[] }; +} + +export async function getWebhookLatency( + params: AnalyticsQueryParams & { webhookId?: string }, +): Promise<{ data: WebhookLatencyStats[] } | { error: string }> { + const searchParams = buildSearchParams(params); + if (params.webhookId) { + searchParams.append("webhookId", params.webhookId); + } + const res = await fetchAnalytics( + `v2/webhook/latency?${searchParams.toString()}`, + ); + if (!res.ok) { + const reason = await res.text(); + return { error: reason }; + } + + return (await res.json()) as { data: WebhookLatencyStats[] }; +} + export async function getInsightChainUsage( params: AnalyticsQueryParams, ): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> { diff --git a/apps/dashboard/src/@/api/webhook-configs.ts b/apps/dashboard/src/@/api/webhook-configs.ts new file mode 100644 index 00000000000..f62a12e0c4b --- /dev/null +++ b/apps/dashboard/src/@/api/webhook-configs.ts @@ -0,0 +1,312 @@ +"use server"; + +import { getAuthToken } from "@/api/auth-token"; +import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; + +export interface WebhookConfig { + id: string; + teamId: string; + projectId: string; + destinationUrl: string; + description: string; + pausedAt: string | null; + webhookSecret: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + topics: { + id: string; + serviceName: string; + description: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + filters: object | null; + }[]; +} + +type WebhookConfigsResponse = + | { + data: WebhookConfig[]; + status: "success"; + } + | { + body: string; + reason: string; + status: "error"; + }; + +interface CreateWebhookConfigRequest { + topics: { id: string; filters: object | null }[]; + destinationUrl: string; + description: string; + isPaused?: boolean; +} + +type CreateWebhookConfigResponse = + | { + data: WebhookConfig; + status: "success"; + } + | { + body: string; + reason: string; + status: "error"; + }; + +export interface Topic { + id: string; + serviceName: string; + description: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +type TopicsResponse = + | { + data: Topic[]; + status: "success"; + } + | { + body: string; + reason: string; + status: "error"; + }; + +interface UpdateWebhookConfigRequest { + destinationUrl?: string; + topics?: { id: string; filters: object | null }[]; + description?: string; + isPaused?: boolean; +} + +type UpdateWebhookConfigResponse = + | { + data: WebhookConfig; + status: "success"; + } + | { + body: string; + reason: string; + status: "error"; + }; + +type DeleteWebhookConfigResponse = + | { + data: WebhookConfig; + status: "success"; + } + | { + body: string; + reason: string; + status: "error"; + }; + +export async function getWebhookConfigs(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + body: "Authentication required", + reason: "no_auth_token", + status: "error", + }; + } + + const response = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "GET", + }, + ); + + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + }; + } + + const result = await response.json(); + return { + data: result.data, + status: "success", + }; +} + +export async function createWebhookConfig(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; + config: CreateWebhookConfigRequest; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + body: "Authentication required", + reason: "no_auth_token", + status: "error", + }; + } + + const response = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`, + { + body: JSON.stringify(props.config), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + }; + } + + const result = await response.json(); + return { + data: result.data, + status: "success", + }; +} + +export async function getAvailableTopics(): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + body: "Authentication required", + reason: "no_auth_token", + status: "error", + }; + } + + const response = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "GET", + }, + ); + + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + }; + } + + const result = await response.json(); + return { + data: result.data, + status: "success", + }; +} + +export async function updateWebhookConfig(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; + webhookConfigId: string; + config: UpdateWebhookConfigRequest; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + body: "Authentication required", + reason: "no_auth_token", + status: "error", + }; + } + + const response = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`, + { + body: JSON.stringify(props.config), + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "PATCH", + }, + ); + + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + }; + } + + const result = await response.json(); + return { + data: result.data, + status: "success", + }; +} + +export async function deleteWebhookConfig(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; + webhookConfigId: string; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + body: "Authentication required", + reason: "no_auth_token", + status: "error", + }; + } + + const response = await fetch( + `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + method: "DELETE", + }, + ); + + if (!response.ok) { + const body = await response.text(); + return { + body, + reason: "unknown", + status: "error", + }; + } + + const result = await response.json(); + return { + data: result.data, + status: "success", + }; +} diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index 1c91f29e00a..70015ea7cd5 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -66,6 +66,31 @@ export interface UniversalBridgeWalletStats { developerFeeUsdCents: number; } +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; +} + export interface AnalyticsQueryParams { teamId: string; projectId?: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx new file mode 100644 index 00000000000..5227aefb749 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsCharts.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { format } from "date-fns"; +import { useMemo } from "react"; +import { ResponsiveSuspense } from "responsive-rsc"; +import type { WebhookConfig } from "@/api/webhook-configs"; +import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import type { ChartConfig } from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { DateRangeControls, WebhookPicker } from "./WebhookAnalyticsFilter"; + +interface WebhookAnalyticsChartsProps { + webhookConfigs: WebhookConfig[]; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhookAnalyticsCharts({ + webhookConfigs, + requestsData, + latencyData, + selectedWebhookId, +}: WebhookAnalyticsChartsProps) { + // Data is already filtered server-side + const filteredRequestsData = requestsData; + const filteredLatencyData = latencyData; + + // Process status code distribution data by individual status codes + const statusCodeData = useMemo(() => { + if (!filteredRequestsData.length) return []; + + const groupedData = filteredRequestsData.reduce( + (acc, item) => { + const date = new Date(item.date).getTime(); + if (!acc[date]) { + acc[date] = { time: date }; + } + + // Only include valid status codes (not 0) with actual request counts + if (item.httpStatusCode > 0 && item.totalRequests > 0) { + const statusKey = item.httpStatusCode.toString(); + acc[date][statusKey] = + (acc[date][statusKey] || 0) + item.totalRequests; + } + return acc; + }, + {} as Record & { time: number }>, + ); + + return Object.values(groupedData).sort( + (a, b) => (a.time || 0) - (b.time || 0), + ); + }, [filteredRequestsData]); + + // Process latency data for charts + const latencyChartData = useMemo(() => { + if (!filteredLatencyData.length) return []; + + return filteredLatencyData + .map((item) => ({ + p50: item.p50LatencyMs, + p90: item.p90LatencyMs, + p99: item.p99LatencyMs, + time: new Date(item.date).getTime(), + })) + .sort((a, b) => a.time - b.time); + }, [filteredLatencyData]); + + // Chart configurations + const latencyChartConfig: ChartConfig = { + p50: { + color: "hsl(var(--chart-1))", + label: "P50 Latency", + }, + p90: { + color: "hsl(var(--chart-2))", + label: "P90 Latency", + }, + p99: { + color: "hsl(var(--chart-3))", + label: "P99 Latency", + }, + }; + + // Generate status code chart config dynamically with class-based colors + const statusCodeConfig: ChartConfig = useMemo(() => { + const statusCodes = new Set(); + statusCodeData.forEach((item) => { + Object.keys(item).forEach((key) => { + if (key !== "time" && !Number.isNaN(Number.parseInt(key))) { + statusCodes.add(key); + } + }); + }); + + const getColorForStatusCode = (statusCode: number): string => { + if (statusCode >= 200 && statusCode < 300) { + return "hsl(var(--chart-1))"; // Green for 2xx + } else if (statusCode >= 300 && statusCode < 400) { + return "hsl(var(--chart-2))"; // Yellow for 3xx + } else if (statusCode >= 400 && statusCode < 500) { + return "hsl(var(--chart-3))"; // Orange for 4xx + } else { + return "hsl(var(--chart-4))"; // Red for 5xx + } + }; + + const config: ChartConfig = {}; + Array.from(statusCodes) + .sort((a, b) => { + const codeA = Number.parseInt(a); + const codeB = Number.parseInt(b); + return codeA - codeB; + }) + .forEach((statusKey) => { + const statusCode = Number.parseInt(statusKey); + config[statusKey] = { + color: getColorForStatusCode(statusCode), + label: statusCode.toString(), + }; + }); + + return config; + }, [statusCodeData]); + + const hasData = statusCodeData.length > 0 || latencyChartData.length > 0; + const selectedWebhookConfig = webhookConfigs.find( + (w) => w.id === selectedWebhookId, + ); + + return ( +
+
+ + +
+ + {/* Selected webhook URL */} + {selectedWebhookConfig && selectedWebhookId !== "all" && ( +
+ +
+ )} + + {!hasData ? ( +
+
+

+ No webhook data available +

+

+ Webhook analytics will appear here once you start receiving + webhook events. +

+
+
+ ) : ( + + + +
+ } + searchParamsUsed={["from", "to", "interval", "webhook"]} + > +
+ {/* Status Code Distribution Chart */} + + format( + new Date(Number.parseInt(label as string)), + "MMM dd, yyyy HH:mm", + ) + } + toolTipValueFormatter={(value) => `${value} requests`} + variant="stacked" + /> + + {/* Latency Chart */} + + format( + new Date(Number.parseInt(label as string)), + "MMM dd, yyyy HH:mm", + ) + } + toolTipValueFormatter={(value) => `${value}ms`} + /> +
+ + )} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx new file mode 100644 index 00000000000..14411d9bba4 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "@/components/analytics/date-range-selector"; +import { IntervalSelector } from "@/components/analytics/interval-selector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time"; + +type SearchParams = { + from?: string; + to?: string; + webhook?: string; + interval?: "day" | "week"; +}; + +interface WebhookAnalyticsFilterProps { + webhookConfigs: Array<{ + id: string; + description: string | null; + }>; + selectedWebhookId: string; +} + +export function WebhookPicker({ + webhookConfigs, + selectedWebhookId, +}: WebhookAnalyticsFilterProps) { + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + return ( + + ); +} + +export function DateRangeControls() { + 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)/webhooks/analytics/components/WebhookAnalyticsServer.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx new file mode 100644 index 00000000000..ba10a2c673b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx @@ -0,0 +1,29 @@ +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts"; + +interface WebhookAnalyticsServerProps { + webhookConfigs: WebhookConfig[]; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhookAnalyticsServer({ + webhookConfigs, + requestsData, + latencyData, + selectedWebhookId, +}: WebhookAnalyticsServerProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx new file mode 100644 index 00000000000..99d6cbd2e10 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx @@ -0,0 +1,29 @@ +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer"; + +interface WebhooksAnalyticsProps { + webhookConfigs: WebhookConfig[]; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhooksAnalytics({ + webhookConfigs, + requestsData, + latencyData, + selectedWebhookId, +}: WebhooksAnalyticsProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx new file mode 100644 index 00000000000..91f0e4aa5b4 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx @@ -0,0 +1,111 @@ +import { notFound } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import { getWebhookLatency, getWebhookRequests } from "@/api/analytics"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/projects"; +import { getWebhookConfigs } from "@/api/webhook-configs"; +import { getFiltersFromSearchParams } from "@/lib/time"; +import { WebhooksAnalytics } from "./components/WebhooksAnalytics"; + +export default async function WebhooksAnalyticsPage(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + webhook?: string | undefined | string[]; + }>; +}) { + const [authToken, params] = await Promise.all([getAuthToken(), props.params]); + + const project = await getProject(params.team_slug, params.project_slug); + + if (!project || !authToken) { + notFound(); + } + + const searchParams = await props.searchParams; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-7", + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + + // Get webhook configs + const webhookConfigsResponse = await getWebhookConfigs({ + projectIdOrSlug: params.project_slug, + teamIdOrSlug: params.team_slug, + }).catch(() => ({ + body: "", + data: [], + reason: "Failed to fetch webhook configs", + status: "error" as const, + })); + + if ( + webhookConfigsResponse.status === "error" || + webhookConfigsResponse.data.length === 0 + ) { + return ( + +
+

+ No webhook configurations found. +

+
+
+ ); + } + + // Get selected webhook ID from search params + const selectedWebhookId = Array.isArray(searchParams.webhook) + ? searchParams.webhook[0] || "all" + : searchParams.webhook || "all"; + + // Fetch webhook analytics data + const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId; + const [requestsData, latencyData] = await Promise.all([ + (async () => { + const res = await getWebhookRequests({ + from: range.from, + period: interval, + projectId: project.id, + teamId: project.teamId, + to: range.to, + webhookId, + }); + if ("error" in res) { + console.error("Failed to fetch webhook requests:", res.error); + return []; + } + return res.data; + })(), + (async () => { + const res = await getWebhookLatency({ + from: range.from, + period: interval, + projectId: project.id, + teamId: project.teamId, + to: range.to, + webhookId, + }); + if ("error" in res) { + console.error("Failed to fetch webhook latency:", res.error); + return []; + } + return res.data; + })(), + ]); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/create-webhook-config-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/create-webhook-config-modal.tsx new file mode 100644 index 00000000000..fd8c4cf304d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/create-webhook-config-modal.tsx @@ -0,0 +1,23 @@ +import type { Topic } from "@/api/webhook-configs"; +import { WebhookConfigModal } from "./webhook-config-modal"; + +interface CreateWebhookConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamSlug: string; + projectSlug: string; + topics: Topic[]; +} + +export function CreateWebhookConfigModal(props: CreateWebhookConfigModalProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx new file mode 100644 index 00000000000..8092c394640 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx @@ -0,0 +1,101 @@ +import { DialogDescription } from "@radix-ui/react-dialog"; +import { AlertTriangleIcon } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import type { WebhookSummaryStats } from "@/types/analytics"; +import type { WebhookConfig } from "../../../../../../../../@/api/webhook-configs"; + +interface DeleteWebhookModalProps { + webhookConfig: WebhookConfig | null; + metrics: WebhookSummaryStats | null; + onConfirm: () => void; + isPending: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteWebhookModal(props: DeleteWebhookModalProps) { + if (!props.webhookConfig) { + return null; + } + + // Use real metrics data + const requests24h = props.metrics?.totalRequests ?? 0; + const hasRecentActivity = requests24h > 0; + + return ( + + + + + Delete Webhook Configuration + + + +
+ Are you sure you want to delete this webhook configuration? This + action cannot be undone. +
+ +
+
+
Description
+
+ {props.webhookConfig.description || "No description"} +
+
+
+
URL
+
+ {props.webhookConfig.destinationUrl} +
+
+
+ + {hasRecentActivity && ( + + + + Recent Activity Detected + + + This webhook has received {requests24h} requests in the last + 24 hours. Deleting it may impact your integrations. + + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/edit-webhook-config-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/edit-webhook-config-modal.tsx new file mode 100644 index 00000000000..7c6457dbd03 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/edit-webhook-config-modal.tsx @@ -0,0 +1,25 @@ +import type { Topic, WebhookConfig } from "@/api/webhook-configs"; +import { WebhookConfigModal } from "./webhook-config-modal"; + +interface EditWebhookConfigModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamSlug: string; + projectSlug: string; + topics: Topic[]; + webhookConfig: WebhookConfig; +} + +export function EditWebhookConfigModal(props: EditWebhookConfigModalProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx new file mode 100644 index 00000000000..8580e384aa4 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { redirect } from "next/navigation"; +import type { WebhookSummaryStats } from "@/types/analytics"; +import type { + Topic, + WebhookConfig, +} from "../../../../../../../../@/api/webhook-configs"; +import { WebhookConfigsTable } from "./webhook-configs-table"; + +interface WebhooksOverviewProps { + teamId: string; + teamSlug: string; + projectId: string; + projectSlug: string; + webhookConfigs: WebhookConfig[]; + topics: Topic[]; + metricsMap: Map; +} + +export function WebhooksOverview({ + teamId, + teamSlug, + projectId, + projectSlug, + webhookConfigs, + topics, + metricsMap, +}: WebhooksOverviewProps) { + // Feature is enabled (matches server component behavior) + const isFeatureEnabled = true; + + // Redirect to contracts tab if feature is disabled + if (!isFeatureEnabled) { + redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`); + } + + // Show full webhook functionality + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/topic-selector-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/topic-selector-modal.tsx new file mode 100644 index 00000000000..54b70e131a9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/topic-selector-modal.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { Topic } from "@/api/webhook-configs"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; + +const TOPIC_IDS_THAT_SUPPORT_FILTERS = [ + "insight.event.confirmed", + "insight.transaction.confirmed", +]; + +interface TopicSelectorModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + topics: Topic[]; + selectedTopics: { id: string; filters: object | null }[]; + onSelectionChange: (topics: { id: string; filters: object | null }[]) => void; +} + +export function TopicSelectorModal(props: TopicSelectorModalProps) { + const [tempSelection, setTempSelection] = useState< + { id: string; filters: object | null }[] + >(props.selectedTopics); + + // Initialize topicFilters with existing filters + const [topicFilters, setTopicFilters] = useState>( + () => { + const initialFilters: Record = {}; + props.selectedTopics.forEach((topic) => { + if (topic.filters) { + initialFilters[topic.id] = JSON.stringify(topic.filters, null, 2); + } + }); + return initialFilters; + }, + ); + + const groupedTopics = useMemo(() => { + const groups: Record = {}; + + props.topics.forEach((topic) => { + const service = topic.id.split(".")[0] || "other"; + if (!groups[service]) { + groups[service] = []; + } + groups[service].push(topic); + }); + + // Sort groups by service name and topics within each group + const sortedGroups: Record = {}; + Object.keys(groups) + .sort() + .forEach((service) => { + sortedGroups[service] = + groups[service]?.sort((a, b) => a.id.localeCompare(b.id)) || []; + }); + + return sortedGroups; + }, [props.topics]); + + function handleTopicToggle(topicId: string, checked: boolean) { + if (checked) { + // Preserve existing filters if re-selecting a topic + const existingTopic = props.selectedTopics.find((t) => t.id === topicId); + setTempSelection((prev) => [ + ...prev, + { id: topicId, filters: existingTopic?.filters || null }, + ]); + } else { + setTempSelection((prev) => prev.filter((topic) => topic.id !== topicId)); + } + } + + function handleSave() { + const processedSelection = tempSelection.map((topic) => { + const filters = topicFilters[topic.id]; + if (filters) { + try { + return { ...topic, filters: JSON.parse(filters) }; + } catch (_error) { + toast.error(`Invalid JSON in filters for ${topic.id}`); + throw new Error(`Invalid JSON in filters for ${topic.id}`); + } + } + return topic; + }); + + props.onSelectionChange(processedSelection); + props.onOpenChange(false); + } + + function handleCancel() { + setTempSelection(props.selectedTopics); + // Reset filter texts to original values + const originalFilters: Record = {}; + props.selectedTopics.forEach((topic) => { + if (topic.filters) { + originalFilters[topic.id] = JSON.stringify(topic.filters, null, 2); + } + }); + setTopicFilters(originalFilters); + props.onOpenChange(false); + } + + return ( + + + + + Select Topics + + + +
+
+ {Object.entries(groupedTopics).map(([service, topics]) => ( +
+

+ {service} +

+
+ {topics.map((topic) => ( +
+ t.id === topic.id)} + id={topic.id} + onCheckedChange={(checked) => + handleTopicToggle(topic.id, !!checked) + } + /> +
+ +

+ {topic.description} +

+ + {/* Show textarea when selecting a topic that supports filters */} + {TOPIC_IDS_THAT_SUPPORT_FILTERS.includes(topic.id) && + tempSelection.some((t) => t.id === topic.id) && ( +
+