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/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index a6f2819e1c6..44ea3c8886d 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -13,6 +13,7 @@ import type { UserOpStats, WalletStats, WalletUserStats, + WebhookSummaryStats, } from "@/types/analytics"; import { getAuthToken } from "./auth-token"; import { getChains } from "./chain"; @@ -424,3 +425,45 @@ export async function getEngineCloudMethodUsage( const json = await res.json(); return json.data as EngineCloudStats[]; } + +export async function getWebhookMetrics(params: { + teamId: string; + projectId: string; + webhookId: string; + period?: "day" | "week" | "month" | "year" | "all"; + from?: Date; + to?: Date; +}): Promise<{ data: WebhookSummaryStats[] } | { error: string }> { + const searchParams = new URLSearchParams(); + + // Required params + searchParams.append("teamId", params.teamId); + searchParams.append("projectId", params.projectId); + searchParams.append("webhookId", params.webhookId); + + // Optional params + if (params.period) { + searchParams.append("period", params.period); + } + if (params.from) { + searchParams.append("from", params.from.toISOString()); + } + if (params.to) { + searchParams.append("to", params.to.toISOString()); + } + + const res = await fetchAnalytics( + `v2/webhook/summary?${searchParams.toString()}`, + { + method: "GET", + }, + ); + + if (!res.ok) { + const reason = await res?.text(); + return { error: reason }; + } + return (await res.json()) as { + data: WebhookSummaryStats[]; + }; +} diff --git a/apps/dashboard/src/@/api/webhook-configs.ts b/apps/dashboard/src/@/api/webhook-configs.ts new file mode 100644 index 00000000000..9aa7c56475c --- /dev/null +++ b/apps/dashboard/src/@/api/webhook-configs.ts @@ -0,0 +1,271 @@ +"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; + }[]; +} + +interface WebhookConfigsResponse { + data: WebhookConfig[]; + error?: string; +} + +interface CreateWebhookConfigRequest { + topicIds: string[]; + destinationUrl: string; + description: string; + isPaused?: boolean; +} + +interface CreateWebhookConfigResponse { + data: WebhookConfig; + error?: string; +} + +export interface Topic { + id: string; + serviceName: string; + description: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +interface TopicsResponse { + data: Topic[]; + error?: string; +} + +interface UpdateWebhookConfigRequest { + destinationUrl?: string; + topicIds?: string[]; + description?: string; + isPaused?: boolean; +} + +interface UpdateWebhookConfigResponse { + data: WebhookConfig; + error?: string; +} + +interface DeleteWebhookConfigResponse { + data: WebhookConfig; + error?: string; +} + +export async function getWebhookConfigs(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + data: [], + error: "Authentication required", + }; + } + + 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 errorText = await response.text(); + return { + data: [], + error: `Failed to fetch webhook configs: ${errorText}`, + }; + } + + const result = await response.json(); + return { + data: result.data, + error: undefined, + }; +} + +export async function createWebhookConfig(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; + config: CreateWebhookConfigRequest; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + data: {} as WebhookConfig, + error: "Authentication required", + }; + } + + 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 errorText = await response.text(); + return { + data: {} as WebhookConfig, + error: `Failed to create webhook config: ${errorText}`, + }; + } + + const result = await response.json(); + return { + data: result.data, + error: undefined, + }; +} + +export async function getAvailableTopics(): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + data: [], + error: "Authentication required", + }; + } + + 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 errorText = await response.text(); + return { + data: [], + error: `Failed to fetch topics: ${errorText}`, + }; + } + + const result = await response.json(); + return { + data: result.data, + error: undefined, + }; +} + +export async function updateWebhookConfig(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; + webhookConfigId: string; + config: UpdateWebhookConfigRequest; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + data: {} as WebhookConfig, + error: "Authentication required", + }; + } + + 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 errorText = await response.text(); + return { + data: {} as WebhookConfig, + error: `Failed to update webhook config: ${errorText}`, + }; + } + + const result = await response.json(); + return { + data: result.data, + error: undefined, + }; +} + +export async function deleteWebhookConfig(props: { + teamIdOrSlug: string; + projectIdOrSlug: string; + webhookConfigId: string; +}): Promise { + const authToken = await getAuthToken(); + + if (!authToken) { + return { + data: {} as WebhookConfig, + error: "Authentication required", + }; + } + + 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 errorText = await response.text(); + return { + data: {} as WebhookConfig, + error: `Failed to delete webhook config: ${errorText}`, + }; + } + + const result = await response.json(); + return { + data: result.data, + error: undefined, + }; +} diff --git a/apps/dashboard/src/@/api/webhook-metrics.ts b/apps/dashboard/src/@/api/webhook-metrics.ts new file mode 100644 index 00000000000..8aff3b45218 --- /dev/null +++ b/apps/dashboard/src/@/api/webhook-metrics.ts @@ -0,0 +1,16 @@ +"use server"; + +import { getWebhookMetrics } from "@/api/analytics"; +import type { WebhookSummaryStats } from "@/types/analytics"; + +export async function getWebhookMetricsAction(params: { + teamId: string; + projectId: string; + webhookId: string; + period?: "day" | "week" | "month" | "year" | "all"; + from?: Date; + to?: Date; +}): Promise { + const metrics = await getWebhookMetrics(params); + return metrics[0] || null; +} diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index 895da7b95df..f1f2e743920 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -72,6 +72,16 @@ export interface UniversalBridgeWalletStats { developerFeeUsdCents: 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/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..a9289d6c802 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/delete-webhook-modal.tsx @@ -0,0 +1,109 @@ +import { DialogDescription } from "@radix-ui/react-dialog"; +import { AlertTriangleIcon } from "lucide-react"; +import type { WebhookConfig } from "@/api/webhook-configs"; +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 { useWebhookMetrics } from "../hooks/use-webhook-metrics"; + +interface DeleteWebhookModalProps { + webhookConfig: WebhookConfig | null; + teamId: string; + projectId: string; + onConfirm: () => void; + isPending: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteWebhookModal(props: DeleteWebhookModalProps) { + const { data: metrics } = useWebhookMetrics({ + enabled: props.open && !!props.webhookConfig?.id, + projectId: props.projectId, + teamId: props.teamId, + webhookId: props.webhookConfig?.id || "", + }); + + if (!props.webhookConfig) { + return null; + } + + // Use real metrics data + const requests24h = 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..a10f8dff04d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/overview.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { redirect } from "next/navigation"; +import posthog from "posthog-js"; +import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; +import { useAvailableTopics } from "../hooks/use-available-topics"; +import { useWebhookConfigs } from "../hooks/use-webhook-configs"; +import { WebhookConfigsTable } from "./webhook-configs-table"; + +interface WebhooksOverviewProps { + teamId: string; + teamSlug: string; + projectId: string; + projectSlug: string; +} + +export function WebhooksOverview({ + teamId, + teamSlug, + projectId, + projectSlug, +}: WebhooksOverviewProps) { + // Enabled on dev or if FF is enabled. + const isFeatureEnabled = + !posthog.__loaded || posthog.isFeatureEnabled("centralized-webhooks"); + + const webhookConfigsQuery = useWebhookConfigs({ + enabled: isFeatureEnabled, + projectSlug, + teamSlug, + }); + const topicsQuery = useAvailableTopics({ enabled: isFeatureEnabled }); + + // Redirect to contracts tab if feature is disabled + if (!isFeatureEnabled) { + redirect(`/team/${teamSlug}/${projectSlug}/webhooks/contracts`); + } + + // Show loading while data is loading + if (webhookConfigsQuery.isPending || topicsQuery.isPending) { + return ; + } + + // Show error state + if (webhookConfigsQuery.error || topicsQuery.error) { + return ( +
+

+ Failed to load webhook data. Please try again. +

+
+ ); + } + + // 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..a50cdc99d63 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/topic-selector-modal.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useMemo, useState } from "react"; +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"; + +interface TopicSelectorModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + topics: Topic[]; + selectedTopicIds: string[]; + onSelectionChange: (topicIds: string[]) => void; +} + +export function TopicSelectorModal(props: TopicSelectorModalProps) { + const [tempSelection, setTempSelection] = useState( + props.selectedTopicIds, + ); + + 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) { + setTempSelection((prev) => [...prev, topicId]); + } else { + setTempSelection((prev) => prev.filter((id) => id !== topicId)); + } + } + + function handleSave() { + props.onSelectionChange(tempSelection); + props.onOpenChange(false); + } + + function handleCancel() { + setTempSelection(props.selectedTopicIds); + props.onOpenChange(false); + } + + return ( + + + + + Select Topics + + + +
+
+ {Object.entries(groupedTopics).map(([service, topics]) => ( +
+

+ {service} +

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

+ {topic.description} +

+
+
+ ))} +
+
+ ))} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx new file mode 100644 index 00000000000..07eaf6b5722 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-config-modal.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { + createWebhookConfig, + type Topic, + updateWebhookConfig, + type WebhookConfig, +} from "@/api/webhook-configs"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Switch } from "@/components/ui/switch"; +import { TopicSelectorModal } from "./topic-selector-modal"; + +const formSchema = z.object({ + description: z.string().min(1, "Description is required"), + destinationUrl: z + .string() + .min(1, "Destination URL is required") + .url("Must be a valid URL") + .refine((url) => url.startsWith("https://"), { + message: "URL must start with https://", + }), + isPaused: z.boolean().default(false), + topicIds: z.array(z.string()).min(1, "At least one topic is required"), +}); + +type FormValues = z.infer; + +interface WebhookConfigModalProps { + mode: "create" | "edit"; + open: boolean; + onOpenChange: (open: boolean) => void; + teamSlug: string; + projectSlug: string; + topics: Topic[]; + webhookConfig?: WebhookConfig; // Only required for edit mode +} + +export function WebhookConfigModal(props: WebhookConfigModalProps) { + const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false); + const queryClient = useQueryClient(); + + const isEditMode = props.mode === "edit"; + const webhookConfig = props.webhookConfig; + + const form = useForm({ + defaultValues: { + description: isEditMode ? webhookConfig?.description || "" : "", + destinationUrl: isEditMode ? webhookConfig?.destinationUrl || "" : "", + isPaused: isEditMode ? !!webhookConfig?.pausedAt : false, + topicIds: isEditMode ? webhookConfig?.topics?.map((t) => t.id) || [] : [], + }, + resolver: zodResolver(formSchema), + }); + + const mutation = useMutation({ + mutationFn: async (values: FormValues) => { + if (isEditMode && webhookConfig) { + const result = await updateWebhookConfig({ + config: { + description: values.description, + destinationUrl: values.destinationUrl, + isPaused: values.isPaused, + topicIds: values.topicIds, + }, + projectIdOrSlug: props.projectSlug, + teamIdOrSlug: props.teamSlug, + webhookConfigId: webhookConfig.id, + }); + + if (result.error) { + throw new Error(result.error); + } + + return result.data; + } else { + const result = await createWebhookConfig({ + config: values, + projectIdOrSlug: props.projectSlug, + teamIdOrSlug: props.teamSlug, + }); + + if (result.error) { + throw new Error(result.error); + } + + return result.data; + } + }, + onError: (error) => { + toast.error(`Failed to ${isEditMode ? "update" : "create"} webhook`, { + description: error.message, + }); + }, + onSuccess: () => { + toast.success( + `Webhook ${isEditMode ? "updated" : "created"} successfully`, + ); + props.onOpenChange(false); + if (!isEditMode) { + form.reset(); + } + queryClient.invalidateQueries({ + queryKey: ["webhook-configs", props.teamSlug, props.projectSlug], + }); + }, + }); + + function onSubmit(values: FormValues) { + mutation.mutate(values); + } + + function handleOpenChange(open: boolean) { + if (!open && !mutation.isPending) { + if (isEditMode && webhookConfig) { + // Reset form to original values when closing edit modal + form.reset({ + description: webhookConfig.description || "", + destinationUrl: webhookConfig.destinationUrl || "", + isPaused: !!webhookConfig.pausedAt, + topicIds: webhookConfig.topics?.map((t) => t.id) || [], + }); + } else { + // Reset to empty values for create modal + form.reset(); + } + } + props.onOpenChange(open); + } + + return ( + + +
+ +
+ + + {isEditMode ? "Edit" : "Create"} Webhook Configuration + + + +
+ ( + + Description + + + + + + )} + /> + + ( + + Destination URL + + + + + {isEditMode + ? "The URL where webhook events will be sent" + : "Enter your webhook URL. Only https:// is supported."} + + + + )} + /> + + ( + + Topics + + + + + {isEditMode + ? "Select the events you want to receive notifications for" + : "Select the events to trigger calls to your webhook."} + + {field.value && field.value.length > 0 && ( +
+ {field.value.map((topicId) => { + const topic = props.topics.find( + (t) => t.id === topicId, + ); + return ( +
+ {topic?.id || topicId} + +
+ ); + })} +
+ )} + +
+ )} + /> + + ( + +
+ + {isEditMode ? "Paused" : "Start Paused"} + + + {isEditMode + ? "Pause webhook notifications" + : "Do not send events yet. You can unpause at any time."} + +
+ + + +
+ )} + /> +
+
+ + + + + +
+ +
+ + { + form.setValue("topicIds", topicIds); + }} + open={isTopicSelectorOpen} + selectedTopicIds={form.watch("topicIds")} + topics={props.topics} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx new file mode 100644 index 00000000000..d2f75b67576 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { + ArrowUpDownIcon, + CalendarIcon, + CheckIcon, + EditIcon, + LetterTextIcon, + MoreHorizontalIcon, + PlusIcon, + TrashIcon, + WebhookIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { Topic, WebhookConfig } from "@/api/webhook-configs"; +import { deleteWebhookConfig } from "@/api/webhook-configs"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { CreateWebhookConfigModal } from "./create-webhook-config-modal"; +import { DeleteWebhookModal } from "./delete-webhook-modal"; +import { EditWebhookConfigModal } from "./edit-webhook-config-modal"; +import { WebhookMetrics } from "./webhook-metrics"; + +type SortById = "description" | "createdAt" | "destinationUrl" | "pausedAt"; + +export function WebhookConfigsTable(props: { + teamId: string; + teamSlug: string; + projectId: string; + projectSlug: string; + webhookConfigs: WebhookConfig[]; + topics: Topic[]; +}) { + const { webhookConfigs } = props; + const [sortBy, setSortBy] = useState("createdAt"); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingWebhook, setEditingWebhook] = useState( + null, + ); + const [deletingWebhook, setDeletingWebhook] = useState( + null, + ); + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: async (webhookId: string) => { + const result = await deleteWebhookConfig({ + projectIdOrSlug: props.projectSlug, + teamIdOrSlug: props.teamSlug, + webhookConfigId: webhookId, + }); + + if (result.error) { + throw new Error(result.error); + } + + return result.data; + }, + onError: (error) => { + toast.error("Failed to delete webhook", { + description: error.message, + }); + }, + onSuccess: () => { + toast.success("Webhook deleted successfully"); + setDeletingWebhook(null); + queryClient.invalidateQueries({ + queryKey: ["webhook-configs", props.teamSlug, props.projectSlug], + }); + }, + }); + + const sortedConfigs = useMemo(() => { + let _configsToShow = webhookConfigs; + + if (sortBy === "description") { + _configsToShow = _configsToShow.sort((a, b) => + (a.description || "").localeCompare(b.description || ""), + ); + } else if (sortBy === "createdAt") { + _configsToShow = _configsToShow.sort( + (a, b) => + new Date(b.createdAt || 0).getTime() - + new Date(a.createdAt || 0).getTime(), + ); + } else if (sortBy === "destinationUrl") { + _configsToShow = _configsToShow.sort((a, b) => + (a.destinationUrl || "").localeCompare(b.destinationUrl || ""), + ); + } else if (sortBy === "pausedAt") { + _configsToShow = _configsToShow.sort((a, b) => + a.pausedAt === b.pausedAt ? 0 : a.pausedAt ? 1 : -1, + ); + } + + return _configsToShow; + }, [sortBy, webhookConfigs]); + + const pageSize = 10; + const [page, setPage] = useState(1); + const paginatedConfigs = sortedConfigs.slice( + (page - 1) * pageSize, + page * pageSize, + ); + + const showPagination = sortedConfigs.length > pageSize; + const totalPages = Math.ceil(sortedConfigs.length / pageSize); + + const hasActiveFilters = sortBy !== "createdAt"; + + return ( +
+
+
+

+ Configuration +

+

+ Manage your webhook endpoints. +

+
+ + {/* Filters + Add New */} +
+ { + setSortBy(v); + setPage(1); + }} + sortBy={sortBy} + /> + + +
+
+ + {/* Webhook Configs Table */} + {paginatedConfigs.length === 0 ? ( +
+
+

+ No webhooks created yet. +

+
+
+ ) : ( +
+ + + + + Description + Destination URL + Topics + Activity (24h) + Created + + + + + {paginatedConfigs.map((config) => ( + + + + {config.description || "No description"} + + + + +
+ {config.destinationUrl + ? config.destinationUrl.length > 30 + ? `${config.destinationUrl.substring(0, 30)}...` + : config.destinationUrl + : "No URL"} +
+
+
+ +
+ {(config.topics || []).slice(0, 3).map((topic) => ( + + {topic.id} + + ))} + {(config.topics || []).length > 3 && ( + + +{(config.topics || []).length - 3} + + )} +
+
+ + + + + {config.createdAt + ? format(new Date(config.createdAt), "MMM d, yyyy") + : "Unknown"} + + + + + + + + { + setEditingWebhook(config); + }} + > + + Edit + + { + setDeletingWebhook(config); + }} + > + + Delete + + + + +
+ ))} +
+
+
+ + {showPagination && ( +
+ +
+ )} +
+ )} + + + + {editingWebhook && ( + { + if (!open) setEditingWebhook(null); + }} + open={!!editingWebhook} + projectSlug={props.projectSlug} + teamSlug={props.teamSlug} + topics={props.topics} + webhookConfig={editingWebhook} + /> + )} + + { + if (deletingWebhook) { + deleteMutation.mutate(deletingWebhook.id); + } + }} + onOpenChange={(open) => { + if (!open) setDeletingWebhook(null); + }} + open={!!deletingWebhook} + projectId={props.projectId} + teamId={props.teamId} + webhookConfig={deletingWebhook} + /> +
+ ); +} + +const sortByIcon: Record> = { + createdAt: CalendarIcon, + description: LetterTextIcon, + destinationUrl: WebhookIcon, + pausedAt: CheckIcon, +}; + +function SortDropdown(props: { + sortBy: SortById; + onSortChange: (value: SortById) => void; + hasActiveFilters: boolean; +}) { + const values: SortById[] = [ + "description", + "createdAt", + "destinationUrl", + "pausedAt", + ]; + const valueToLabel: Record = { + createdAt: "Creation Date", + description: "Description", + destinationUrl: "Destination URL", + pausedAt: "Requests", + }; + + return ( + + + + + + props.onSortChange(v as SortById)} + value={props.sortBy} + > + {values.map((value) => { + const Icon = sortByIcon[value]; + return ( + props.onSortChange(value)} + > +
+ + {valueToLabel[value]} +
+ + {props.sortBy === value ? ( + + ) : ( +
+ )} + + ); + })} + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx new file mode 100644 index 00000000000..f1e17746c37 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { useWebhookMetrics } from "../hooks/use-webhook-metrics"; + +interface WebhookMetricsProps { + webhookId: string; + teamId: string; + projectId: string; + isPaused: boolean; +} + +export function WebhookMetrics({ + webhookId, + teamId, + projectId, + isPaused, +}: WebhookMetricsProps) { + const { + data: metrics, + isLoading, + error, + } = useWebhookMetrics({ + projectId, + teamId, + webhookId, + }); + + if (isPaused) { + return ( + + Paused + + ); + } + + if (isLoading) { + return ( +
+ + Loading... +
+ ); + } + + if (error) { + return ( +
+ Failed to load metrics +
+ ); + } + + const totalRequests = metrics?.totalRequests ?? 0; + const errorRequests = metrics?.errorRequests ?? 0; + const errorRate = + totalRequests > 0 ? (errorRequests / totalRequests) * 100 : 0; + + return ( +
+
{totalRequests} requests
+
+ {errorRate.toFixed(1)}% errors +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/contracts/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/contracts/page.tsx new file mode 100644 index 00000000000..7e9a512321e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/contracts/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 ContractsPage({ + 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/hooks/use-available-topics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts new file mode 100644 index 00000000000..4799ac53783 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-available-topics.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getAvailableTopics } from "@/api/webhook-configs"; + +export function useAvailableTopics({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + return useQuery({ + enabled, + queryFn: async () => { + const result = await getAvailableTopics(); + + if (result.error) { + throw new Error(result.error); + } + + return result.data || []; + }, + queryKey: ["webhook-topics"], + }); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts new file mode 100644 index 00000000000..44c54c4d4b0 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-configs.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getWebhookConfigs } from "@/api/webhook-configs"; + +interface UseWebhookConfigsParams { + teamSlug: string; + projectSlug: string; + enabled?: boolean; +} + +export function useWebhookConfigs({ + teamSlug, + projectSlug, + enabled = true, +}: UseWebhookConfigsParams) { + return useQuery({ + enabled, + queryFn: async () => { + const result = await getWebhookConfigs({ + projectIdOrSlug: projectSlug, + teamIdOrSlug: teamSlug, + }); + + if (result.error) { + throw new Error(result.error); + } + + return result.data || []; + }, + queryKey: ["webhook-configs", teamSlug, projectSlug], + }); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts new file mode 100644 index 00000000000..813c1d5f146 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/hooks/use-webhook-metrics.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getWebhookMetricsAction } from "@/api/webhook-metrics"; +import type { WebhookSummaryStats } from "@/types/analytics"; + +interface UseWebhookMetricsParams { + webhookId: string; + teamId: string; + projectId: string; + enabled?: boolean; +} + +export function useWebhookMetrics({ + webhookId, + teamId, + projectId, + enabled = true, +}: UseWebhookMetricsParams) { + return useQuery({ + enabled: enabled && !!webhookId, + queryFn: async (): Promise => { + return await getWebhookMetricsAction({ + from: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago + period: "day", + projectId, + teamId, + to: new Date(), + webhookId, + }); + }, + queryKey: ["webhook-metrics", teamId, projectId, webhookId], + retry: 1, + staleTime: 5 * 60 * 1000, + }); +} 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 4b1c85c8ebd..bc6c3280fb3 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 @@ -1,3 +1,4 @@ +import posthog from "posthog-js"; import { TabPathLinks } from "@/components/ui/tabs"; export default async function WebhooksLayout(props: { @@ -7,6 +8,10 @@ export default async function WebhooksLayout(props: { project_slug: string; }>; }) { + // Enabled on dev or if FF is enabled. + const isFeatureEnabled = + !posthog.__loaded || posthog.isFeatureEnabled("centralized-webhooks"); + const params = await props.params; return (
@@ -23,10 +28,18 @@ export default async function WebhooksLayout(props: { -

- Contract Webhooks -

-

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

-
- -
+ ); }