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/.storybook/main.ts b/apps/dashboard/.storybook/main.ts index d11f0b46808..33bed7699d2 100644 --- a/apps/dashboard/.storybook/main.ts +++ b/apps/dashboard/.storybook/main.ts @@ -9,13 +9,15 @@ function getAbsolutePath(value: string): string { return dirname(require.resolve(join(value, "package.json"))); } const config: StorybookConfig = { - stories: ["../src/**/*.stories.tsx"], addons: [ getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@chromatic-com/storybook"), getAbsolutePath("@storybook/addon-docs"), ], + features: { + experimentalRSC: true, + }, framework: { name: getAbsolutePath("@storybook/nextjs"), options: {}, @@ -26,8 +28,6 @@ const config: StorybookConfig = { }, }, staticDirs: ["../public"], - features: { - experimentalRSC: true, - }, + stories: ["../src/**/*.stories.tsx"], }; export default config; diff --git a/apps/dashboard/.storybook/preview.tsx b/apps/dashboard/.storybook/preview.tsx index 7b1658d6275..0927fa3f863 100644 --- a/apps/dashboard/.storybook/preview.tsx +++ b/apps/dashboard/.storybook/preview.tsx @@ -2,11 +2,10 @@ import type { Preview } from "@storybook/nextjs"; import "../src/global.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MoonIcon, SunIcon } from "lucide-react"; -import { ThemeProvider, useTheme } from "next-themes"; import { Inter as interFont } from "next/font/google"; +import { ThemeProvider, useTheme } from "next-themes"; // biome-ignore lint/style/useImportType: -import React from "react"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { Toaster } from "sonner"; import { Button } from "../src/@/components/ui/button"; @@ -18,45 +17,33 @@ const fontSans = interFont({ }); const customViewports = { - xs: { - // Regular sized phones (iphone 15 / 15 pro) - name: "iPhone", - styles: { - width: "390px", - height: "844px", - }, - }, sm: { // Larger phones (iphone 15 plus / 15 pro max) name: "iPhone Plus", styles: { - width: "430px", height: "932px", + width: "430px", }, }, -}; - -const preview: Preview = { - parameters: { - viewport: { - viewports: customViewports, - }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, + xs: { + // Regular sized phones (iphone 15 / 15 pro) + name: "iPhone", + styles: { + height: "844px", + width: "390px", }, }, +}; +const preview: Preview = { decorators: [ (Story) => { return ( @@ -65,13 +52,22 @@ const preview: Preview = { ); }, ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + viewport: { + viewports: customViewports, + }, + }, }; export default preview; -function StoryLayout(props: { - children: React.ReactNode; -}) { +function StoryLayout(props: { children: React.ReactNode }) { const { setTheme, theme } = useTheme(); useEffect(() => { @@ -83,10 +79,10 @@ function StoryLayout(props: {
+ ); +} 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..53da6e6417f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsFilter.tsx @@ -0,0 +1,105 @@ +"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; + 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..3d83b0a319f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhookAnalyticsServer.tsx @@ -0,0 +1,42 @@ +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { Range } from "@/components/analytics/date-range-selector"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { WebhookAnalyticsCharts } from "./WebhookAnalyticsCharts"; + +interface WebhookAnalyticsServerProps { + teamId: string; + projectId: string; + webhookConfigs: WebhookConfig[]; + range: Range; + interval: "day" | "week"; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhookAnalyticsServer({ + teamId, + projectId, + webhookConfigs, + range, + interval, + 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..a11971b1450 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/components/WebhooksAnalytics.tsx @@ -0,0 +1,44 @@ +import type { WebhookConfig } from "@/api/webhook-configs"; +import type { Range } from "@/components/analytics/date-range-selector"; +import type { + WebhookLatencyStats, + WebhookRequestStats, +} from "@/types/analytics"; +import { WebhookAnalyticsServer } from "./WebhookAnalyticsServer"; + +interface WebhooksAnalyticsProps { + teamId: string; + teamSlug: string; + projectId: string; + projectSlug: string; + range: Range; + interval: "day" | "week"; + webhookConfigs: WebhookConfig[]; + requestsData: WebhookRequestStats[]; + latencyData: WebhookLatencyStats[]; + selectedWebhookId: string; +} + +export function WebhooksAnalytics({ + teamId, + projectId, + range, + interval, + 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..fa0af1af7ac --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/analytics/page.tsx @@ -0,0 +1,117 @@ +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..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..fae532bd0ca --- /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.status === "error") { + throw new Error(result.body); + } + + return result.data; + } else { + const result = await createWebhookConfig({ + config: values, + projectIdOrSlug: props.projectSlug, + teamIdOrSlug: props.teamSlug, + }); + + if (result.status === "error") { + throw new Error(result.body); + } + + 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..e456c258a79 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-configs-table.tsx @@ -0,0 +1,406 @@ +"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 { 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 type { WebhookSummaryStats } from "@/types/analytics"; +import type { + Topic, + WebhookConfig, +} from "../../../../../../../../@/api/webhook-configs"; +import { deleteWebhookConfig } from "../../../../../../../../@/api/webhook-configs"; +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[]; + metricsMap: Map; +}) { + 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.status === "error") { + throw new Error(result.body); + } + + 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} + 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..00523fdc996 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/components/webhook-metrics.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { WebhookSummaryStats } from "@/types/analytics"; + +interface WebhookMetricsProps { + metrics: WebhookSummaryStats | null; + isPaused: boolean; +} + +export function WebhookMetrics({ metrics, isPaused }: WebhookMetricsProps) { + if (isPaused) { + return ( + + Paused + + ); + } + + if (!metrics) { + return ( +
No metrics available
+ ); + } + + 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/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/webhooks/layout.tsx index 72abd17713a..43cfed57ddb 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,5 @@ +import { getValidAccount } from "@app/account/settings/getAccount"; +import { isFeatureFlagEnabled } from "@/analytics/posthog-server"; import { TabPathLinks } from "@/components/ui/tabs"; export default async function WebhooksLayout(props: { @@ -7,6 +9,12 @@ export default async function WebhooksLayout(props: { project_slug: string; }>; }) { + const account = await getValidAccount(); + const isFeatureEnabled = await isFeatureFlagEnabled( + "webhook-analytics-tab", + account.email, + ); + const params = await props.params; return (
@@ -23,10 +31,23 @@ export default async function WebhooksLayout(props: { { + const metricsResult = await getWebhookSummary({ + from: new Date(Date.now() - 24 * 60 * 60 * 1000), + period: "day", + projectId: project.id, + teamId: project.teamId, // 24 hours ago + to: new Date(), + webhookId: config.id, + }); + + return { + metrics: + "error" in metricsResult ? null : (metricsResult.data[0] ?? null), + webhookId: config.id, + }; + }), + ); + + // Create a map for easy lookup + const metricsMap = new Map( + webhookMetrics.map((item) => [item.webhookId, item.metrics]), + ); + return ( -
-

- Contract Webhooks -

-

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

-
- -
+ ); } diff --git a/apps/login/package.json b/apps/login/package.json new file mode 100644 index 00000000000..8a2d4fc1c11 --- /dev/null +++ b/apps/login/package.json @@ -0,0 +1,5 @@ +{ + "name": "login", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000000..8ea43099a68 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,5 @@ +{ + "name": "auth", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/chains/package.json b/packages/chains/package.json new file mode 100644 index 00000000000..8e3385e0b1d --- /dev/null +++ b/packages/chains/package.json @@ -0,0 +1,5 @@ +{ + "name": "chains", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000000..51263f1201d --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,5 @@ +{ + "name": "cli", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/contracts-js/package.json b/packages/contracts-js/package.json new file mode 100644 index 00000000000..de95c8ae2d7 --- /dev/null +++ b/packages/contracts-js/package.json @@ -0,0 +1,5 @@ +{ + "name": "contracts-js", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/crypto/package.json b/packages/crypto/package.json new file mode 100644 index 00000000000..96a2993610c --- /dev/null +++ b/packages/crypto/package.json @@ -0,0 +1,5 @@ +{ + "name": "crypto", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/eslint-config-thirdweb/package.json b/packages/eslint-config-thirdweb/package.json new file mode 100644 index 00000000000..bb5b1eabb56 --- /dev/null +++ b/packages/eslint-config-thirdweb/package.json @@ -0,0 +1,5 @@ +{ + "name": "eslint-config-thirdweb", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/merkletree/package.json b/packages/merkletree/package.json new file mode 100644 index 00000000000..628483f54f1 --- /dev/null +++ b/packages/merkletree/package.json @@ -0,0 +1,5 @@ +{ + "name": "merkletree", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/pay/package.json b/packages/pay/package.json new file mode 100644 index 00000000000..a3444c482d0 --- /dev/null +++ b/packages/pay/package.json @@ -0,0 +1,5 @@ +{ + "name": "pay", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/payments/package.json b/packages/payments/package.json new file mode 100644 index 00000000000..a177cff7a82 --- /dev/null +++ b/packages/payments/package.json @@ -0,0 +1,5 @@ +{ + "name": "payments", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/react-core/package.json b/packages/react-core/package.json new file mode 100644 index 00000000000..6c71bfda2e5 --- /dev/null +++ b/packages/react-core/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-core", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/react-native-compat/package.json b/packages/react-native-compat/package.json new file mode 100644 index 00000000000..f7f9dc5c04c --- /dev/null +++ b/packages/react-native-compat/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-native-compat", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/react-native/package.json b/packages/react-native/package.json new file mode 100644 index 00000000000..9306d436908 --- /dev/null +++ b/packages/react-native/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-native", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000000..3b201992d38 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,5 @@ +{ + "name": "react", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000000..255dc47011b --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,5 @@ +{ + "name": "sdk", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 00000000000..c99aeb12b32 --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,5 @@ +{ + "name": "storage", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/typedoc-gen/package.json b/packages/typedoc-gen/package.json new file mode 100644 index 00000000000..5994c8a2fae --- /dev/null +++ b/packages/typedoc-gen/package.json @@ -0,0 +1,5 @@ +{ + "name": "typedoc-gen", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/unity-js-bridge/package.json b/packages/unity-js-bridge/package.json new file mode 100644 index 00000000000..b1d8cf36d81 --- /dev/null +++ b/packages/unity-js-bridge/package.json @@ -0,0 +1,5 @@ +{ + "name": "unity-js-bridge", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/packages/wallets/package.json b/packages/wallets/package.json new file mode 100644 index 00000000000..80d64aeaffe --- /dev/null +++ b/packages/wallets/package.json @@ -0,0 +1,5 @@ +{ + "name": "wallets", + "version": "0.0.0", + "private": true +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 655afe3b603..d2ae6337288 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: posthog-js: specifier: 1.256.1 version: 1.256.1 + posthog-node: + specifier: ^5.3.1 + version: 5.3.1 prettier: specifier: 3.6.2 version: 3.6.2 @@ -411,6 +414,8 @@ importers: specifier: 5.8.3 version: 5.8.3 + apps/login: {} + apps/nebula: dependencies: '@hookform/resolvers': @@ -1051,6 +1056,16 @@ importers: specifier: 5.8.3 version: 5.8.3 + packages/auth: {} + + packages/chains: {} + + packages/cli: {} + + packages/contracts-js: {} + + packages/crypto: {} + packages/engine: dependencies: '@hey-api/client-fetch': @@ -1073,6 +1088,8 @@ importers: specifier: ^2.8.1 version: 2.8.1 + packages/eslint-config-thirdweb: {} + packages/insight: dependencies: typescript: @@ -1092,6 +1109,8 @@ importers: specifier: ^2.8.1 version: 2.8.1 + packages/merkletree: {} + packages/nebula: dependencies: thirdweb: @@ -1114,6 +1133,16 @@ importers: specifier: ^2.8.1 version: 2.8.1 + packages/pay: {} + + packages/payments: {} + + packages/react: {} + + packages/react-core: {} + + packages/react-native: {} + packages/react-native-adapter: dependencies: '@aws-sdk/client-kms': @@ -1175,6 +1204,10 @@ importers: specifier: 6.0.1 version: 6.0.1 + packages/react-native-compat: {} + + packages/sdk: {} + packages/service-utils: dependencies: '@confluentinc/kafka-javascript': @@ -1209,6 +1242,8 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.2(@types/node@22.14.1)(typescript@5.8.3))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + packages/storage: {} + packages/thirdweb: dependencies: '@coinbase/wallet-sdk': @@ -1450,6 +1485,10 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.10)(@vitest/ui@3.2.4)(happy-dom@17.4.4)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.7.5(@types/node@24.0.10)(typescript@5.8.3))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + packages/typedoc-gen: {} + + packages/unity-js-bridge: {} + packages/vault-sdk: dependencies: '@noble/ciphers': @@ -1497,6 +1536,8 @@ importers: specifier: workspace:* version: link:../thirdweb + packages/wallets: {} + packages: '@0no-co/graphql.web@1.1.2': @@ -13098,6 +13139,10 @@ packages: rrweb-snapshot: optional: true + posthog-node@5.3.1: + resolution: {integrity: sha512-rCGf5Od7+lLHYLQvkBXrHt2Q8vuf0hzs95HLzDwqLQMltNxatqDxK1vPWbpa5YoEUxhiGoikTQ1cEbqCkEV+kA==} + engines: {node: '>=20'} + preact@10.26.9: resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==} @@ -33535,6 +33580,8 @@ snapshots: preact: 10.26.9 web-vitals: 4.2.4 + posthog-node@5.3.1: {} + preact@10.26.9: {} prelude-ls@1.2.1: {}