+
{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..6c567ebbc
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/chart-utils.ts
@@ -0,0 +1,102 @@
+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'
+
+// 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
()
+ const intervalMs = bucketIntervalSeconds * 1000
+
+ 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 start = Math.floor(rangeBounds.start / intervalMs) * intervalMs
+ const end = Math.floor(rangeBounds.end / intervalMs) * intervalMs
+
+ 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
+}
+
+const hideInactiveZeroValuePoints = (
+ points: StatsChartPoint[],
+ nearbyOffsets = [-2, -1, 1, 2]
+) =>
+ points.map((point, index) => {
+ if (point.value !== 0) return point
+
+ const hasNearbyValue = nearbyOffsets.some(
+ (offset) => (points[index + offset]?.value ?? 0) > 0
+ )
+ if (hasNearbyValue) return point
+
+ return { ...point, synthetic: true, value: null }
+ })
+
+// Builds response-time points from API buckets, e.g. a bucket average -> one chart point.
+const getResponseTimeSeriesData = (
+ buckets: WebhookDeliveryStatsBucket[],
+ rangeBounds: WebhookStatsRangeBounds,
+ metric: ResponseTimeMetric
+) => {
+ const baseline: StatsChartPoint = {
+ synthetic: true,
+ timestamp: new Date(rangeBounds.start).toISOString(),
+ value: 0,
+ }
+ const bucketPoints = buckets.flatMap((bucket) => {
+ if (bucket.total <= 0) return []
+
+ const value =
+ metric === 'avg'
+ ? bucket.durationMs.average
+ : metric === 'max'
+ ? bucket.durationMs.maximum
+ : bucket.durationMs.minimum
+
+ return [
+ {
+ timestamp: bucket.timestamp,
+ value,
+ },
+ ]
+ })
+
+ return [
+ baseline,
+ ...bucketPoints.sort(
+ (left, right) =>
+ new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
+ ),
+ ]
+}
+
+export {
+ getDeliveryCountSeriesData,
+ getResponseTimeSeriesData,
+ hideInactiveZeroValuePoints,
+}
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..9e0d7cf55
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/header.tsx
@@ -0,0 +1,90 @@
+'use client'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges'
+import { Timestamp } from '@/features/dashboard/shared'
+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
+
+ return (
+
+
+
+
+ {webhook.name}
+
+
+
+
+
+ {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..3788181ae
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx
@@ -0,0 +1,231 @@
+'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,
+ hideInactiveZeroValuePoints,
+} from './chart-utils'
+import { StatsChart, type StatsChartSeries } from './stats-chart'
+import { StatsIntervalSelect } from './stats-interval-select'
+import {
+ getValidWebhookStatsBounds,
+ getWebhookStatsApiBounds,
+ 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
+ title: string
+}
+
+const MetricPanel = ({ label, value, description }: MetricPanelProps) => (
+
+ {label}
+
+ {value}
+
+ {description}
+
+)
+
+const ChartPanel = ({ children, title }: ChartPanelProps) => (
+
+)
+
+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 = apiRangeBounds.bucketIntervalSeconds
+ const rangeStartMs = rangeBounds.start
+ const rangeEndMs = rangeBounds.end
+ const hasFailedDeliveries = buckets.some((bucket) => bucket.failed > 0)
+ const failedDeliverySeriesData =
+ buckets.length > 0
+ ? getDeliveryCountSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds,
+ 'failed'
+ )
+ : []
+ const deliverySeries = [
+ {
+ name: 'Total deliveries',
+ colorVar: '--accent-info-highlight',
+ showSymbol: true,
+ z: 2,
+ data: getDeliveryCountSeriesData(
+ buckets,
+ rangeBounds,
+ bucketIntervalSeconds
+ ),
+ },
+ {
+ name: 'Failed deliveries',
+ colorVar: '--accent-error-highlight',
+ showSymbol: true,
+ z: hasFailedDeliveries ? 3 : 1,
+ data: hideInactiveZeroValuePoints(failedDeliverySeriesData, [-1, 1]),
+ },
+ ] satisfies StatsChartSeries[]
+ const latencySeries = [
+ {
+ name: 'Min',
+ colorVar: '--accent-info-highlight',
+ connectNulls: true,
+ lineWidth: 2,
+ showSymbol: true,
+ z: 1,
+ data: getResponseTimeSeriesData(buckets, rangeBounds, 'min'),
+ },
+ {
+ name: 'Avg',
+ colorVar: '--accent-main-highlight',
+ connectNulls: true,
+ lineWidth: 2,
+ showSymbol: true,
+ z: 3,
+ data: getResponseTimeSeriesData(buckets, rangeBounds, 'avg'),
+ },
+ {
+ name: 'Max',
+ colorVar: '--accent-warning-highlight',
+ connectNulls: true,
+ lineWidth: 2,
+ showSymbol: true,
+ z: 2,
+ data: getResponseTimeSeriesData(buckets, rangeBounds, 'max'),
+ },
+ ] 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..3c7fa970f
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-chart.tsx
@@ -0,0 +1,439 @@
+'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, useEffect, useMemo, useRef } from 'react'
+import { useCssVars } from '@/lib/hooks/use-css-vars'
+import { cn } from '@/lib/utils'
+import { calculateAxisMax } from '@/lib/utils/chart'
+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[]
+ connectNulls?: boolean
+ lineWidth?: number
+ showSymbol?: boolean
+ z?: number
+ colorVar:
+ | '--accent-main-highlight'
+ | '--accent-info-highlight'
+ | '--accent-error-highlight'
+ | '--accent-positive-highlight'
+ | '--accent-warning-highlight'
+ | '--fg'
+ | '--fg-secondary'
+ | '--fg-tertiary'
+}
+
+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 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 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 getTooltipTimestampMs = (param: unknown) => {
+ if (!param || typeof param !== 'object') return null
+ if (!('value' in param)) return null
+ if (!Array.isArray(param.value)) return null
+
+ const [timestamp] = param.value
+ return typeof timestamp === 'number' ? timestamp : null
+}
+
+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 { resolvedTheme } = useTheme()
+ const cssVars = useCssVars([
+ '--accent-main-highlight',
+ '--accent-info-highlight',
+ '--accent-error-highlight',
+ '--accent-positive-highlight',
+ '--accent-warning-highlight',
+ '--fg',
+ '--fg-secondary',
+ '--fg-tertiary',
+ '--stroke',
+ '--bg-1',
+ '--font-mono',
+ ] as const)
+
+ const stroke = cssVars['--stroke'] || '#d4d4d4'
+ const fgTertiary = cssVars['--fg-tertiary'] || '#666'
+ const bg = cssVars['--bg-1'] || '#fff'
+ const fontMono = cssVars['--font-mono'] || 'monospace'
+
+ 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 getTooltipContent = (param: unknown) => {
+ if (getTooltipSyntheticValue(param)) return ''
+
+ const timestampMs = getTooltipTimestampMs(param)
+ if (timestampMs === null) return ''
+
+ const rows = series.flatMap((item) => {
+ const point = item.data.find(
+ (point) =>
+ !point.synthetic &&
+ point.value !== null &&
+ new Date(point.timestamp).getTime() === timestampMs
+ )
+ if (!point || point.value === null) return []
+
+ const color = cssVars[item.colorVar] || '#000'
+
+ return [
+ `
+
+
+
+ ${item.name}
+
+
+ ${valueFormatter(point.value)}
+
`,
+ ]
+ })
+
+ if (rows.length === 0) return ''
+
+ const tooltipTimestamp = bucketIntervalSeconds
+ ? formatTooltipInterval(timestampMs, bucketIntervalSeconds, xAxisRange)
+ : formatTooltipTimestamp(timestampMs, xAxisRange)
+
+ return `
+
${tooltipTimestamp}
+
${rows.join('')}
+
`
+ }
+
+ const chartSeries: SeriesOption[] = series.map((item) => {
+ const color = cssVars[item.colorVar] || '#000'
+
+ return {
+ name: item.name,
+ type: chartType,
+ z: item.z,
+ data: item.data.map((point) => ({
+ synthetic: point.synthetic,
+ value: [new Date(point.timestamp).getTime(), point.value],
+ })),
+ symbol: 'circle',
+ symbolSize: (_value: unknown, params: unknown) =>
+ getTooltipSyntheticValue(params) ? 0 : 7,
+ showSymbol: item.showSymbol ?? chartType === 'scatter',
+ connectNulls: item.connectNulls,
+ itemStyle: {
+ color,
+ },
+ lineStyle: {
+ color,
+ width: item.lineWidth ?? 2,
+ },
+ emphasis: {
+ disabled: true,
+ },
+ }
+ })
+
+ return {
+ backgroundColor: 'transparent',
+ animation: false,
+ grid: {
+ top: 16,
+ right: 16,
+ bottom: 28,
+ left: yAxisLabelGutter,
+ },
+ tooltip: {
+ trigger: 'item',
+ confine: true,
+ transitionDuration: 0,
+ backgroundColor: bg,
+ borderColor: stroke,
+ borderWidth: 1,
+ textStyle: {
+ color: fgTertiary,
+ fontFamily: fontMono,
+ fontSize: 12,
+ },
+ axisPointer: {
+ type: 'line',
+ lineStyle: {
+ color: stroke,
+ type: 'solid',
+ width: 1,
+ },
+ label: {
+ show: false,
+ },
+ },
+ formatter: getTooltipContent,
+ },
+ 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: 12,
+ hideOverlap: true,
+ formatter: (value: number) =>
+ formatAxisLabel(value, xAxisRange, { xAxisMax, xAxisMin }),
+ },
+ splitLine: { show: false },
+ axisPointer: {
+ show: true,
+ type: 'line',
+ lineStyle: {
+ color: stroke,
+ type: 'solid',
+ width: 1,
+ },
+ 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: 12,
+ interval: 0,
+ margin: yAxisLabelGutter,
+ formatter: (value: number) => yAxisValueFormatter(value),
+ },
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: stroke,
+ type: 'dashed',
+ },
+ interval: 0,
+ },
+ axisPointer: { show: false },
+ },
+ series: chartSeries,
+ }
+ }, [
+ series,
+ bucketIntervalSeconds,
+ chartType,
+ cssVars,
+ stroke,
+ fgTertiary,
+ bg,
+ fontMono,
+ valueFormatter,
+ yAxisValueFormatter,
+ xAxisRange,
+ xAxisMax,
+ xAxisMin,
+ ])
+
+ useEffect(() => {
+ const frame = requestAnimationFrame(() => {
+ chartRef.current?.getEchartsInstance().resize()
+ })
+
+ return () => cancelAnimationFrame(frame)
+ }, [])
+
+ return (
+
+
+
+ )
+})
+
+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..8b0e2ea90
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts
@@ -0,0 +1,147 @@
+import { createLoader, parseAsInteger } from 'nuqs/server'
+import type { WebhookStatsBucketIntervalSeconds } from '@/core/server/functions/webhooks/schema'
+
+type WebhookStatsRangeBounds = {
+ start: number
+ end: number
+}
+
+type WebhookStatsApiBounds = {
+ bucketIntervalSeconds: WebhookStatsBucketIntervalSeconds
+ 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 => ({
+ bucketIntervalSeconds: getWebhookStatsBucketIntervalSeconds({ start, end }),
+ start: new Date(start).toISOString(),
+ end: new Date(end).toISOString(),
+})
+
+// Picks the API bucket size for a range, e.g. a 12h range -> 600 seconds.
+const getWebhookStatsBucketIntervalSeconds = ({
+ start,
+ end,
+}: WebhookStatsRangeBounds): WebhookStatsBucketIntervalSeconds => {
+ const rangeMs = end - start
+ if (rangeMs <= HOUR_MS) return 60
+ if (rangeMs <= 12 * HOUR_MS) return 600
+ if (rangeMs <= DAY_MS) return 1800
+
+ 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 c0b9c824b..b2beadb40 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'
@@ -85,7 +87,12 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
-
{name}
+
+ {name}
+
@@ -102,7 +109,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 +164,10 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => {
return (
-
+ e.stopPropagation()}
+ >
@@ -187,6 +197,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,8 +207,19 @@ 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 +230,9 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {