+
{children}
) : null}
diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx
index e1a780ee7..e8c2accd8 100644
--- a/src/features/dashboard/layouts/header.tsx
+++ b/src/features/dashboard/layouts/header.tsx
@@ -1,10 +1,13 @@
'use client'
+import { useSuspenseQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react'
import { getDashboardLayoutConfig, type TitleSegment } from '@/configs/layout'
+import { PROTECTED_URLS } from '@/configs/urls'
import { cn } from '@/lib/utils'
+import { useTRPC } from '@/trpc/client'
import ClientOnly from '@/ui/client-only'
import CopyButton from '@/ui/copy-button'
import { SidebarTrigger } from '@/ui/primitives/sidebar'
@@ -22,6 +25,7 @@ export default function DashboardLayoutHeader({
const pathname = usePathname()
const config = getDashboardLayoutConfig(pathname)
const copyableValue = config.copyValue ?? null
+ const webhookRoute = getWebhookRoute(pathname)
return (
-
+ {webhookRoute ? (
+
+ ) : (
+
+ )}
{copyableValue && (
{
+ const parts = pathname.split('/')
+ const teamSlug = parts[2]
+ const resource = parts[3]
+ const webhookId = parts[4]
+
+ if (resource !== 'webhooks' || !teamSlug || !webhookId) return null
+ return { teamSlug, webhookId }
+}
+
+function WebhookHeaderTitle({
+ teamSlug,
+ webhookId,
+}: {
+ teamSlug: string
+ webhookId: string
+}) {
+ const trpc = useTRPC()
+ const { data } = useSuspenseQuery(
+ trpc.webhooks.get.queryOptions({ teamSlug, webhookId })
+ )
+
+ return (
+
+
+ Webhooks
+
+ /
+ {data.webhook.name}
+
+ )
+}
+
function HeaderTitle({ title }: { title: string | TitleSegment[] }) {
if (typeof title === 'string') {
return title
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/deliveries-content.tsx b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx
new file mode 100644
index 000000000..8e6e4ba31
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/deliveries-content.tsx
@@ -0,0 +1,573 @@
+'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 {
+ EventTypeFilter,
+ eventTypeFilterParams,
+ IdBadge,
+ SandboxEventTypeBadge,
+} from '@/features/dashboard/shared'
+import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast'
+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 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}
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+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(
+ () =>
+ 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 event types 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/header.tsx b/src/features/dashboard/settings/webhooks/detail/header.tsx
new file mode 100644
index 000000000..2f9d4159c
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/header.tsx
@@ -0,0 +1,85 @@
+'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.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..16a8f4de6
--- /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: '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..4644cff9a
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/overview-content.tsx
@@ -0,0 +1,486 @@
+'use client'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { useQueryStates } from 'nuqs'
+import type { ReactNode } from 'react'
+import { useMemo } from 'react'
+import { type TRPCRouterOutputs, useTRPC } from '@/trpc/client'
+import { WebhookRangeSelector } from './range-selector'
+import {
+ getValidWebhookStatsBounds,
+ getWebhookStatsApiBounds,
+ getWebhookStatsRange,
+ getWebhookStatsRangeFromBounds,
+ type WebhookStatsRange,
+ type WebhookStatsRangeBounds,
+ webhookStatsTimeframeParams,
+} from './stats-range'
+import {
+ WebhookStatsChart,
+ type WebhookStatsChartPoint,
+ type WebhookStatsChartSeries,
+} from './webhook-stats-chart'
+
+type WebhookOverviewContentProps = {
+ teamSlug: string
+ webhookId: string
+ initialRangeBounds: WebhookStatsRangeBounds
+}
+
+type MetricPanelProps = {
+ label: string
+ value: string
+ description: string
+}
+
+type ChartPanelProps = {
+ children: ReactNode
+ title: string
+}
+
+type WebhookDeliveryStats =
+ TRPCRouterOutputs['webhooks']['getDeliveryStats']['stats']
+
+type ResponseTimeTimestampStats = {
+ count: number
+ maxDurationMs: number
+ minDurationMs: number
+ timestampMs: number
+ totalDurationMs: number
+}
+
+type WebhookStatsGrouping = 'day' | 'timestamp'
+type WebhookDeliveryStatsBucket = WebhookDeliveryStats['buckets'][number]
+type WebhookDeliveryStatus = 'failed'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const MINUTE_MS = 60 * 1000
+
+const MetricPanel = ({ label, value, description }: MetricPanelProps) => (
+
+ {label}
+
+ {value}
+
+ {description}
+
+)
+
+const ChartPanel = ({ children, title }: ChartPanelProps) => (
+
+)
+
+const getStartOfDay = (timestampMs: number) => {
+ const date = new Date(timestampMs)
+ date.setHours(0, 0, 0, 0)
+
+ return date.getTime()
+}
+
+const getSeriesTimestamp = (
+ timestamp: string,
+ grouping: WebhookStatsGrouping
+) => {
+ const timestampMs = new Date(timestamp).getTime()
+ if (grouping === 'day') return getStartOfDay(timestampMs)
+
+ return timestampMs
+}
+
+// Groups delivery buckets by chart granularity, e.g. minute buckets from one day -> one daily count.
+const getDeliveryCountSeriesData = (
+ buckets: WebhookDeliveryStatsBucket[],
+ rangeBounds: WebhookStatsRangeBounds,
+ grouping: WebhookStatsGrouping,
+ status?: WebhookDeliveryStatus
+) => {
+ const countByTimestamp = new Map<
+ number,
+ { count: number; timestampMs: number }
+ >()
+
+ for (const bucket of buckets) {
+ const count = status === 'failed' ? bucket.failed : bucket.total
+ if (count <= 0) continue
+
+ const timestampMs = getSeriesTimestamp(bucket.timestamp, grouping)
+ const bucketTimestampMs =
+ grouping === 'day'
+ ? timestampMs
+ : Math.floor(timestampMs / MINUTE_MS) * MINUTE_MS
+ const current = countByTimestamp.get(bucketTimestampMs)
+
+ countByTimestamp.set(bucketTimestampMs, {
+ count: (current?.count ?? 0) + count,
+ timestampMs: Math.max(current?.timestampMs ?? timestampMs, timestampMs),
+ })
+ }
+
+ if (grouping === 'day') {
+ const points = []
+ const start = getStartOfDay(rangeBounds.start)
+ const end = getStartOfDay(rangeBounds.end)
+
+ for (let timestampMs = start; timestampMs <= end; timestampMs += DAY_MS) {
+ const value = countByTimestamp.get(timestampMs)?.count ?? 0
+
+ points.push({
+ synthetic: value === 0,
+ timestamp: new Date(timestampMs).toISOString(),
+ value,
+ })
+ }
+
+ return points
+ }
+
+ const points: WebhookStatsChartPoint[] = [
+ {
+ synthetic: true,
+ timestamp: new Date(rangeBounds.start).toISOString(),
+ value: 0,
+ },
+ ]
+
+ for (const [, bucket] of Array.from(countByTimestamp).sort(
+ ([left], [right]) => left - right
+ )) {
+ const timestampMs = bucket.timestampMs
+
+ points.push(
+ {
+ synthetic: true,
+ timestamp: new Date(
+ Math.max(rangeBounds.start, timestampMs - 1)
+ ).toISOString(),
+ value: 0,
+ },
+ {
+ timestamp: new Date(timestampMs).toISOString(),
+ value: bucket.count,
+ },
+ {
+ synthetic: true,
+ timestamp: new Date(
+ Math.min(rangeBounds.end, timestampMs + 1)
+ ).toISOString(),
+ value: 0,
+ }
+ )
+ }
+
+ points.push({
+ synthetic: true,
+ timestamp: new Date(rangeBounds.end).toISOString(),
+ value: 0,
+ })
+
+ return points
+}
+
+// Builds a zero-value baseline for an empty range, e.g. [May 19 10am, May 19 2pm] -> 0 deliveries line.
+const getEmptyDeliveryCountSeriesData = (
+ rangeBounds: WebhookStatsRangeBounds,
+ grouping: WebhookStatsGrouping
+) => {
+ if (grouping === 'day') {
+ const points = []
+ const start = getStartOfDay(rangeBounds.start)
+ const end = getStartOfDay(rangeBounds.end)
+
+ for (let timestampMs = start; timestampMs <= end; timestampMs += DAY_MS) {
+ points.push({
+ synthetic: true,
+ timestamp: new Date(timestampMs).toISOString(),
+ value: 0,
+ })
+ }
+
+ return points
+ }
+
+ return [
+ {
+ synthetic: true,
+ timestamp: new Date(rangeBounds.start).toISOString(),
+ value: 0,
+ },
+ {
+ synthetic: true,
+ timestamp: new Date(rangeBounds.end).toISOString(),
+ value: 0,
+ },
+ ]
+}
+
+const hideInactiveZeroValuePoints = (points: WebhookStatsChartPoint[]) =>
+ points.map((point, index) => {
+ if (point.value !== 0) return point
+
+ const hasNearbyValue = [-2, -1, 1, 2].some(
+ (offset) => (points[index + offset]?.value ?? 0) > 0
+ )
+ if (hasNearbyValue) return point
+
+ return { ...point, synthetic: true, value: null }
+ })
+
+// Groups response-time buckets by chart granularity, e.g. minute buckets from one day -> one daily min/avg/max point.
+const getResponseTimeSeriesData = (
+ buckets: WebhookDeliveryStatsBucket[],
+ rangeBounds: WebhookStatsRangeBounds,
+ grouping: WebhookStatsGrouping,
+ metric: 'avg' | 'max' | 'min'
+) => {
+ const statsByTimestamp = new Map()
+
+ for (const bucket of buckets) {
+ if (bucket.total <= 0) continue
+
+ const timestampMs = getSeriesTimestamp(bucket.timestamp, grouping)
+ const bucketTimestampMs =
+ grouping === 'day'
+ ? timestampMs
+ : Math.floor(timestampMs / MINUTE_MS) * MINUTE_MS
+ const currentStats = statsByTimestamp.get(bucketTimestampMs)
+ const durationTotal = bucket.durationMs.average * bucket.total
+
+ statsByTimestamp.set(
+ bucketTimestampMs,
+ currentStats
+ ? {
+ count: currentStats.count + bucket.total,
+ maxDurationMs: Math.max(
+ currentStats.maxDurationMs,
+ bucket.durationMs.maximum
+ ),
+ minDurationMs: Math.min(
+ currentStats.minDurationMs,
+ bucket.durationMs.minimum
+ ),
+ timestampMs: Math.max(currentStats.timestampMs, timestampMs),
+ totalDurationMs: currentStats.totalDurationMs + durationTotal,
+ }
+ : {
+ count: bucket.total,
+ maxDurationMs: bucket.durationMs.maximum,
+ minDurationMs: bucket.durationMs.minimum,
+ timestampMs,
+ totalDurationMs: durationTotal,
+ }
+ )
+ }
+
+ const points: WebhookStatsChartPoint[] = [
+ {
+ synthetic: true,
+ timestamp: new Date(rangeBounds.start).toISOString(),
+ value: 0,
+ },
+ ]
+
+ points.push(
+ ...Array.from(statsByTimestamp)
+ .sort(([left], [right]) => left - right)
+ .map(([, stats]) => {
+ const value = stats
+ ? metric === 'avg'
+ ? stats.totalDurationMs / stats.count
+ : metric === 'max'
+ ? stats.maxDurationMs
+ : stats.minDurationMs
+ : null
+
+ return {
+ timestamp: new Date(stats.timestampMs).toISOString(),
+ value,
+ }
+ })
+ )
+
+ return points
+}
+
+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 rangeStartMs = rangeBounds.start
+ const rangeEndMs = rangeBounds.end
+ const grouping: WebhookStatsGrouping =
+ range === 'this-week' ? 'day' : 'timestamp'
+ const xAxisScale =
+ range === '4h'
+ ? 'four-hour'
+ : range === '12h'
+ ? 'twelve-hour'
+ : range === 'today'
+ ? 'today'
+ : 'daily'
+ const hasFailedDeliveries = buckets.some((bucket) => bucket.failed > 0)
+ const deliverySeries = [
+ {
+ name: 'Total deliveries',
+ colorVar: '--accent-info-highlight',
+ showSymbol: true,
+ z: 2,
+ data:
+ buckets.length > 0
+ ? getDeliveryCountSeriesData(buckets, rangeBounds, grouping)
+ : getEmptyDeliveryCountSeriesData(rangeBounds, grouping),
+ },
+ {
+ name: 'Failed deliveries',
+ colorVar: '--accent-error-highlight',
+ showSymbol: true,
+ z: hasFailedDeliveries ? 3 : 1,
+ data:
+ buckets.length > 0
+ ? hideInactiveZeroValuePoints(
+ getDeliveryCountSeriesData(
+ buckets,
+ rangeBounds,
+ grouping,
+ 'failed'
+ )
+ )
+ : [],
+ },
+ ] satisfies WebhookStatsChartSeries[]
+ const latencySeries = [
+ {
+ name: 'Min',
+ colorVar: '--accent-info-highlight',
+ connectNulls: true,
+ lineWidth: 2,
+ showSymbol: true,
+ z: 1,
+ data: getResponseTimeSeriesData(buckets, rangeBounds, grouping, 'min'),
+ },
+ {
+ name: 'Avg',
+ colorVar: '--accent-main-highlight',
+ connectNulls: true,
+ lineWidth: 2,
+ showSymbol: true,
+ z: 3,
+ data: getResponseTimeSeriesData(buckets, rangeBounds, grouping, 'avg'),
+ },
+ {
+ name: 'Max',
+ colorVar: '--accent-warning-highlight',
+ connectNulls: true,
+ lineWidth: 2,
+ showSymbol: true,
+ z: 2,
+ data: getResponseTimeSeriesData(buckets, rangeBounds, grouping, 'max'),
+ },
+ ] satisfies WebhookStatsChartSeries[]
+ 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/range-selector.tsx b/src/features/dashboard/settings/webhooks/detail/range-selector.tsx
new file mode 100644
index 000000000..681b9a8b4
--- /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..6f6475d32
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/stats-range.ts
@@ -0,0 +1,126 @@
+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 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(),
+})
+
+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/detail/webhook-stats-chart.tsx b/src/features/dashboard/settings/webhooks/detail/webhook-stats-chart.tsx
new file mode 100644
index 000000000..c1ab479cb
--- /dev/null
+++ b/src/features/dashboard/settings/webhooks/detail/webhook-stats-chart.tsx
@@ -0,0 +1,404 @@
+'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 { formatDisplayTimestamp } from '@/lib/utils/formatting'
+
+echarts.use([
+ LineChart,
+ ScatterChart,
+ GridComponent,
+ TooltipComponent,
+ AxisPointerComponent,
+ SVGRenderer,
+])
+
+type WebhookStatsChartPoint = {
+ synthetic?: boolean
+ timestamp: string
+ value: number | null
+}
+
+type WebhookStatsChartSeries = {
+ name: string
+ data: WebhookStatsChartPoint[]
+ 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 WebhookStatsChartProps = {
+ series: WebhookStatsChartSeries[]
+ chartType?: 'line' | 'scatter'
+ className?: string
+ valueFormatter?: (value: number) => string
+ yAxisValueFormatter?: (value: number) => string
+ xAxisScale?: 'daily' | 'four-hour' | 'twelve-hour' | 'today'
+ 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,
+ scale: NonNullable,
+ bounds: Pick
+) => {
+ const date = new Date(value)
+
+ if (scale === 'daily') {
+ 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 (scale === 'twelve-hour' && 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 = ({
+ scale,
+ xAxisMax,
+ xAxisMin,
+}: Pick & {
+ scale: NonNullable
+}) => {
+ if (scale === 'daily') return DAY_MS
+ if (scale === 'four-hour') return HOUR_MS
+ if (scale === 'twelve-hour') 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 formatTooltipTimestamp = (
+ timestampMs: number,
+ scale: NonNullable
+) => {
+ if (scale !== 'daily') return formatDisplayTimestamp(timestampMs)
+
+ const date = new Date(timestampMs)
+ 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 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 WebhookStatsChart = memo(function WebhookStatsChart({
+ series,
+ chartType = 'scatter',
+ className,
+ valueFormatter = defaultValueFormatter,
+ yAxisValueFormatter = valueFormatter,
+ xAxisScale = 'daily',
+ xAxisMax,
+ xAxisMin,
+}: WebhookStatsChartProps) {
+ 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({
+ scale: xAxisScale,
+ 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 ''
+
+ return `
+
${formatTooltipTimestamp(timestampMs, xAxisScale)}
+
${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, xAxisScale, { 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,
+ chartType,
+ cssVars,
+ stroke,
+ fgTertiary,
+ bg,
+ fontMono,
+ valueFormatter,
+ yAxisValueFormatter,
+ xAxisScale,
+ xAxisMax,
+ xAxisMin,
+ ])
+
+ useEffect(() => {
+ const frame = requestAnimationFrame(() => {
+ chartRef.current?.getEchartsInstance().resize()
+ })
+
+ return () => cancelAnimationFrame(frame)
+ })
+
+ return (
+
+
+
+ )
+})
+
+export {
+ WebhookStatsChart,
+ type WebhookStatsChartPoint,
+ type WebhookStatsChartSeries,
+}
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..fcadab409 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 { useRouter } from 'next/navigation'
+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'
@@ -85,7 +80,12 @@ const WebhookNameAndUrl = ({ name, url }: WebhookNameAndUrlProps) => {
-
{name}
+
+ {name}
+
@@ -102,62 +102,20 @@ 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'
-const getWebhookEventLabel = (event: string): string => {
- const matchedEvent = SandboxLifecycleEventTypeSchema.options.find(
- (webhookEvent) => webhookEvent === event
- )
- if (!matchedEvent) return event
- return WEBHOOK_EVENT_LABELS[matchedEvent]
-}
-
-type WebhookEventBadgesProps = {
- events: readonly string[]
-}
-
-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)}
- ))
-}
-
const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => {
const [dropDownOpen, setDropDownOpen] = useState(false)
return (
-
+ e.stopPropagation()}
+ >
@@ -187,6 +145,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 +155,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 +178,9 @@ export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {
-
+
-
+
{createdAt}
diff --git a/src/features/dashboard/settings/webhooks/table.tsx b/src/features/dashboard/settings/webhooks/table.tsx
index dd34cb992..1b8f393e0 100644
--- a/src/features/dashboard/settings/webhooks/table.tsx
+++ b/src/features/dashboard/settings/webhooks/table.tsx
@@ -20,7 +20,7 @@ interface WebhooksTableProps {
}
const headerCellClassName =
- 'h-[17px] p-0 pb-2 align-top font-sans! text-[12px] leading-[17px] text-left font-normal text-fg-tertiary uppercase'
+ 'h-[17px] p-0 pb-0.5 align-top font-sans! text-[12px] leading-[17px] text-left font-normal text-fg-tertiary uppercase'
export const WebhooksTable = ({
webhooks,
@@ -34,11 +34,16 @@ export const WebhooksTable = ({
: 'No webhooks match your search'
return (
-
+
-
+
diff --git a/src/features/dashboard/shared/event-type-badge.tsx b/src/features/dashboard/shared/event-type-badge.tsx
new file mode 100644
index 000000000..7f41f8b68
--- /dev/null
+++ b/src/features/dashboard/shared/event-type-badge.tsx
@@ -0,0 +1,24 @@
+import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types'
+import { Badge } from '@/ui/primitives/badge'
+import { SANDBOX_EVENT_TYPE_MAP } from './event-type-map'
+
+export const SandboxEventTypeBadge = ({ type }: { type: string }) => {
+ const parsed = SandboxLifecycleEventTypeSchema.safeParse(type)
+
+ if (!parsed.success) {
+ return (
+
+ {type}
+
+ )
+ }
+
+ const { icon: IconComponent, label } = SANDBOX_EVENT_TYPE_MAP[parsed.data]
+
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/features/dashboard/shared/event-type-filter-params.ts b/src/features/dashboard/shared/event-type-filter-params.ts
new file mode 100644
index 000000000..c57e4f0b8
--- /dev/null
+++ b/src/features/dashboard/shared/event-type-filter-params.ts
@@ -0,0 +1,24 @@
+import { createParser, parseAsArrayOf } from 'nuqs/server'
+import {
+ SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX,
+ type SandboxLifecycleEventType,
+ SandboxLifecycleEventTypeSchema,
+} from '@/core/modules/sandboxes/lifecycle-event-types'
+
+// Maps URL value to lifecycle event type, e.g. "created" -> "sandbox.lifecycle.created".
+const eventTypeParser = createParser({
+ parse: (value) => {
+ const result = SandboxLifecycleEventTypeSchema.safeParse(
+ `${SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX}${value}`
+ )
+ return result.success ? result.data : null
+ },
+ serialize: (value: SandboxLifecycleEventType) =>
+ value.slice(SANDBOX_LIFECYCLE_EVENT_TYPE_PREFIX.length),
+})
+
+const eventTypeFilterParams = {
+ types: parseAsArrayOf(eventTypeParser),
+}
+
+export { eventTypeFilterParams }
diff --git a/src/features/dashboard/shared/event-type-filter.tsx b/src/features/dashboard/shared/event-type-filter.tsx
new file mode 100644
index 000000000..6d19d4df4
--- /dev/null
+++ b/src/features/dashboard/shared/event-type-filter.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import {
+ type SandboxLifecycleEventType,
+ SandboxLifecycleEventTypeSchema,
+} from '@/core/modules/sandboxes/lifecycle-event-types'
+import { Button } from '@/ui/primitives/button'
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/ui/primitives/dropdown-menu'
+import { SandboxEventTypeBadge } from './event-type-badge'
+import { SANDBOX_EVENT_TYPE_MAP } from './event-type-map'
+
+const getTriggerLabel = (selected: SandboxLifecycleEventType[]) => {
+ if (selected.length === SandboxLifecycleEventTypeSchema.options.length)
+ return 'All'
+ if (selected.length === 0) return 'None'
+ const [first] = selected
+ if (selected.length === 1 && first) return SANDBOX_EVENT_TYPE_MAP[first].label
+ return `${selected.length}/${SandboxLifecycleEventTypeSchema.options.length}`
+}
+
+interface EventTypeFilterProps {
+ types: SandboxLifecycleEventType[]
+ onTypesChange: (types: SandboxLifecycleEventType[]) => void
+}
+
+export const EventTypeFilter = ({
+ types,
+ onTypesChange,
+}: EventTypeFilterProps) => {
+ const isAllSelected =
+ types.length === SandboxLifecycleEventTypeSchema.options.length
+
+ const toggleType = (type: SandboxLifecycleEventType) => {
+ const next = types.includes(type)
+ ? types.filter((t) => t !== type)
+ : [...types, type]
+ onTypesChange(next)
+ }
+
+ const toggleAll = (checked: boolean) => {
+ onTypesChange(checked ? [...SandboxLifecycleEventTypeSchema.options] : [])
+ }
+
+ return (
+
+
+
+
+
+ e.preventDefault()}
+ >
+ All events
+
+
+ {SandboxLifecycleEventTypeSchema.options.map((type) => (
+ toggleType(type)}
+ onSelect={(e) => e.preventDefault()}
+ >
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/features/dashboard/shared/event-type-map.ts b/src/features/dashboard/shared/event-type-map.ts
new file mode 100644
index 000000000..fb94cc123
--- /dev/null
+++ b/src/features/dashboard/shared/event-type-map.ts
@@ -0,0 +1,22 @@
+import type { SandboxLifecycleEventType } from '@/core/modules/sandboxes/lifecycle-event-types'
+import {
+ BlockIcon,
+ CheckIcon,
+ type Icon,
+ PausedIcon,
+ RefreshIcon,
+ RunningIcon,
+} from '@/ui/primitives/icons'
+
+const SANDBOX_EVENT_TYPE_MAP: Record<
+ SandboxLifecycleEventType,
+ { icon: Icon; label: string }
+> = {
+ 'sandbox.lifecycle.created': { icon: CheckIcon, label: 'Created' },
+ 'sandbox.lifecycle.updated': { icon: RefreshIcon, label: 'Updated' },
+ 'sandbox.lifecycle.paused': { icon: PausedIcon, label: 'Paused' },
+ 'sandbox.lifecycle.resumed': { icon: RunningIcon, label: 'Resumed' },
+ 'sandbox.lifecycle.killed': { icon: BlockIcon, label: 'Killed' },
+}
+
+export { SANDBOX_EVENT_TYPE_MAP }
diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts
index 9722c392c..d913954eb 100644
--- a/src/features/dashboard/shared/index.ts
+++ b/src/features/dashboard/shared/index.ts
@@ -1,2 +1,6 @@
+export { SandboxEventTypeBadge } from './event-type-badge'
+export { EventTypeFilter } from './event-type-filter'
+export { eventTypeFilterParams } from './event-type-filter-params'
export { IdBadge } from './id-badge'
+export { Timestamp } from './timestamp'
export { UserAvatar } from './user-avatar'
diff --git a/src/features/dashboard/shared/timestamp.tsx b/src/features/dashboard/shared/timestamp.tsx
new file mode 100644
index 000000000..aaca2bc1b
--- /dev/null
+++ b/src/features/dashboard/shared/timestamp.tsx
@@ -0,0 +1,9 @@
+import { formatDisplayTimestamp } from '@/lib/utils/formatting'
+
+type TimestampProps = {
+ value: string
+}
+
+export const Timestamp = ({ value }: TimestampProps) => {
+ return {formatDisplayTimestamp(value)}
+}
diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts
index d0c2ece7c..3f609e740 100644
--- a/src/lib/utils/formatting.ts
+++ b/src/lib/utils/formatting.ts
@@ -135,6 +135,29 @@ export function formatChartTimestampUTC(
return formatInTimeZone(date, 'UTC', 'h:mm:ss a')
}
+/** Formats a timestamp with a relative day label, e.g. "2026-05-19T14:35:10Z" -> "Today, 2:35:10 PM". */
+export const formatDisplayTimestamp = (value: string | number | Date) => {
+ const date = new Date(value)
+ const now = new Date()
+ const yesterday = new Date()
+ yesterday.setDate(now.getDate() - 1)
+
+ const isToday = date.toDateString() === now.toDateString()
+ const isYesterday = date.toDateString() === yesterday.toDateString()
+ const prefix = isToday
+ ? 'Today'
+ : isYesterday
+ ? 'Yesterday'
+ : date.toLocaleDateString()
+ const timeStr = date.toLocaleTimeString([], {
+ hour: 'numeric',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+
+ return `${prefix}, ${timeStr}`
+}
+
/** Formats elapsed time as a compact relative label; e.g. `new Date(Date.now() - 7200000)` -> `"2h ago"` */
export const formatRelativeAgo = (date: Date): string => {
const now = Date.now()
diff --git a/src/ui/copy-button.tsx b/src/ui/copy-button.tsx
index 825b7e346..8c8eec7df 100644
--- a/src/ui/copy-button.tsx
+++ b/src/ui/copy-button.tsx
@@ -1,10 +1,10 @@
'use client'
import { AnimatePresence, motion } from 'motion/react'
-import { FC } from 'react'
+import type { FC } from 'react'
import { useClipboard } from '@/lib/hooks/use-clipboard'
import { EASE_APPEAR } from '@/lib/utils/ui'
-import { IconButton, IconButtonProps } from '@/ui/primitives/icon-button'
+import { IconButton, type IconButtonProps } from '@/ui/primitives/icon-button'
import { CheckIcon, CopyIcon } from '@/ui/primitives/icons'
interface CopyButtonProps extends IconButtonProps {
diff --git a/src/ui/primitives/icons.tsx b/src/ui/primitives/icons.tsx
index 126e7797a..a24432e42 100644
--- a/src/ui/primitives/icons.tsx
+++ b/src/ui/primitives/icons.tsx
@@ -1048,8 +1048,8 @@ export const BugIcon = ({ className, ...props }: IconProps) => (
)
@@ -1065,14 +1065,14 @@ export const FeedbackIcon = ({ className, ...props }: IconProps) => (
)
@@ -1216,21 +1216,21 @@ export const UnlockIcon = ({ className, ...props }: IconProps) => (
)
@@ -1456,14 +1456,14 @@ export const ArrowRightIcon = ({ className, ...props }: IconProps) => (
)
@@ -1479,14 +1479,14 @@ export const ArrowLeftIcon = ({ className, ...props }: IconProps) => (
)