(null)
+ const trpc = useTRPC()
+ const eventTypeFilter = eventType.trim() || undefined
+ const deliveriesQuery = useInfiniteQuery(
+ trpc.webhooks.listDeliveries.infiniteQueryOptions(
+ {
+ teamSlug,
+ webhookId,
+ limit: 25,
+ deliveryStatus,
+ eventType: eventTypeFilter,
+ },
+ {
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
+ placeholderData: keepPreviousData,
+ }
+ )
+ )
+ const groups = useMemo(
+ () => deliveriesQuery.data?.pages.flatMap((page) => page.groups) ?? [],
+ [deliveriesQuery.data]
+ )
+ const selectedGroup = useMemo(
+ () =>
+ groups.find((group) => group.eventId === selectedEventId) ?? groups[0],
+ [groups, selectedEventId]
+ )
+ const selectedAttempt = selectedGroup?.latestAttempt ?? null
+ const deliveryDetailQuery = useQuery(
+ trpc.webhooks.getDelivery.queryOptions(
+ {
+ teamSlug,
+ webhookId,
+ deliveryId: selectedAttempt?.id ?? EMPTY_UUID,
+ },
+ {
+ enabled: Boolean(selectedAttempt),
+ }
+ )
+ )
+ const detailedAttempt = deliveryDetailQuery.data?.delivery ?? selectedAttempt
+ const hasActiveFilters = deliveryStatus !== 'all' || Boolean(eventTypeFilter)
+
+ return (
+
+
+ {
+ setDeliveryStatus(value)
+ setSelectedEventId(null)
+ }}
+ />
+ {
+ setEventType(event.target.value)
+ setSelectedEventId(null)
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Event
+ Status
+ HTTP
+ Attempts
+ Duration
+ Last attempt
+
+
+
+ {deliveriesQuery.isLoading ? (
+
+ ) : groups.length === 0 ? (
+
+
+
+ {hasActiveFilters
+ ? 'No deliveries match these filters'
+ : 'No deliveries yet'}
+
+
+ ) : (
+ groups.map((group) => {
+ const attempt = group.latestAttempt
+ const isSelected = group.eventId === selectedGroup?.eventId
+
+ return (
+ setSelectedEventId(group.eventId)}
+ >
+
+
+
+ {getEventLabel(group.eventType)}
+
+
+ {group.sandboxId}
+
+
+
+
+ {attempt ? (
+
+ ) : (
+ '-'
+ )}
+
+
+ {attempt
+ ? formatHttpStatus(attempt.httpStatusCode)
+ : '-'}
+
+ {group.attemptCount}
+
+ {attempt
+ ? `${attempt.durationMs.toLocaleString()}ms`
+ : '-'}
+
+
+ {attempt ? formatDateTime(attempt.timestamp) : '-'}
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
+
+
+
+ Showing {groups.length.toLocaleString()} grouped events
+
+
+
+
+
+
+ )
+}
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..5223ee1fa
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/header.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { WebhookEventBadges } from '@/features/dashboard/settings/webhooks/event-badges'
+import { Timestamp } from '@/features/dashboard/shared'
+import { useTRPC } from '@/trpc/client'
+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,
+ deliveryStatus: 'all',
+ })
+ )
+ const { webhook } = data
+ const latestAttempt =
+ latestDeliveryQuery.data?.groups[0]?.latestAttempt ?? null
+
+ return (
+
+
+
+
+ {webhook.url}
+
+
+
+
+
+
+
+
+
+
+
+ {latestAttempt ? (
+
+ ) : (
+ -
+ )}
+
+
+
+ )
+}
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..55efba0a2
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/layout.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { PROTECTED_URLS } from '@/configs/urls'
+import { DashboardTabsList } from '@/ui/dashboard-tabs'
+import { ListIcon, TrendIcon } from '@/ui/primitives/icons'
+import { WebhookDetailHeader } from './header'
+
+type WebhookDetailLayoutProps = {
+ children: React.ReactNode
+ teamSlug: string
+ webhookId: string
+}
+
+export const WebhookDetailLayout = ({
+ children,
+ teamSlug,
+ webhookId,
+}: WebhookDetailLayoutProps) => (
+
+
+ ,
+ },
+ {
+ id: 'deliveries',
+ label: 'Event deliveries',
+ 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..aed45d0fc
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx
@@ -0,0 +1,240 @@
+'use client'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { useQueryStates } from 'nuqs'
+import { useMemo } from 'react'
+import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
+import { useTRPC } from '@/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/ui/primitives/card'
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@/ui/primitives/chart'
+import { WebhookRangeSelector } from './range-selector'
+import {
+ getValidWebhookStatsBounds,
+ getWebhookStatsApiBounds,
+ getWebhookStatsRange,
+ getWebhookStatsRangeFromBounds,
+ type WebhookStatsRange,
+ type WebhookStatsRangeBounds,
+ webhookStatsTimeframeParams,
+} from './stats-range'
+
+type WebhookOverviewContentProps = {
+ teamSlug: string
+ webhookId: string
+ initialRangeBounds: WebhookStatsRangeBounds
+}
+
+type MetricCardProps = {
+ label: string
+ value: string
+ description: string
+}
+
+const deliveryChartConfig = {
+ total: {
+ label: 'Total deliveries',
+ color: 'var(--accent-info-highlight)',
+ },
+ failed: {
+ label: 'Failed deliveries',
+ color: 'var(--accent-error-highlight)',
+ },
+} satisfies ChartConfig
+
+const latencyChartConfig = {
+ avgDurationMs: {
+ label: 'Average duration',
+ color: 'var(--accent-positive-highlight)',
+ },
+} satisfies ChartConfig
+
+const formatBucketLabel = (value: string) =>
+ new Date(value).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ })
+
+const MetricCard = ({ label, value, description }: MetricCardProps) => (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+ {description}
+
+
+)
+
+const EmptyChartState = ({ label }: { label: string }) => (
+
+ {label}
+
+)
+
+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
+ const successful = Math.max(stats.total - stats.failed, 0)
+ const failureRate =
+ stats.total > 0
+ ? `${((stats.failed / stats.total) * 100).toFixed(1)}%`
+ : '0%'
+ const hasBuckets = stats.buckets.length > 0
+ const handleRangeChange = (nextRange: WebhookStatsRange) => {
+ setTimeframeParams(getWebhookStatsRange(nextRange))
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasBuckets ? (
+
+
+
+
+
+ } />
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {hasBuckets ? (
+
+
+
+
+
+ } />
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/settings/webhooks/detail/range-selector.tsx b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx
new file mode 100644
index 000000000..b22683afd
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/range-selector.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 WebhookRangeSelectorProps = {
+ value: WebhookStatsRange
+ onChange: (value: WebhookStatsRange) => void
+}
+
+export const WebhookRangeSelector = ({
+ value,
+ onChange,
+}: WebhookRangeSelectorProps) => {
+ 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..b544264e8
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts
@@ -0,0 +1,130 @@
+import { createLoader, parseAsInteger } from 'nuqs/server'
+
+type WebhookStatsRangeBounds = {
+ start: number
+ end: number
+}
+
+type WebhookStatsApiBounds = {
+ start: string
+ end: string
+}
+
+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 getStartOfWeek = (timestamp: number) => {
+ const date = new Date(timestamp)
+ const daysSinceMonday = (date.getDay() + 6) % 7
+ date.setDate(date.getDate() - daysSinceMonday)
+ 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: 'This week', getStart: getStartOfWeek },
+] 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(),
+})
+
+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
+ ? { start, end }
+ : getWebhookStatsRange(DEFAULT_WEBHOOK_STATS_RANGE)
+
+export {
+ DEFAULT_WEBHOOK_STATS_RANGE,
+ getWebhookStatsApiBounds,
+ 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/detail/status-badge.tsx b/src/features/dashboard/settings/webhooks/detail/status-badge.tsx
new file mode 100644
index 000000000..91a54a39d
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/status-badge.tsx
@@ -0,0 +1,36 @@
+import { Badge } from '@/ui/primitives/badge'
+
+type WebhookDeliveryHealth = 'disabled' | 'failing' | 'healthy' | 'unknown'
+
+const statusConfigMap: Record<
+ WebhookDeliveryHealth,
+ { label: string; variant: React.ComponentProps['variant'] }
+> = {
+ disabled: { label: 'Disabled', variant: 'warning' },
+ failing: { label: 'Failing', variant: 'error' },
+ healthy: { label: 'Healthy', variant: 'positive' },
+ unknown: { label: 'No deliveries', variant: 'info' },
+}
+
+type WebhookStatusBadgeProps = {
+ enabled: boolean
+ failedCount?: number
+ totalCount?: number
+}
+
+export const WebhookStatusBadge = ({
+ enabled,
+ failedCount,
+ totalCount,
+}: WebhookStatusBadgeProps) => {
+ const health: WebhookDeliveryHealth = !enabled
+ ? 'disabled'
+ : !totalCount
+ ? 'unknown'
+ : failedCount && failedCount > 0
+ ? 'failing'
+ : 'healthy'
+ const config = statusConfigMap[health]
+
+ return {config.label}
+}
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..c8ef13b0a 100644
--- a/src/features/dashboard/settings/webhooks/table-row.tsx
+++ b/src/features/dashboard/settings/webhooks/table-row.tsx
@@ -1,11 +1,11 @@
'use client'
-import { Fragment, useState } from 'react'
-import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types'
+import Link from 'next/link'
+import { useState } from 'react'
+import { PROTECTED_URLS } from '@/configs/urls'
import { useClipboard } from '@/lib/hooks/use-clipboard'
import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
-import { Badge } from '@/ui/primitives/badge'
import { Button } from '@/ui/primitives/button'
import {
DropdownMenu,
@@ -25,15 +25,10 @@ import {
WebhookIcon,
} from '@/ui/primitives/icons'
import { TableCell, TableRow } from '@/ui/primitives/table'
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from '@/ui/primitives/tooltip'
import { useDashboard } from '../../context'
import { UserAvatar } from '../../shared'
-import { WEBHOOK_EVENT_LABELS } from './constants'
import { DeleteWebhookDialog } from './delete-webhook-dialog'
+import { WebhookEventBadges } from './event-badges'
import type { Webhook } from './types'
import { UpdateWebhookSecretDialog } from './update-webhook-secret-dialog'
import { UpsertWebhookDialog } from './upsert-webhook-dialog'
@@ -47,6 +42,7 @@ type WebhookRowActionsProps = {
}
type WebhookNameAndUrlProps = {
+ href: string
name: string
url: string
}
@@ -59,7 +55,7 @@ const urlIconMap: Record = {
idle: WebhookIcon,
}
-const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
+const WebhookNameAndUrl = ({ href, name, url }: WebhookNameAndUrlProps) => {
const [wasCopied, copy] = useClipboard(1500)
const [isUrlHovered, setIsUrlHovered] = useState(false)
const iconState: UrlIconState = wasCopied
@@ -85,7 +81,13 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {