+
{children}
) : null}
diff --git a/src/features/dashboard/sandbox/header/started-at.tsx b/src/features/dashboard/sandbox/header/started-at.tsx
index 2e6614ba1..712a7cb53 100644
--- a/src/features/dashboard/sandbox/header/started-at.tsx
+++ b/src/features/dashboard/sandbox/header/started-at.tsx
@@ -1,41 +1,21 @@
'use client'
+import { Timestamp } from '@/features/dashboard/shared'
import CopyButton from '@/ui/copy-button'
import { useSandboxContext } from '../context'
-export default function StartedAt() {
+const StartedAt = () => {
const { sandboxLifecycle } = useSandboxContext()
const startedAt = sandboxLifecycle?.createdAt
- if (!startedAt) {
- return null
- }
-
- const date = new Date(startedAt)
- const now = new Date()
- const isToday = date.toDateString() === now.toDateString()
- const isYesterday =
- date.toDateString() ===
- new Date(now.setDate(now.getDate() - 1)).toDateString()
-
- const prefix = isToday
- ? 'Today'
- : isYesterday
- ? 'Yesterday'
- : date.toLocaleDateString()
-
- const timeStr = date.toLocaleTimeString([], {
- hour: 'numeric',
- minute: '2-digit',
- second: '2-digit',
- })
+ if (!startedAt) return null
return (
-
- {prefix}, {timeStr}
-
-
+
+
)
}
+
+export default StartedAt
diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx
index 2cdd149f4..b6392a9bd 100644
--- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx
+++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx
@@ -11,6 +11,7 @@ import {
BrushComponent,
GridComponent,
MarkPointComponent,
+ ToolboxComponent,
} from 'echarts/components'
import * as echarts from 'echarts/core'
import { SVGRenderer } from 'echarts/renderers'
@@ -46,6 +47,7 @@ echarts.use([
GridComponent,
BrushComponent,
MarkPointComponent,
+ ToolboxComponent,
SVGRenderer,
AxisPointerComponent,
])
diff --git a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx
index d0ac1cbd5..2af4f9757 100644
--- a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx
+++ b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx
@@ -12,6 +12,7 @@ import {
GridComponent,
MarkLineComponent,
MarkPointComponent,
+ ToolboxComponent,
TooltipComponent,
} from 'echarts/components'
import * as echarts from 'echarts/core'
@@ -42,6 +43,7 @@ echarts.use([
MarkPointComponent,
MarkLineComponent,
AxisPointerComponent,
+ ToolboxComponent,
CanvasRenderer,
])
diff --git a/src/features/dashboard/settings/webhooks/detail/chart-utils.ts b/src/features/dashboard/settings/webhooks/detail/chart-utils.ts
new file mode 100644
index 000000000..cea94cd5d
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/chart-utils.ts
@@ -0,0 +1,98 @@
+import type { TRPCRouterOutputs } from '@/trpc/client'
+import type { StatsChartPoint } from './stats-chart'
+import type { WebhookStatsRangeBounds } from './stats-range'
+
+type WebhookDeliveryStats =
+ TRPCRouterOutputs['webhooks']['getDeliveryStats']['stats']
+
+type WebhookDeliveryStatsBucket = WebhookDeliveryStats['buckets'][number]
+type DeliveryCountMetric = 'failed' | 'total'
+type ResponseTimeMetric = 'avg' | 'max' | 'min'
+
+const getBucketTimestampRange = (
+ rangeBounds: WebhookStatsRangeBounds,
+ bucketIntervalSeconds: number
+) => {
+ const intervalMs = bucketIntervalSeconds * 1000
+ const start = Math.ceil(rangeBounds.start / intervalMs) * intervalMs
+ const end = Math.floor(rangeBounds.end / intervalMs) * intervalMs
+
+ return { end, intervalMs, start }
+}
+
+// Builds delivery count points from API buckets, e.g. 10m buckets -> chart points with missing buckets filled as 0.
+const getDeliveryCountSeriesData = (
+ buckets: WebhookDeliveryStatsBucket[],
+ rangeBounds: WebhookStatsRangeBounds,
+ bucketIntervalSeconds: number,
+ metric: DeliveryCountMetric = 'total'
+) => {
+ const countByTimestamp = new Map
()
+
+ for (const bucket of buckets) {
+ const count = metric === 'failed' ? bucket.failed : bucket.total
+ const timestampMs = new Date(bucket.timestamp).getTime()
+ countByTimestamp.set(timestampMs, count)
+ }
+
+ const points: StatsChartPoint[] = []
+ const { end, intervalMs, start } = getBucketTimestampRange(
+ rangeBounds,
+ bucketIntervalSeconds
+ )
+
+ for (let timestampMs = start; timestampMs <= end; timestampMs += intervalMs) {
+ const value = countByTimestamp.get(timestampMs) ?? 0
+
+ points.push({
+ synthetic: value === 0,
+ timestamp: new Date(timestampMs).toISOString(),
+ value,
+ })
+ }
+
+ return points
+}
+
+// Builds response-time points from API buckets, e.g. a bucket average -> one chart point.
+const getResponseTimeSeriesData = (
+ buckets: WebhookDeliveryStatsBucket[],
+ rangeBounds: WebhookStatsRangeBounds,
+ bucketIntervalSeconds: number,
+ metric: ResponseTimeMetric
+) => {
+ const valueByTimestamp = new Map()
+
+ for (const bucket of buckets) {
+ if (bucket.total <= 0) continue
+
+ const value =
+ metric === 'avg'
+ ? bucket.durationMs.average
+ : metric === 'max'
+ ? bucket.durationMs.maximum
+ : bucket.durationMs.minimum
+
+ valueByTimestamp.set(new Date(bucket.timestamp).getTime(), value)
+ }
+
+ const points: StatsChartPoint[] = []
+ const { end, intervalMs, start } = getBucketTimestampRange(
+ rangeBounds,
+ bucketIntervalSeconds
+ )
+
+ for (let timestampMs = start; timestampMs <= end; timestampMs += intervalMs) {
+ const value = valueByTimestamp.get(timestampMs) ?? 0
+
+ points.push({
+ synthetic: value === 0,
+ timestamp: new Date(timestampMs).toISOString(),
+ value,
+ })
+ }
+
+ return points
+}
+
+export { getDeliveryCountSeriesData, getResponseTimeSeriesData }
diff --git a/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx
new file mode 100644
index 000000000..3af04f47a
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx
@@ -0,0 +1,583 @@
+'use client'
+
+import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
+import {
+ useVirtualizer,
+ type VirtualItem,
+ type Virtualizer,
+} from '@tanstack/react-virtual'
+import { useQueryStates } from 'nuqs'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { z } from 'zod'
+import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types'
+import {
+ VirtualizedTableLoaderBody,
+ VirtualizedTableRow,
+} from '@/features/dashboard/common/virtualized-table-ui'
+import {
+ EventTypeBadge,
+ EventTypeFilter,
+ eventTypeFilterParams,
+ IdBadge,
+} from '@/features/dashboard/shared'
+import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast'
+import { cn } from '@/lib/utils'
+import { type TRPCRouterOutputs, useTRPC } from '@/trpc/client'
+import { JsonPopover } from '@/ui/json-popover'
+import { Badge } from '@/ui/primitives/badge'
+import { Button } from '@/ui/primitives/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/ui/primitives/dropdown-menu'
+import { WebhookIcon } from '@/ui/primitives/icons'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableEmptyState,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/ui/primitives/table'
+import {
+ deliveryFilterParams,
+ WEBHOOK_DELIVERY_STATUSES,
+ type WebhookDeliveryStatus,
+} from './delivery-filter-params'
+
+type WebhookDeliveriesContentProps = {
+ teamSlug: string
+ webhookId: string
+}
+
+type WebhookDeliveryGroup =
+ TRPCRouterOutputs['webhooks']['listDeliveries']['groups'][number]
+
+const JsonValueSchema = z.unknown()
+const ROW_HEIGHT_PX = 32
+const VIRTUAL_OVERSCAN = 16
+const SCROLL_LOAD_THRESHOLD_PX = 240
+
+const deliveryTableHeadClassName =
+ 'flex h-8 items-center whitespace-nowrap p-0 pr-12 [&>span]:whitespace-nowrap'
+const deliveryTableCellClassName = 'flex h-8 items-center p-0 pr-12'
+const deliveryDetailPopoverClassName =
+ 'min-w-0 max-w-[180px] normal-case text-fg-tertiary hover:text-fg hover:underline'
+
+const deliveryStatusVariantMap: Record<
+ WebhookDeliveryStatus,
+ React.ComponentProps['variant']
+> = {
+ failed: 'error',
+ success: 'positive',
+}
+
+const formatDateTime = (value: string) =>
+ new Date(value).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+
+const formatHttpStatus = (status: number | null | undefined) =>
+ status === null || status === undefined ? 'No response' : String(status)
+
+// Parses a JSON string safely, e.g. '{"ok":true}' -> { ok: true }.
+const parseMaybeJson = (value: string | null | undefined) => {
+ if (!value) return undefined
+
+ try {
+ const parsedValue: unknown = JSON.parse(value)
+ const result = JsonValueSchema.safeParse(parsedValue)
+
+ return result.success ? result.data : value
+ } catch {
+ return value
+ }
+}
+
+const DeliveryStatusBadge = ({ status }: { status: WebhookDeliveryStatus }) => (
+ {status}
+)
+
+const getDeliveryStatusTriggerLabel = (statuses: WebhookDeliveryStatus[]) => {
+ if (statuses.length === WEBHOOK_DELIVERY_STATUSES.length) return 'All'
+ if (statuses.length === 0) return 'None'
+ const [first] = statuses
+ if (statuses.length === 1 && first)
+ return first.charAt(0).toUpperCase() + first.slice(1)
+
+ return `${statuses.length}/${WEBHOOK_DELIVERY_STATUSES.length}`
+}
+
+const DeliveryStatusFilter = ({
+ statuses,
+ onStatusesChange,
+}: {
+ statuses: WebhookDeliveryStatus[]
+ onStatusesChange: (statuses: WebhookDeliveryStatus[]) => void
+}) => {
+ const isAllSelected = statuses.length === WEBHOOK_DELIVERY_STATUSES.length
+
+ const toggleStatus = (status: WebhookDeliveryStatus) => {
+ const next = statuses.includes(status)
+ ? statuses.filter((item) => item !== status)
+ : [...statuses, status]
+ onStatusesChange(next)
+ }
+
+ const toggleAll = (checked: boolean) => {
+ onStatusesChange(checked ? [...WEBHOOK_DELIVERY_STATUSES] : [])
+ }
+
+ return (
+
+
+
+
+
+ event.preventDefault()}
+ >
+ All statuses
+
+
+ {WEBHOOK_DELIVERY_STATUSES.map((status) => (
+ toggleStatus(status)}
+ onSelect={(event) => event.preventDefault()}
+ >
+
+
+ ))}
+
+
+ )
+}
+
+const DeliveryDetailCell = ({
+ value,
+}: {
+ value: string | null | undefined
+}) => {
+ const parsedValue = useMemo(() => parseMaybeJson(value), [value])
+
+ if (parsedValue === undefined) {
+ return n/a
+ }
+
+ if (typeof parsedValue === 'string') {
+ return (
+
+ {parsedValue}
+
+ )
+ }
+
+ return (
+
+ {value}
+
+ )
+}
+
+interface WebhookDeliveriesTableProps {
+ groups: WebhookDeliveryGroup[]
+ isLoading: boolean
+ emptyStateLabel: string
+ scrollContainer: HTMLDivElement | null
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ onLoadMore: () => void
+}
+
+const WebhookDeliveriesTable = ({
+ groups,
+ isLoading,
+ emptyStateLabel,
+ scrollContainer,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+}: WebhookDeliveriesTableProps) => {
+ 'use no memo'
+
+ return (
+
+
+
+
+ Event
+
+
+ Sandbox ID
+
+
+ Status
+
+
+ Last attempt
+
+
+ Attempts
+
+
+ Duration
+
+
+ Request headers
+
+
+ Request body
+
+
+ Response HTTP
+
+
+ Response headers
+
+
+ Response body
+
+
+
+
+ {isLoading ? (
+
+ ) : groups.length === 0 ? (
+
+
+
+ {emptyStateLabel}
+
+
+ ) : scrollContainer ? (
+
+ ) : null}
+
+ )
+}
+
+interface VirtualizedDeliveriesBodyProps {
+ groups: WebhookDeliveryGroup[]
+ scrollContainer: HTMLDivElement | null
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ onLoadMore: () => void
+}
+
+const VirtualizedDeliveriesBody = ({
+ groups,
+ scrollContainer,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+}: VirtualizedDeliveriesBodyProps) => {
+ 'use no memo'
+
+ const initialRect = useMemo(() => {
+ if (!scrollContainer) return undefined
+
+ return {
+ height: scrollContainer.clientHeight,
+ width: scrollContainer.clientWidth,
+ }
+ }, [scrollContainer])
+
+ useScrollLoadMore({
+ scrollContainer,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+ })
+
+ const virtualizer = useVirtualizer({
+ count: groups.length,
+ estimateSize: () => ROW_HEIGHT_PX,
+ getScrollElement: () => scrollContainer,
+ initialRect,
+ overscan: VIRTUAL_OVERSCAN,
+ paddingStart: 8,
+ })
+
+ return (
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const group = groups[virtualRow.index]
+ if (!group) return null
+
+ return (
+
+ )
+ })}
+
+ )
+}
+
+interface WebhookDeliveryRowProps {
+ group: WebhookDeliveryGroup
+ virtualRow: VirtualItem
+ virtualizer: Virtualizer
+}
+
+const WebhookDeliveryRow = ({
+ group,
+ virtualRow,
+ virtualizer,
+}: WebhookDeliveryRowProps) => {
+ const attempt = group.latestAttempt
+
+ return (
+
+
+
+
+
+
+
+ toast(defaultSuccessToast('Sandbox ID copied'))}
+ />
+
+
+ {attempt ? : '-'}
+
+
+ {attempt ? formatDateTime(attempt.timestamp) : '-'}
+
+
+ {group.attemptCount}
+
+
+ {attempt ? `${attempt.durationMs.toLocaleString()}ms` : '-'}
+
+
+
+
+
+
+
+
+ {attempt ? formatHttpStatus(attempt.responseHttpStatusCode) : '-'}
+
+
+
+
+
+
+
+
+ )
+}
+
+interface UseScrollLoadMoreParams {
+ scrollContainer: HTMLDivElement | null
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ onLoadMore: () => void
+}
+
+const useScrollLoadMore = ({
+ scrollContainer,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+}: UseScrollLoadMoreParams) => {
+ useEffect(() => {
+ if (!scrollContainer) return
+
+ const handleScroll = () => {
+ const distanceToBottom =
+ scrollContainer.scrollHeight -
+ scrollContainer.scrollTop -
+ scrollContainer.clientHeight
+
+ if (
+ distanceToBottom < SCROLL_LOAD_THRESHOLD_PX &&
+ hasNextPage &&
+ !isFetchingNextPage
+ ) {
+ onLoadMore()
+ }
+ }
+
+ const frame = requestAnimationFrame(handleScroll)
+ scrollContainer.addEventListener('scroll', handleScroll, {
+ passive: true,
+ })
+
+ return () => {
+ cancelAnimationFrame(frame)
+ scrollContainer.removeEventListener('scroll', handleScroll)
+ }
+ }, [scrollContainer, hasNextPage, isFetchingNextPage, onLoadMore])
+}
+
+export const WebhookDeliveriesContent = ({
+ teamSlug,
+ webhookId,
+}: WebhookDeliveriesContentProps) => {
+ const [scrollContainer, setScrollContainer] = useState(
+ null
+ )
+ const [filters, setFilters] = useQueryStates(
+ {
+ ...deliveryFilterParams,
+ ...eventTypeFilterParams,
+ },
+ { shallow: true }
+ )
+ const trpc = useTRPC()
+ const deliveryStatuses = useMemo(
+ () => filters.statuses ?? [...WEBHOOK_DELIVERY_STATUSES],
+ [filters.statuses]
+ )
+ const hasSelectedDeliveryStatuses = deliveryStatuses.length > 0
+ const hasAllDeliveryStatuses =
+ deliveryStatuses.length === WEBHOOK_DELIVERY_STATUSES.length
+ const deliveryStatusFilter = hasAllDeliveryStatuses
+ ? undefined
+ : deliveryStatuses
+ const handleDeliveryStatusesChange = useCallback(
+ (nextStatuses: WebhookDeliveryStatus[]) => {
+ const nextHasAllStatuses =
+ nextStatuses.length === WEBHOOK_DELIVERY_STATUSES.length
+
+ setFilters({
+ statuses: nextHasAllStatuses ? null : nextStatuses,
+ })
+ },
+ [setFilters]
+ )
+ const eventTypes = useMemo(
+ () => filters.types ?? [...SandboxLifecycleEventTypeSchema.options],
+ [filters.types]
+ )
+ const hasSelectedEventTypes = eventTypes.length > 0
+ const hasAllEventTypes =
+ eventTypes.length === SandboxLifecycleEventTypeSchema.options.length
+ const eventTypeFilter = hasAllEventTypes ? undefined : eventTypes
+ const handleEventTypesChange = useCallback(
+ (nextEventTypes: typeof eventTypes) => {
+ const nextHasAllEventTypes =
+ nextEventTypes.length === SandboxLifecycleEventTypeSchema.options.length
+
+ setFilters({
+ types: nextHasAllEventTypes ? null : nextEventTypes,
+ })
+ },
+ [setFilters]
+ )
+ const deliveriesQuery = useInfiniteQuery(
+ trpc.webhooks.listDeliveries.infiniteQueryOptions(
+ {
+ teamSlug,
+ webhookId,
+ limit: 25,
+ deliveryStatus: deliveryStatusFilter,
+ eventType: eventTypeFilter,
+ },
+ {
+ enabled: hasSelectedEventTypes && hasSelectedDeliveryStatuses,
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
+ placeholderData: keepPreviousData,
+ }
+ )
+ )
+ const groups = useMemo(() => {
+ return hasSelectedEventTypes && hasSelectedDeliveryStatuses
+ ? (deliveriesQuery.data?.pages.flatMap((page) => page.groups) ?? [])
+ : []
+ }, [deliveriesQuery.data, hasSelectedDeliveryStatuses, hasSelectedEventTypes])
+ const hasActiveFilters = !hasAllDeliveryStatuses || !hasAllEventTypes
+ const isDeliveriesLoading =
+ hasSelectedEventTypes &&
+ hasSelectedDeliveryStatuses &&
+ deliveriesQuery.isLoading
+
+ const emptyStateLabel = !hasSelectedDeliveryStatuses
+ ? 'No statuses selected'
+ : !hasSelectedEventTypes
+ ? 'No events selected'
+ : hasActiveFilters
+ ? 'No deliveries match these filters'
+ : 'No deliveries yet'
+ const handleLoadMore = useCallback(() => {
+ if (!deliveriesQuery.hasNextPage || deliveriesQuery.isFetchingNextPage)
+ return
+
+ deliveriesQuery.fetchNextPage()
+ }, [deliveriesQuery])
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/settings/webhooks/detail/delivery-filter-params.ts b/src/features/dashboard/settings/webhooks/detail/delivery-filter-params.ts
new file mode 100644
index 000000000..a017545de
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/delivery-filter-params.ts
@@ -0,0 +1,27 @@
+import { createParser, parseAsArrayOf } from 'nuqs/server'
+
+const WEBHOOK_DELIVERY_STATUSES = ['success', 'failed'] as const
+
+type WebhookDeliveryStatus = (typeof WEBHOOK_DELIVERY_STATUSES)[number]
+
+// Maps URL value to delivery status, e.g. "failed" -> "failed".
+const deliveryStatusParser = createParser({
+ parse: (value) => {
+ const matchedStatus = WEBHOOK_DELIVERY_STATUSES.find(
+ (status) => status === value
+ )
+
+ return matchedStatus ?? null
+ },
+ serialize: (value: WebhookDeliveryStatus) => value,
+})
+
+const deliveryFilterParams = {
+ statuses: parseAsArrayOf(deliveryStatusParser),
+}
+
+export {
+ deliveryFilterParams,
+ WEBHOOK_DELIVERY_STATUSES,
+ type WebhookDeliveryStatus,
+}
diff --git a/src/features/dashboard/settings/webhooks/detail/fallbacks.tsx b/src/features/dashboard/settings/webhooks/detail/fallbacks.tsx
new file mode 100644
index 000000000..da5f2b774
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/fallbacks.tsx
@@ -0,0 +1,49 @@
+import { Skeleton } from '@/ui/primitives/skeleton'
+
+const headerItemSkeletonClassNames = [
+ 'h-4 w-20',
+ 'h-4 w-64',
+ 'h-4 w-14',
+ 'h-4 w-36',
+ 'h-4 w-36',
+]
+
+export const WebhookDetailHeaderFallback = () => (
+
+)
+
+export const WebhookDetailContentFallback = () => (
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+ {Array.from({ length: 2 }).map((_, index) => (
+
+ ))}
+
+
+)
diff --git a/src/features/dashboard/settings/webhooks/detail/header.tsx b/src/features/dashboard/settings/webhooks/detail/header.tsx
new file mode 100644
index 000000000..8e7c44bc2
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/header.tsx
@@ -0,0 +1,101 @@
+'use client'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { useMemo } from 'react'
+import type { TitleSegment } from '@/configs/layout'
+import { PROTECTED_URLS } from '@/configs/urls'
+import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges'
+import { Timestamp } from '@/features/dashboard/shared'
+import { usePageTitle } from '@/lib/hooks/use-page-title'
+import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast'
+import { useTRPC } from '@/trpc/client'
+import CopyButton from '@/ui/copy-button'
+import { DetailsItem, DetailsRow } from '../../../layouts/details-row'
+
+type WebhookDetailHeaderProps = {
+ teamSlug: string
+ webhookId: string
+}
+
+export const WebhookDetailHeader = ({
+ teamSlug,
+ webhookId,
+}: WebhookDetailHeaderProps) => {
+ const trpc = useTRPC()
+ const { data } = useSuspenseQuery(
+ trpc.webhooks.get.queryOptions({ teamSlug, webhookId })
+ )
+ const latestDeliveryQuery = useSuspenseQuery(
+ trpc.webhooks.listDeliveries.queryOptions({
+ teamSlug,
+ webhookId,
+ limit: 1,
+ })
+ )
+ const { webhook } = data
+ const latestAttempt =
+ latestDeliveryQuery.data?.groups[0]?.latestAttempt ?? null
+ const titleSegments = useMemo(
+ () => [
+ {
+ label: 'Webhooks',
+ href: PROTECTED_URLS.WEBHOOKS(teamSlug),
+ },
+ { label: webhook.name },
+ ],
+ [teamSlug, webhook.name]
+ )
+
+ usePageTitle(titleSegments, webhookId)
+
+ return (
+
+
+
+
+
+ {webhook.url}
+
+
toast(defaultSuccessToast('Webhook URL copied'))}
+ value={webhook.url}
+ />
+
+
+
+
+
+
+
+
+
+
+ toast(defaultSuccessToast('Timestamp copied'))}
+ value={webhook.createdAt}
+ />
+
+
+
+ {latestAttempt ? (
+
+
+ toast(defaultSuccessToast('Timestamp copied'))}
+ value={latestAttempt.timestamp}
+ />
+
+ ) : (
+ -
+ )}
+
+
+
+ )
+}
diff --git a/src/features/dashboard/settings/webhooks/detail/index.ts b/src/features/dashboard/settings/webhooks/detail/index.ts
new file mode 100644
index 000000000..c8b5997ac
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/index.ts
@@ -0,0 +1,3 @@
+export { WebhookDeliveriesContent } from './deliveries-content'
+export { WebhookDetailLayout } from './layout'
+export { WebhookOverviewContent } from './overview-content'
diff --git a/src/features/dashboard/settings/webhooks/detail/layout.tsx b/src/features/dashboard/settings/webhooks/detail/layout.tsx
new file mode 100644
index 000000000..ff92caf09
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/layout.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import { Suspense } from 'react'
+import { PROTECTED_URLS } from '@/configs/urls'
+import { DashboardTabsList } from '@/ui/dashboard-tabs'
+import { ListIcon, TrendIcon } from '@/ui/primitives/icons'
+import {
+ WebhookDetailContentFallback,
+ WebhookDetailHeaderFallback,
+} from './fallbacks'
+import { WebhookDetailHeader } from './header'
+
+type WebhookDetailLayoutProps = {
+ children: React.ReactNode
+ teamSlug: string
+ webhookId: string
+}
+
+export const WebhookDetailLayout = ({
+ children,
+ teamSlug,
+ webhookId,
+}: WebhookDetailLayoutProps) => (
+
+ }>
+
+
+ ,
+ },
+ {
+ id: 'deliveries',
+ label: 'Events',
+ href: PROTECTED_URLS.WEBHOOK_DELIVERIES(teamSlug, webhookId),
+ icon: ,
+ },
+ ]}
+ />
+ }>{children}
+
+)
diff --git a/src/features/dashboard/settings/webhooks/detail/overview-content.tsx b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx
new file mode 100644
index 000000000..c59aaf597
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx
@@ -0,0 +1,402 @@
+'use client'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { useQueryStates } from 'nuqs'
+import type { ReactNode } from 'react'
+import { useMemo } from 'react'
+import { useTRPC } from '@/trpc/client'
+import {
+ getDeliveryCountSeriesData,
+ getResponseTimeSeriesData,
+} from './chart-utils'
+import { StatsChart, type StatsChartSeries } from './stats-chart'
+import { StatsIntervalSelect } from './stats-interval-select'
+import {
+ getValidWebhookStatsBounds,
+ getWebhookStatsApiBounds,
+ getWebhookStatsBucketIntervalSeconds,
+ getWebhookStatsRange,
+ getWebhookStatsRangeFromBounds,
+ type WebhookStatsRange,
+ type WebhookStatsRangeBounds,
+ webhookStatsTimeframeParams,
+} from './stats-range'
+
+type WebhookOverviewContentProps = {
+ teamSlug: string
+ webhookId: string
+ initialRangeBounds: WebhookStatsRangeBounds
+}
+
+type MetricPanelProps = {
+ label: string
+ value: string
+ description: string
+}
+
+type ChartPanelProps = {
+ children: ReactNode
+ legendItems: ChartLegendItem[]
+ title: string
+}
+
+type ChartLegendItem = {
+ label: string
+ indicatorClassName: string
+}
+
+const MetricPanel = ({ label, value, description }: MetricPanelProps) => (
+
+ {label}
+
+ {value}
+
+ {description}
+
+)
+
+const ChartLegend = ({ items }: { items: ChartLegendItem[] }) => (
+
+ {items.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+)
+
+const ChartPanel = ({ children, legendItems, title }: ChartPanelProps) => (
+
+)
+
+const deliveryLegendItems = [
+ {
+ label: 'Total',
+ indicatorClassName: 'bg-accent-info-highlight',
+ },
+ {
+ label: 'Failed',
+ indicatorClassName: 'bg-accent-error-highlight',
+ },
+] satisfies ChartLegendItem[]
+
+const latencyLegendItems = [
+ {
+ label: 'Min',
+ indicatorClassName: 'bg-accent-positive-highlight',
+ },
+ {
+ label: 'Avg',
+ indicatorClassName: 'bg-bg-inverted',
+ },
+ {
+ label: 'Max',
+ indicatorClassName: 'bg-accent-main-highlight',
+ },
+] satisfies ChartLegendItem[]
+
+export const WebhookOverviewContent = ({
+ teamSlug,
+ webhookId,
+ initialRangeBounds,
+}: WebhookOverviewContentProps) => {
+ const [timeframeParams, setTimeframeParams] = useQueryStates(
+ webhookStatsTimeframeParams,
+ {
+ history: 'push',
+ shallow: true,
+ }
+ )
+ const rangeBounds = useMemo(
+ () =>
+ getValidWebhookStatsBounds({
+ start: timeframeParams.start ?? initialRangeBounds.start,
+ end: timeframeParams.end ?? initialRangeBounds.end,
+ }),
+ [timeframeParams.start, timeframeParams.end, initialRangeBounds]
+ )
+ const apiRangeBounds = useMemo(
+ () => getWebhookStatsApiBounds(rangeBounds),
+ [rangeBounds]
+ )
+ const range = getWebhookStatsRangeFromBounds(rangeBounds)
+ const trpc = useTRPC()
+ const { data } = useSuspenseQuery(
+ trpc.webhooks.getDeliveryStats.queryOptions({
+ teamSlug,
+ webhookId,
+ ...apiRangeBounds,
+ })
+ )
+ const stats = data.stats
+ const buckets = stats.buckets
+ const failureRate =
+ stats.total > 0
+ ? `${((stats.failed / stats.total) * 100).toFixed(1)}%`
+ : '0%'
+ const bucketIntervalSeconds =
+ getWebhookStatsBucketIntervalSeconds(rangeBounds)
+ const rangeStartMs = rangeBounds.start
+ const rangeEndMs = rangeBounds.end
+ const hasFailedDeliveries = buckets.some((bucket) => bucket.failed > 0)
+ const totalDeliveryData = getDeliveryCountSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds
+ )
+ const failedDeliveryData = getDeliveryCountSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds,
+ 'failed'
+ )
+ const successfulDeliveryBandData = totalDeliveryData.map((point, index) => {
+ const failedValue = failedDeliveryData[index]?.value ?? 0
+ const totalValue = point.value ?? 0
+
+ return {
+ ...point,
+ value: Math.max(totalValue - failedValue, 0),
+ }
+ })
+ const deliverySeries = [
+ {
+ name: 'Failed deliveries fill',
+ colorVar: '--accent-error-highlight',
+ alignAreaGradientToYAxis: true,
+ areaFromOpacity: 0.14,
+ areaGradientData: failedDeliveryData,
+ areaToOpacity: 0.04,
+ showArea: true,
+ lineOpacity: 0,
+ showSymbol: false,
+ showTooltipMarker: false,
+ stack: 'delivery-area',
+ z: 1,
+ data: failedDeliveryData,
+ },
+ {
+ name: 'Successful deliveries fill',
+ colorVar: '--accent-info-highlight',
+ alignAreaGradientToYAxis: true,
+ areaFromOpacity: 0.12,
+ areaGradientData: totalDeliveryData,
+ areaToOpacity: 0.03,
+ showArea: true,
+ lineOpacity: 0,
+ showSymbol: false,
+ showTooltipMarker: false,
+ stack: 'delivery-area',
+ z: 1,
+ data: successfulDeliveryBandData,
+ },
+ {
+ name: 'Total deliveries',
+ colorVar: '--accent-info-highlight',
+ showSymbol: false,
+ z: 2,
+ data: totalDeliveryData,
+ },
+ {
+ name: 'Failed deliveries',
+ colorVar: '--accent-error-highlight',
+ showSymbol: false,
+ z: hasFailedDeliveries ? 3 : 1,
+ data: failedDeliveryData,
+ },
+ {
+ name: 'Total zero baseline',
+ colorVar: '--accent-info-highlight',
+ showSymbol: false,
+ showTooltipMarker: false,
+ z: 4,
+ data: totalDeliveryData.map((point) => ({
+ ...point,
+ value: point.value === 0 ? 0 : null,
+ })),
+ },
+ ] satisfies StatsChartSeries[]
+ const minLatencyData = getResponseTimeSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds,
+ 'min'
+ )
+ const avgLatencyData = getResponseTimeSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds,
+ 'avg'
+ )
+ const maxLatencyData = getResponseTimeSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds,
+ 'max'
+ )
+ const getLatencyBandData = (
+ lowerData: typeof minLatencyData | null,
+ upperData: typeof minLatencyData
+ ) =>
+ upperData.map((point, index) => {
+ const lowerValue = lowerData?.[index]?.value ?? 0
+ const upperValue = point.value ?? 0
+
+ return {
+ ...point,
+ value: Math.max(upperValue - lowerValue, 0),
+ }
+ })
+ const latencySeries = [
+ {
+ name: 'Min fill',
+ colorVar: '--accent-positive-highlight',
+ alignAreaGradientToYAxis: true,
+ areaGradientData: minLatencyData,
+ showArea: true,
+ areaFromOpacity: 0.08,
+ areaToOpacity: 0.018,
+ connectNulls: false,
+ lineOpacity: 0,
+ showTooltipMarker: false,
+ showSymbol: false,
+ stack: 'latency-area',
+ z: 1,
+ data: getLatencyBandData(null, minLatencyData),
+ },
+ {
+ name: 'Avg fill',
+ colorVar: '--bg-inverted',
+ alignAreaGradientToYAxis: true,
+ areaGradientData: avgLatencyData,
+ showArea: true,
+ areaFromOpacity: 0.08,
+ areaToOpacity: 0.018,
+ connectNulls: false,
+ lineOpacity: 0,
+ showTooltipMarker: false,
+ showSymbol: false,
+ stack: 'latency-area',
+ z: 1,
+ data: getLatencyBandData(minLatencyData, avgLatencyData),
+ },
+ {
+ name: 'Max fill',
+ colorVar: '--accent-main-highlight',
+ alignAreaGradientToYAxis: true,
+ areaGradientData: maxLatencyData,
+ showArea: true,
+ areaFromOpacity: 0.1,
+ areaToOpacity: 0.02,
+ connectNulls: false,
+ lineOpacity: 0,
+ showTooltipMarker: false,
+ showSymbol: false,
+ stack: 'latency-area',
+ z: 1,
+ data: getLatencyBandData(avgLatencyData, maxLatencyData),
+ },
+ {
+ name: 'Min',
+ colorVar: '--accent-positive-highlight',
+ connectNulls: false,
+ showSymbol: false,
+ z: 2,
+ data: minLatencyData,
+ },
+ {
+ name: 'Avg',
+ colorVar: '--bg-inverted',
+ connectNulls: false,
+ showSymbol: false,
+ z: 4,
+ data: avgLatencyData,
+ },
+ {
+ name: 'Max',
+ colorVar: '--accent-main-highlight',
+ connectNulls: false,
+ showSymbol: false,
+ z: 2,
+ data: maxLatencyData,
+ },
+ ] satisfies StatsChartSeries[]
+ const handleRangeChange = (nextRange: WebhookStatsRange) => {
+ setTimeframeParams(getWebhookStatsRange(nextRange))
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `${value.toLocaleString('en-US', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ })}ms`
+ }
+ yAxisValueFormatter={(value) =>
+ `${Math.round(value).toLocaleString()}ms`
+ }
+ />
+
+
+
+ )
+}
diff --git a/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx b/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx
new file mode 100644
index 000000000..e7b203ce1
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx
@@ -0,0 +1,805 @@
+'use client'
+
+import type { EChartsOption, SeriesOption } from 'echarts'
+import { LineChart, ScatterChart } from 'echarts/charts'
+import {
+ AxisPointerComponent,
+ GridComponent,
+ TooltipComponent,
+} from 'echarts/components'
+import * as echarts from 'echarts/core'
+import { SVGRenderer } from 'echarts/renderers'
+import ReactEChartsCore from 'echarts-for-react/lib/core'
+import { useTheme } from 'next-themes'
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCssVars } from '@/lib/hooks/use-css-vars'
+import { cn } from '@/lib/utils'
+import { calculateAxisMax } from '@/lib/utils/chart'
+import { withOpacity } from '../../../sandbox/monitoring/utils/chart-colors'
+import type { WebhookStatsRange } from './stats-range'
+
+echarts.use([
+ LineChart,
+ ScatterChart,
+ GridComponent,
+ TooltipComponent,
+ AxisPointerComponent,
+ SVGRenderer,
+])
+
+type StatsChartPoint = {
+ synthetic?: boolean
+ timestamp: string
+ value: number | null
+}
+
+type StatsChartSeries = {
+ name: string
+ data: StatsChartPoint[]
+ alignAreaGradientToYAxis?: boolean
+ areaFromOpacity?: number
+ areaFromVar?: StatsChartColorVar
+ areaToOpacity?: number
+ areaToVar?: StatsChartColorVar
+ connectNulls?: boolean
+ areaGradientData?: StatsChartPoint[]
+ lineWidth?: number
+ lineOpacity?: number
+ showTooltipMarker?: boolean
+ showSymbol?: boolean
+ showArea?: boolean
+ stack?: string
+ z?: number
+ colorVar: StatsChartColorVar
+}
+
+type StatsChartProps = {
+ series: StatsChartSeries[]
+ bucketIntervalSeconds?: number
+ chartType?: 'line' | 'scatter'
+ className?: string
+ valueFormatter?: (value: number) => string
+ yAxisValueFormatter?: (value: number) => string
+ xAxisRange?: WebhookStatsRange
+ xAxisMax?: number
+ xAxisMin?: number
+}
+
+const HOUR_MS = 60 * 60 * 1000
+const DAY_MS = 24 * HOUR_MS
+const AXIS_LABEL_GRID_GAP = 8
+const MONO_AXIS_LABEL_CHAR_WIDTH = 7.2
+const CHART_LINE_WIDTH = 1
+const CHART_AREA_OPACITY = 1
+const CHART_FALLBACK_AREA_OPACITY = 0.18
+const CHART_AXIS_LABEL_FONT_SIZE = 12
+const MARKER_RIGHT_THRESHOLD_PX = 86
+const MARKER_OVERLAP_THRESHOLD_PX = 24
+const MARKER_LABEL_VERTICAL_GAP_PX = 20
+const MARKER_LABEL_MIN_GAP_PX = 24
+const MARKER_LABEL_TOP_CLEARANCE_PX = 28
+const MARKER_LABEL_BOTTOM_CLEARANCE_PX = 64
+
+const STATS_CHART_COLOR_VARS = [
+ '--accent-info-highlight',
+ '--accent-error-highlight',
+ '--accent-positive-highlight',
+ '--accent-main-highlight',
+ '--bg-inverted',
+] as const
+
+const STATS_CHART_STYLE_VARS = [
+ '--stroke',
+ '--bg-1',
+ '--font-mono',
+ '--fg-tertiary',
+] as const
+
+type StatsChartColorVar = (typeof STATS_CHART_COLOR_VARS)[number]
+
+type ChartPointValue = [number, number]
+
+type CrosshairMarker = {
+ key: string
+ xPx: number
+ yPx: number
+ valueContent: string
+ dotColor: string
+ placeValueOnRight: boolean
+ labelOffsetYPx: number
+ labelYPx: number
+}
+
+const formatAxisLabel = (
+ value: number,
+ range: NonNullable,
+ bounds: Pick
+) => {
+ const date = new Date(value)
+
+ if (range === 'this-week') {
+ return date.toLocaleDateString('en-US', { weekday: 'short' })
+ }
+
+ const isWholeHour =
+ date.getMinutes() === 0 &&
+ date.getSeconds() === 0 &&
+ date.getMilliseconds() === 0
+ if (!isWholeHour) return ''
+ if (bounds.xAxisMin && value < bounds.xAxisMin) return ''
+ if (bounds.xAxisMax && value >= bounds.xAxisMax) return ''
+ if (range === '12h' && bounds.xAxisMin) {
+ const firstWholeHour = Math.ceil(bounds.xAxisMin / HOUR_MS) * HOUR_MS
+ if ((value - firstWholeHour) % (2 * HOUR_MS) !== 0) return ''
+ }
+
+ return date
+ .toLocaleTimeString('en-US', { hour: 'numeric' })
+ .replace(/\s/g, '')
+}
+
+const getXAxisInterval = ({
+ range,
+ xAxisMax,
+ xAxisMin,
+}: Pick & {
+ range: NonNullable
+}) => {
+ if (range === 'this-week') return DAY_MS
+ if (range === '4h') return HOUR_MS
+ if (range === '12h') return 2 * HOUR_MS
+ if (!xAxisMin || !xAxisMax) return 2 * HOUR_MS
+
+ const rangeMs = xAxisMax - xAxisMin
+ if (rangeMs <= 6 * HOUR_MS) return HOUR_MS
+ if (rangeMs <= 12 * HOUR_MS) return 2 * HOUR_MS
+
+ return 4 * HOUR_MS
+}
+
+const defaultValueFormatter = (value: number) => value.toLocaleString()
+
+const toChartPointValue = (point: StatsChartPoint): ChartPointValue | null => {
+ if (point.value === null) return null
+
+ return [new Date(point.timestamp).getTime(), point.value]
+}
+
+const getClosestPoint = (
+ points: StatsChartPoint[],
+ timestampMs: number
+): ChartPointValue | null => {
+ const values = points
+ .map(toChartPointValue)
+ .filter((point): point is ChartPointValue => Boolean(point))
+ if (values.length === 0) return null
+
+ return values.reduce((closest, point) =>
+ Math.abs(point[0] - timestampMs) < Math.abs(closest[0] - timestampMs)
+ ? point
+ : closest
+ )
+}
+
+const applyMarkerLabelOffsets = (
+ markers: CrosshairMarker[],
+ chartHeight: number
+): CrosshairMarker[] => {
+ const minLabelYPx = MARKER_LABEL_TOP_CLEARANCE_PX
+ const maxLabelYPx = Math.max(
+ minLabelYPx,
+ chartHeight - MARKER_LABEL_BOTTOM_CLEARANCE_PX
+ )
+ const clampLabelYPx = (value: number) =>
+ Math.min(Math.max(value, minLabelYPx), maxLabelYPx)
+ const spreadLabels = (nextMarkers: CrosshairMarker[]) => {
+ if (nextMarkers.length < 2) return nextMarkers
+
+ const sortedMarkers = [...nextMarkers].sort(
+ (a, b) => a.labelYPx - b.labelYPx
+ )
+
+ for (let index = 1; index < sortedMarkers.length; index += 1) {
+ const previousMarker = sortedMarkers[index - 1]
+ const currentMarker = sortedMarkers[index]
+ if (!previousMarker || !currentMarker) continue
+
+ currentMarker.labelYPx = Math.max(
+ currentMarker.labelYPx,
+ previousMarker.labelYPx + MARKER_LABEL_MIN_GAP_PX
+ )
+ }
+
+ const lastMarker = sortedMarkers.at(-1)
+ if (lastMarker && lastMarker.labelYPx > maxLabelYPx) {
+ const overflowYPx = lastMarker.labelYPx - maxLabelYPx
+ sortedMarkers.forEach((marker) => {
+ marker.labelYPx -= overflowYPx
+ })
+ }
+
+ const firstMarker = sortedMarkers[0]
+ if (firstMarker && firstMarker.labelYPx < minLabelYPx) {
+ const underflowYPx = minLabelYPx - firstMarker.labelYPx
+ sortedMarkers.forEach((marker) => {
+ marker.labelYPx += underflowYPx
+ })
+ }
+
+ const labelYPxByMarkerKey = new Map(
+ sortedMarkers.map((marker) => [marker.key, marker.labelYPx])
+ )
+
+ return nextMarkers.map((marker) => ({
+ ...marker,
+ labelYPx: labelYPxByMarkerKey.get(marker.key) ?? marker.labelYPx,
+ }))
+ }
+
+ if (markers.length < 2) {
+ return spreadLabels(
+ markers.map((marker) => ({
+ ...marker,
+ labelYPx: clampLabelYPx(marker.yPx + marker.labelOffsetYPx),
+ }))
+ )
+ }
+
+ const sortedMarkers = [...markers].sort((a, b) => a.yPx - b.yPx)
+ const offsetsByMarkerKey = new Map()
+ let clusterStart = 0
+
+ for (let index = 1; index <= sortedMarkers.length; index += 1) {
+ const previousMarker = sortedMarkers[index - 1]
+ const currentMarker = sortedMarkers[index]
+ if (!previousMarker) continue
+
+ const shouldSplitCluster =
+ !currentMarker ||
+ Math.abs(currentMarker.yPx - previousMarker.yPx) >
+ MARKER_OVERLAP_THRESHOLD_PX
+ if (!shouldSplitCluster) continue
+
+ const cluster = sortedMarkers.slice(clusterStart, index)
+ const halfIndex = (cluster.length - 1) / 2
+
+ cluster.forEach((marker, clusterIndex) => {
+ offsetsByMarkerKey.set(
+ marker.key,
+ (clusterIndex - halfIndex) * MARKER_LABEL_VERTICAL_GAP_PX
+ )
+ })
+
+ clusterStart = index
+ }
+
+ return spreadLabels(
+ markers.map((marker) => {
+ const labelOffsetYPx =
+ offsetsByMarkerKey.get(marker.key) ?? marker.labelOffsetYPx
+
+ return {
+ ...marker,
+ labelOffsetYPx,
+ labelYPx: clampLabelYPx(marker.yPx + labelOffsetYPx),
+ }
+ })
+ )
+}
+
+const getTooltipDayLabel = (date: Date) => {
+ const now = new Date()
+ const yesterday = new Date()
+ yesterday.setDate(now.getDate() - 1)
+
+ if (date.toDateString() === now.toDateString()) return 'Today'
+ if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
+
+ return date.toLocaleDateString('en-US', { weekday: 'long' })
+}
+
+const formatTooltipTime = (date: Date, showMinutes: boolean) =>
+ date
+ .toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: showMinutes ? '2-digit' : undefined,
+ })
+ .replace(/\s/g, '')
+ .toLowerCase()
+
+const formatTooltipTimestamp = (
+ timestampMs: number,
+ range: NonNullable
+) => {
+ const date = new Date(timestampMs)
+ const day = getTooltipDayLabel(date)
+ if (range === 'this-week') return day
+
+ const time = formatTooltipTime(date, false)
+
+ return `${day}, ${time}`
+}
+
+const formatTooltipInterval = (
+ startTimestampMs: number,
+ bucketIntervalSeconds: number,
+ range: NonNullable
+) => {
+ const startDate = new Date(startTimestampMs)
+ if (range === 'this-week') return getTooltipDayLabel(startDate)
+
+ const endDate = new Date(startTimestampMs + bucketIntervalSeconds * 1000)
+ const showMinutes = bucketIntervalSeconds < HOUR_MS / 1000
+ const startTime = formatTooltipTime(startDate, showMinutes)
+ const endTime = formatTooltipTime(endDate, showMinutes)
+
+ return `${getTooltipDayLabel(startDate)}, ${startTime} — ${endTime}`
+}
+
+const getTooltipSyntheticValue = (param: unknown) => {
+ if (!param || typeof param !== 'object') return false
+ if (!('data' in param)) return false
+ if (!param.data || typeof param.data !== 'object') return false
+ if (!('synthetic' in param.data)) return false
+
+ return param.data.synthetic === true
+}
+
+const StatsChart = memo(function StatsChart({
+ series,
+ bucketIntervalSeconds,
+ chartType = 'scatter',
+ className,
+ valueFormatter = defaultValueFormatter,
+ yAxisValueFormatter = valueFormatter,
+ xAxisRange = 'this-week',
+ xAxisMax,
+ xAxisMin,
+}: StatsChartProps) {
+ const chartRef = useRef(null)
+ const chartInstanceRef = useRef(null)
+ const [hoveredTimestampMs, setHoveredTimestampMs] = useState(
+ null
+ )
+ const [chartRevision, setChartRevision] = useState(0)
+ const { resolvedTheme } = useTheme()
+ const cssVars = useCssVars([
+ ...STATS_CHART_COLOR_VARS,
+ ...STATS_CHART_STYLE_VARS,
+ ] as const)
+
+ const stroke = cssVars['--stroke'] || '#d4d4d4'
+ const bg1 = cssVars['--bg-1'] || '#fff'
+ const fgTertiary = cssVars['--fg-tertiary'] || '#666'
+ const fontMono = cssVars['--font-mono'] || 'monospace'
+ const axisPointerColor = stroke
+
+ const handleChartReady = useCallback((chart: echarts.ECharts) => {
+ chartInstanceRef.current = chart
+ chart.on('finished', () => setChartRevision((revision) => revision + 1))
+ }, [])
+
+ const handleUpdateAxisPointer = useCallback(
+ (params: {
+ axesInfo?: { value?: unknown }[]
+ xAxisInfo?: { value?: unknown }[]
+ value?: unknown
+ }) => {
+ const pointerValue =
+ params.axesInfo?.[0]?.value ??
+ params.xAxisInfo?.[0]?.value ??
+ params.value
+ const timestampMs =
+ typeof pointerValue === 'number' ? pointerValue : Number(pointerValue)
+ if (!Number.isFinite(timestampMs)) return
+
+ setHoveredTimestampMs(Math.floor(timestampMs))
+ },
+ []
+ )
+
+ const handleGlobalOut = useCallback(() => {
+ setHoveredTimestampMs(null)
+ chartInstanceRef.current?.dispatchAction({ type: 'hideTip' })
+ chartInstanceRef.current?.dispatchAction({
+ type: 'updateAxisPointer',
+ currTrigger: 'leave',
+ })
+ }, [])
+
+ const option = useMemo(() => {
+ const values = series.flatMap((item) =>
+ item.data.flatMap((point) => (point.value === null ? [] : [point.value]))
+ )
+ const yAxisMax = calculateAxisMax(values.length > 0 ? values : [0], 1.5)
+ const yAxisLabels = [0, yAxisMax / 2, yAxisMax].map(yAxisValueFormatter)
+ const yAxisLabelGutter =
+ Math.ceil(
+ Math.max(...yAxisLabels.map((label) => label.length)) *
+ MONO_AXIS_LABEL_CHAR_WIDTH
+ ) + AXIS_LABEL_GRID_GAP
+ const xAxisInterval = getXAxisInterval({
+ range: xAxisRange,
+ xAxisMax,
+ xAxisMin,
+ })
+
+ const chartSeries: SeriesOption[] = series.map((item) => {
+ const color = cssVars[item.colorVar] || '#000'
+ const areaFrom = item.areaFromVar
+ ? (cssVars[item.areaFromVar] ??
+ withOpacity(
+ color,
+ item.areaFromOpacity ?? CHART_FALLBACK_AREA_OPACITY
+ ))
+ : withOpacity(
+ color,
+ item.areaFromOpacity ?? CHART_FALLBACK_AREA_OPACITY
+ )
+ const areaTo = item.areaToVar
+ ? (cssVars[item.areaToVar] ??
+ withOpacity(color, item.areaToOpacity ?? 0))
+ : withOpacity(color, item.areaToOpacity ?? 0)
+ const areaGradientData = item.areaGradientData ?? item.data
+ const itemMaxValue = Math.max(
+ ...areaGradientData.flatMap((point) =>
+ point.value === null ? [] : [point.value]
+ )
+ )
+ const areaGradientY =
+ item.alignAreaGradientToYAxis && itemMaxValue > 0
+ ? 1 - yAxisMax / itemMaxValue
+ : 0
+
+ return {
+ name: item.name,
+ type: chartType,
+ stack: item.stack,
+ z: item.z,
+ data: item.data.map((point) => ({
+ synthetic: point.synthetic,
+ value: [new Date(point.timestamp).getTime(), point.value],
+ })),
+ symbol: chartType === 'line' ? 'none' : 'circle',
+ symbolSize: (_value: unknown, params: unknown) =>
+ getTooltipSyntheticValue(params) ? 0 : 6,
+ showSymbol: item.showSymbol ?? chartType === 'scatter',
+ connectNulls: item.connectNulls,
+ smooth: false,
+ itemStyle: {
+ color,
+ },
+ areaStyle: item.showArea
+ ? {
+ opacity: CHART_AREA_OPACITY,
+ color: {
+ type: 'linear',
+ x: 0,
+ y: areaGradientY,
+ x2: 0,
+ y2: 1,
+ colorStops: [
+ { offset: 0, color: areaFrom },
+ { offset: 1, color: areaTo },
+ ],
+ },
+ }
+ : undefined,
+ lineStyle: {
+ color,
+ opacity: item.lineOpacity ?? 1,
+ width: item.lineWidth ?? CHART_LINE_WIDTH,
+ },
+ emphasis: {
+ disabled: true,
+ },
+ }
+ })
+
+ return {
+ backgroundColor: 'transparent',
+ animation: false,
+ grid: {
+ top: 16,
+ right: 16,
+ bottom: 28,
+ left: yAxisLabelGutter,
+ },
+ tooltip: {
+ show: true,
+ trigger: 'axis',
+ confine: true,
+ transitionDuration: 0,
+ enterable: false,
+ hideDelay: 0,
+ backgroundColor: 'transparent',
+ borderWidth: 0,
+ textStyle: {
+ color: 'transparent',
+ fontSize: 0,
+ },
+ axisPointer: {
+ type: 'line',
+ lineStyle: {
+ color: axisPointerColor,
+ type: 'solid',
+ width: CHART_LINE_WIDTH,
+ },
+ label: {
+ show: false,
+ },
+ },
+ formatter: () => '',
+ position: [-9999, -9999],
+ },
+ xAxis: {
+ type: 'time',
+ min: xAxisMin,
+ max: xAxisMax,
+ interval: xAxisInterval,
+ boundaryGap: [0, 0],
+ axisLine: { show: true, lineStyle: { color: stroke } },
+ axisTick: { show: false },
+ axisLabel: {
+ color: fgTertiary,
+ fontFamily: fontMono,
+ fontSize: CHART_AXIS_LABEL_FONT_SIZE,
+ hideOverlap: true,
+ formatter: (value: number) =>
+ formatAxisLabel(value, xAxisRange, { xAxisMax, xAxisMin }),
+ },
+ splitLine: { show: false },
+ axisPointer: {
+ show: true,
+ type: 'line',
+ lineStyle: {
+ color: stroke,
+ type: 'solid',
+ width: CHART_LINE_WIDTH,
+ },
+ snap: false,
+ label: {
+ show: false,
+ },
+ },
+ },
+ yAxis: {
+ type: 'value',
+ min: 0,
+ max: yAxisMax,
+ interval: yAxisMax / 2,
+ axisLine: { show: false },
+ axisTick: { show: false },
+ axisLabel: {
+ align: 'left',
+ color: fgTertiary,
+ fontFamily: fontMono,
+ fontSize: CHART_AXIS_LABEL_FONT_SIZE,
+ interval: 0,
+ margin: yAxisLabelGutter,
+ formatter: (value: number) => yAxisValueFormatter(value),
+ },
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: withOpacity(stroke, 0.7),
+ type: 'dashed',
+ },
+ interval: 0,
+ },
+ axisPointer: { show: false },
+ },
+ series: chartSeries,
+ }
+ }, [
+ series,
+ chartType,
+ cssVars,
+ stroke,
+ axisPointerColor,
+ fgTertiary,
+ fontMono,
+ yAxisValueFormatter,
+ xAxisRange,
+ xAxisMax,
+ xAxisMin,
+ ])
+
+ useEffect(() => {
+ const frame = requestAnimationFrame(() => {
+ chartRef.current?.getEchartsInstance().resize()
+ })
+
+ return () => cancelAnimationFrame(frame)
+ }, [])
+
+ const crosshairMarkers = useMemo(() => {
+ void chartRevision
+
+ const chart = chartInstanceRef.current
+ if (hoveredTimestampMs === null || !chart || chart.isDisposed()) {
+ return []
+ }
+ const chartHeight = chart.getHeight()
+
+ const markers = series.flatMap((item) => {
+ if (item.showTooltipMarker === false) return []
+
+ const closestPoint = getClosestPoint(item.data, hoveredTimestampMs)
+ if (!closestPoint) return []
+
+ const [timestampMs, value] = closestPoint
+ if (
+ (xAxisMin !== undefined && timestampMs < xAxisMin) ||
+ (xAxisMax !== undefined && timestampMs > xAxisMax)
+ ) {
+ return []
+ }
+
+ const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [
+ timestampMs,
+ value,
+ ])
+ if (!Array.isArray(pixel) || pixel.length < 2) return []
+
+ const [xPx, yPx] = pixel
+ if (
+ typeof xPx !== 'number' ||
+ typeof yPx !== 'number' ||
+ !Number.isFinite(xPx) ||
+ !Number.isFinite(yPx)
+ ) {
+ return []
+ }
+
+ const firstPoint = item.data.map(toChartPointValue).find(Boolean)
+ const firstTimestampMs = firstPoint?.[0] ?? null
+ const firstPointPixel =
+ firstTimestampMs === null
+ ? null
+ : chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [
+ firstTimestampMs,
+ 0,
+ ])
+ const firstPointXPx =
+ Array.isArray(firstPointPixel) && typeof firstPointPixel[0] === 'number'
+ ? firstPointPixel[0]
+ : null
+
+ return [
+ {
+ key: `${item.name}-${timestampMs}`,
+ xPx,
+ yPx,
+ valueContent: valueFormatter(value),
+ dotColor: cssVars[item.colorVar] || stroke,
+ placeValueOnRight:
+ firstPointXPx !== null &&
+ xPx - firstPointXPx <= MARKER_RIGHT_THRESHOLD_PX,
+ labelOffsetYPx: 0,
+ labelYPx: yPx,
+ },
+ ]
+ })
+
+ return applyMarkerLabelOffsets(markers, chartHeight)
+ }, [
+ chartRevision,
+ cssVars,
+ hoveredTimestampMs,
+ series,
+ stroke,
+ valueFormatter,
+ xAxisMax,
+ xAxisMin,
+ ])
+
+ const xAxisHoverBadge = useMemo(() => {
+ void chartRevision
+
+ const chart = chartInstanceRef.current
+ if (hoveredTimestampMs === null || !chart || chart.isDisposed()) {
+ return null
+ }
+
+ const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [
+ hoveredTimestampMs,
+ 0,
+ ])
+ if (!Array.isArray(pixel) || typeof pixel[0] !== 'number') return null
+
+ return {
+ xPx: pixel[0],
+ label: bucketIntervalSeconds
+ ? formatTooltipInterval(
+ hoveredTimestampMs,
+ bucketIntervalSeconds,
+ xAxisRange
+ )
+ : formatTooltipTimestamp(hoveredTimestampMs, xAxisRange),
+ }
+ }, [bucketIntervalSeconds, chartRevision, hoveredTimestampMs, xAxisRange])
+
+ const onEvents = useMemo(
+ () => ({
+ globalout: handleGlobalOut,
+ updateAxisPointer: handleUpdateAxisPointer,
+ }),
+ [handleGlobalOut, handleUpdateAxisPointer]
+ )
+
+ return (
+
+
+ {crosshairMarkers.length > 0 || xAxisHoverBadge ? (
+
+ {crosshairMarkers.map((marker) => (
+
+
+
+
+
+
+ {marker.valueContent}
+
+
+
+ ))}
+ {xAxisHoverBadge ? (
+
+ {xAxisHoverBadge.label}
+
+ ) : null}
+
+ ) : null}
+
+ )
+})
+
+export { StatsChart, type StatsChartPoint, type StatsChartSeries }
diff --git a/src/features/dashboard/settings/webhooks/detail/stats-interval-select.tsx b/src/features/dashboard/settings/webhooks/detail/stats-interval-select.tsx
new file mode 100644
index 000000000..4b498aa51
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-interval-select.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/ui/primitives/select'
+import {
+ isWebhookStatsRange,
+ WEBHOOK_STATS_RANGE_OPTIONS,
+ type WebhookStatsRange,
+} from './stats-range'
+
+type StatsIntervalSelectProps = {
+ value: WebhookStatsRange
+ onChange: (value: WebhookStatsRange) => void
+}
+
+export const StatsIntervalSelect = ({
+ value,
+ onChange,
+}: StatsIntervalSelectProps) => {
+ const handleValueChange = (nextValue: string) => {
+ if (!isWebhookStatsRange(nextValue)) return
+
+ onChange(nextValue)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/settings/webhooks/detail/stats-range.ts b/src/features/dashboard/settings/webhooks/detail/stats-range.ts
new file mode 100644
index 000000000..495dd3608
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts
@@ -0,0 +1,144 @@
+import { createLoader, parseAsInteger } from 'nuqs/server'
+
+type WebhookStatsRangeBounds = {
+ start: number
+ end: number
+}
+
+type WebhookStatsApiBounds = {
+ start: string
+ end: string
+}
+
+const MAX_WEBHOOK_STATS_RANGE_MS = 7 * 24 * 60 * 60 * 1000
+const HOUR_MS = 60 * 60 * 1000
+const DAY_MS = 24 * HOUR_MS
+
+const webhookStatsTimeframeParams = {
+ start: parseAsInteger,
+ end: parseAsInteger,
+}
+
+const loadWebhookStatsTimeframeParams = createLoader(
+ webhookStatsTimeframeParams
+)
+
+const getStableNow = () => {
+ const now = Date.now()
+ return Math.floor(now / 1_000) * 1_000
+}
+
+const getStartOfDay = (timestamp: number) => {
+ const date = new Date(timestamp)
+ date.setHours(0, 0, 0, 0)
+ return date.getTime()
+}
+
+const WEBHOOK_STATS_RANGE_OPTIONS = [
+ {
+ value: '4h',
+ label: 'Last 4 hours',
+ getStart: (end: number) => end - 4 * 60 * 60 * 1000,
+ },
+ {
+ value: '12h',
+ label: 'Last 12 hours',
+ getStart: (end: number) => end - 12 * 60 * 60 * 1000,
+ },
+ { value: 'today', label: 'Today', getStart: getStartOfDay },
+ {
+ value: 'this-week',
+ label: 'Last 7 days',
+ getStart: (end: number) => end - 7 * 24 * 60 * 60 * 1000,
+ },
+] as const
+
+const WEBHOOK_STATS_RANGE_VALUES = WEBHOOK_STATS_RANGE_OPTIONS.map(
+ (option) => option.value
+) as [WebhookStatsRange, ...WebhookStatsRange[]]
+
+type WebhookStatsRange = (typeof WEBHOOK_STATS_RANGE_OPTIONS)[number]['value']
+
+const DEFAULT_WEBHOOK_STATS_RANGE: WebhookStatsRange = 'this-week'
+
+const getWebhookStatsRangeOption = (range: WebhookStatsRange) => {
+ const matchedOption = WEBHOOK_STATS_RANGE_OPTIONS.find(
+ (option) => option.value === range
+ )
+ if (matchedOption) return matchedOption
+
+ return WEBHOOK_STATS_RANGE_OPTIONS[0]
+}
+
+const isWebhookStatsRange = (range: string): range is WebhookStatsRange =>
+ WEBHOOK_STATS_RANGE_OPTIONS.some((option) => option.value === range)
+
+// Builds millisecond stats bounds from a range, e.g. "4h" -> { start: 177..., end: 177... }.
+const getWebhookStatsRange = (
+ range: WebhookStatsRange
+): WebhookStatsRangeBounds => {
+ const end = getStableNow()
+ const option = getWebhookStatsRangeOption(range)
+
+ return {
+ start: option.getStart(end),
+ end,
+ }
+}
+
+const getWebhookStatsApiBounds = ({
+ start,
+ end,
+}: WebhookStatsRangeBounds): WebhookStatsApiBounds => ({
+ start: new Date(start).toISOString(),
+ end: new Date(end).toISOString(),
+})
+
+// Mirrors Belt's server-side webhook stats bucketing for frontend gap filling and tooltips.
+const getWebhookStatsBucketIntervalSeconds = ({
+ start,
+ end,
+}: WebhookStatsRangeBounds): number => {
+ const rangeMs = end - start
+ if (rangeMs <= 4 * HOUR_MS) return 300
+ if (rangeMs <= 12 * HOUR_MS) return 1800
+ if (rangeMs <= DAY_MS) return 3600
+
+ return 86400
+}
+
+const getWebhookStatsRangeFromBounds = ({
+ start,
+ end,
+}: WebhookStatsRangeBounds): WebhookStatsRange => {
+ return (
+ WEBHOOK_STATS_RANGE_OPTIONS.find(
+ (option) => Math.abs(option.getStart(end) - start) < 60_000
+ )?.value ?? DEFAULT_WEBHOOK_STATS_RANGE
+ )
+}
+
+const getValidWebhookStatsBounds = ({
+ start,
+ end,
+}: Partial): WebhookStatsRangeBounds =>
+ start && end && end > start && end - start <= MAX_WEBHOOK_STATS_RANGE_MS
+ ? { start, end }
+ : getWebhookStatsRange(DEFAULT_WEBHOOK_STATS_RANGE)
+
+export {
+ DEFAULT_WEBHOOK_STATS_RANGE,
+ getWebhookStatsApiBounds,
+ getWebhookStatsBucketIntervalSeconds,
+ getWebhookStatsRange,
+ getWebhookStatsRangeFromBounds,
+ getValidWebhookStatsBounds,
+ isWebhookStatsRange,
+ loadWebhookStatsTimeframeParams,
+ webhookStatsTimeframeParams,
+ WEBHOOK_STATS_RANGE_OPTIONS,
+ WEBHOOK_STATS_RANGE_VALUES,
+ type WebhookStatsApiBounds,
+ type WebhookStatsRange,
+ type WebhookStatsRangeBounds,
+}
diff --git a/src/features/dashboard/settings/webhooks/event-badges.tsx b/src/features/dashboard/settings/webhooks/event-badges.tsx
new file mode 100644
index 000000000..b19df45b7
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/event-badges.tsx
@@ -0,0 +1,54 @@
+import { Fragment } from 'react'
+import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types'
+import { Badge } from '@/ui/primitives/badge'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/ui/primitives/tooltip'
+import { WEBHOOK_EVENT_LABELS } from './constants'
+
+type WebhookEventBadgesProps = {
+ events: readonly string[]
+}
+
+const getWebhookEventLabel = (event: string): string => {
+ const matchedEvent = SandboxLifecycleEventTypeSchema.options.find(
+ (webhookEvent) => webhookEvent === event
+ )
+ if (!matchedEvent) return event
+ return WEBHOOK_EVENT_LABELS[matchedEvent]
+}
+
+export const WebhookEventBadges = ({ events }: WebhookEventBadgesProps) => {
+ const isAllEvents =
+ events.length === SandboxLifecycleEventTypeSchema.options.length
+
+ if (isAllEvents) {
+ return (
+
+
+ ALL ({events.length})
+
+
+
+ {SandboxLifecycleEventTypeSchema.options.map((event, index) => (
+
+ {index > 0 && (
+
+ ·
+
+ )}
+ {WEBHOOK_EVENT_LABELS[event]}
+
+ ))}
+
+
+
+ )
+ }
+
+ return events.map((event) => (
+ {getWebhookEventLabel(event)}
+ ))
+}
diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx
index 8779ce4c8..9e6e4421c 100644
--- a/src/features/dashboard/settings/webhooks/table-row.tsx
+++ b/src/features/dashboard/settings/webhooks/table-row.tsx
@@ -1,6 +1,8 @@
'use client'
+import { useRouter } from 'next/navigation'
import { Fragment, useState } from 'react'
+import { PROTECTED_URLS } from '@/configs/urls'
import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types'
import { useClipboard } from '@/lib/hooks/use-clipboard'
import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast'
@@ -30,6 +32,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/ui/primitives/tooltip'
+import { RowHoverFrame } from '@/ui/row-hover-frame'
import { useDashboard } from '../../context'
import { UserAvatar } from '../../shared'
import { WEBHOOK_EVENT_LABELS } from './constants'
@@ -85,7 +88,12 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
-
{name}
+
+ {name}
+
@@ -102,7 +110,7 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
)
}
-const rowCellClassName = 'p-0 py-1.5 align-middle [tr:first-child>&]:pt-0'
+const rowCellClassName = 'p-0 py-1.5 align-middle'
const rowContentClassName = 'flex items-center'
const actionIconClassName = 'size-4 text-fg-tertiary'
@@ -157,7 +165,10 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => {
return (
-
+ e.stopPropagation()}
+ >
@@ -187,6 +198,7 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => {
export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {
const { team } = useDashboard()
+ const router = useRouter()
const createdAt = webhook.createdAt
? new Date(webhook.createdAt).toLocaleDateString('en-US', {
@@ -196,9 +208,30 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {
})
: '-'
+ const webhookHref = PROTECTED_URLS.WEBHOOK(team.slug, webhook.id)
+ const handleRowClick = (event: React.MouseEvent) => {
+ if (!(event.target instanceof Node)) return
+ if (!event.currentTarget.contains(event.target)) return
+
+ router.push(webhookHref)
+ }
+
return (
-
+
+
@@ -208,9 +241,9 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {