PlatformWebhooksEndpointDetails.tsx364 lines · main
1import {
2 flexRender,
3 getCoreRowModel,
4 getPaginationRowModel,
5 getSortedRowModel,
6 useReactTable,
7 type ColumnDef,
8 type PaginationState,
9 type SortingState,
10} from '@tanstack/react-table'
11import { ChevronLeft, ChevronRight, RotateCcw, Search } from 'lucide-react'
12import { useEffect, useState, type ReactNode } from 'react'
13import {
14 Badge,
15 Button,
16 Card,
17 CardContent,
18 CardFooter,
19 Table,
20 TableBody,
21 TableCell,
22 TableHead,
23 TableHeader,
24 TableRow,
25} from 'ui'
26import { TimestampInfo } from 'ui-patterns'
27import { Input } from 'ui-patterns/DataInputs/Input'
28import { TanStackTableHeadSort } from 'ui-patterns/Table'
29
30import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types'
31import { statusBadgeVariant } from './PlatformWebhooksView.utils'
32import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils'
33import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
34import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
35
36interface DetailItemProps {
37 label: string
38 children: ReactNode
39 ddClassName?: string
40}
41
42const DetailItem = ({ label, children, ddClassName = 'text-sm' }: DetailItemProps) => (
43 <div className="space-y-1">
44 <dt className="text-sm text-foreground-lighter">{label}</dt>
45 <dd className={ddClassName}>{children}</dd>
46 </div>
47)
48
49interface PlatformWebhooksEndpointDetailsProps {
50 deliverySearch: string
51 filteredDeliveries: WebhookDelivery[]
52 selectedEndpoint: WebhookEndpoint
53 onDeliverySearchChange: (value: string) => void
54 onOpenDelivery: (deliveryId: string) => void
55 onRetryDelivery: (deliveryId: string) => void
56}
57
58const DELIVERIES_PAGE_SIZE = 5
59const DELIVERY_ACTIONS_COLUMN_ID = 'actions'
60const DEFAULT_DELIVERY_SORTING: SortingState = [{ id: 'attemptAt', desc: true }]
61
62const DELIVERY_COLUMNS: ColumnDef<WebhookDelivery>[] = [
63 {
64 accessorKey: 'status',
65 header: ({ column }) => <TanStackTableHeadSort column={column}>Status</TanStackTableHeadSort>,
66 cell: ({ row }) => (
67 <Badge variant={statusBadgeVariant[row.original.status]}>{row.original.status}</Badge>
68 ),
69 },
70 {
71 accessorKey: 'eventType',
72 header: ({ column }) => (
73 <TanStackTableHeadSort column={column}>Event type</TanStackTableHeadSort>
74 ),
75 cell: ({ row }) => <code className="text-code-inline">{row.original.eventType}</code>,
76 },
77 {
78 accessorKey: 'responseCode',
79 header: ({ column }) => <TanStackTableHeadSort column={column}>Response</TanStackTableHeadSort>,
80 sortingFn: (rowA, rowB, columnId) => {
81 const responseA = rowA.getValue<number | undefined>(columnId) ?? -1
82 const responseB = rowB.getValue<number | undefined>(columnId) ?? -1
83 return responseA - responseB
84 },
85 cell: ({ row }) =>
86 row.original.responseCode != null ? (
87 <DataTableColumnStatusCode
88 value={row.original.responseCode}
89 level={getStatusLevel(row.original.responseCode)}
90 className="text-xs"
91 />
92 ) : (
93 <span className="text-xs text-foreground-muted">–</span>
94 ),
95 },
96 {
97 accessorKey: 'attemptAt',
98 header: ({ column }) => (
99 <TanStackTableHeadSort column={column}>Attempted</TanStackTableHeadSort>
100 ),
101 cell: ({ row }) => (
102 <TimestampInfo
103 className="text-sm text-foreground-lighter"
104 utcTimestamp={row.original.attemptAt}
105 />
106 ),
107 },
108 {
109 id: DELIVERY_ACTIONS_COLUMN_ID,
110 enableSorting: false,
111 header: () => <span className="sr-only">Actions</span>,
112 cell: ({ row, table }) => {
113 const { onRetryDelivery } = table.options.meta as {
114 onRetryDelivery: (deliveryId: string) => void
115 }
116
117 return (
118 <div className="flex h-full items-center justify-end">
119 {row.original.status !== 'success' ? (
120 <ButtonTooltip
121 type="default"
122 size="tiny"
123 className="w-7 shrink-0 hit-area-2"
124 icon={<RotateCcw />}
125 aria-label={`Retry ${row.original.id}`}
126 tooltip={{ content: { side: 'top', text: 'Retry' } }}
127 onClick={(event) => {
128 event.stopPropagation()
129 onRetryDelivery(row.original.id)
130 }}
131 onKeyDown={(event) => event.stopPropagation()}
132 />
133 ) : (
134 <Button
135 type="default"
136 size="tiny"
137 className="w-7 shrink-0 hit-area-2 invisible pointer-events-none"
138 icon={<RotateCcw />}
139 aria-hidden
140 tabIndex={-1}
141 />
142 )}
143 </div>
144 )
145 },
146 },
147]
148
149export const PlatformWebhooksEndpointDetails = ({
150 deliverySearch,
151 filteredDeliveries,
152 selectedEndpoint,
153 onDeliverySearchChange,
154 onOpenDelivery,
155 onRetryDelivery,
156}: PlatformWebhooksEndpointDetailsProps) => {
157 const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0
158 const hasName = selectedEndpoint.name.trim().length > 0
159 const hasDescription = selectedEndpoint.description.trim().length > 0
160 const [sorting, setSorting] = useState<SortingState>(DEFAULT_DELIVERY_SORTING)
161 const [pagination, setPagination] = useState<PaginationState>({
162 pageIndex: 0,
163 pageSize: DELIVERIES_PAGE_SIZE,
164 })
165
166 const table = useReactTable({
167 data: filteredDeliveries,
168 columns: DELIVERY_COLUMNS,
169 state: { pagination, sorting },
170 meta: { onRetryDelivery },
171 getRowId: (row) => row.id,
172 onPaginationChange: setPagination,
173 onSortingChange: setSorting,
174 getCoreRowModel: getCoreRowModel(),
175 getPaginationRowModel: getPaginationRowModel(),
176 getSortedRowModel: getSortedRowModel(),
177 })
178
179 const paginatedDeliveries = table.getRowModel().rows
180 const deliveryStartIndex =
181 table.getState().pagination.pageIndex * table.getState().pagination.pageSize
182 const deliveryRangeStart = filteredDeliveries.length === 0 ? 0 : deliveryStartIndex + 1
183 const deliveryRangeEnd = Math.min(
184 deliveryStartIndex + table.getState().pagination.pageSize,
185 filteredDeliveries.length
186 )
187
188 useEffect(() => {
189 setPagination((currentPagination) => ({ ...currentPagination, pageIndex: 0 }))
190 }, [deliverySearch, selectedEndpoint.id])
191
192 return (
193 <div className="space-y-16">
194 <div className="space-y-4">
195 <h2 className="text-foreground text-xl">Overview</h2>
196 <Card className="overflow-hidden">
197 <CardContent className="pb-5">
198 <dl className="grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-2">
199 {hasName && <DetailItem label="Name">{selectedEndpoint.name}</DetailItem>}
200
201 <DetailItem label="URL" ddClassName="text-sm break-all">
202 {selectedEndpoint.url}
203 </DetailItem>
204
205 {hasDescription && (
206 <DetailItem label="Description">{selectedEndpoint.description}</DetailItem>
207 )}
208
209 <DetailItem label="Event types" ddClassName="flex flex-wrap gap-2">
210 {(selectedEndpoint.eventTypes.includes('*')
211 ? ['All events (*)']
212 : selectedEndpoint.eventTypes
213 ).map((eventType) => (
214 <code
215 key={eventType}
216 className="text-code-inline rounded-md border px-3 py-1.5 text-2xs"
217 >
218 {eventType}
219 </code>
220 ))}
221 </DetailItem>
222
223 {hasCustomHeaders && (
224 <DetailItem label="Custom headers">
225 <div className="rounded-md border divide-y divide-border">
226 {selectedEndpoint.customHeaders.map((header) => (
227 <div
228 key={header.id}
229 className="px-2 py-2 font-mono font-medium text-xs flex items-center gap-2 flex-wrap"
230 >
231 <code className="text-code_block-4">{header.key}:</code>
232 <code>{header.value}</code>
233 </div>
234 ))}
235 </div>
236 </DetailItem>
237 )}
238
239 <DetailItem label="Created by">{selectedEndpoint.createdBy}</DetailItem>
240
241 <DetailItem label="Created at">
242 <TimestampInfo className="text-sm" utcTimestamp={selectedEndpoint.createdAt} />
243 </DetailItem>
244 </dl>
245 </CardContent>
246 </Card>
247 </div>
248
249 <div className="space-y-4">
250 <h2 className="text-foreground text-xl">Deliveries</h2>
251 <div className="flex items-center justify-between gap-2">
252 <Input
253 placeholder="Search deliveries"
254 size="tiny"
255 icon={<Search />}
256 value={deliverySearch}
257 className="w-full lg:w-52"
258 onChange={(event) => onDeliverySearchChange(event.target.value)}
259 />
260 </div>
261 <Card className="overflow-hidden">
262 <Table>
263 <TableHeader>
264 {table.getHeaderGroups().map((headerGroup) => (
265 <TableRow key={headerGroup.id}>
266 {headerGroup.headers.map((header) => {
267 const columnId = header.column.id
268 const sort = header.column.getIsSorted()
269
270 return (
271 <TableHead
272 key={header.id}
273 aria-sort={
274 header.column.getCanSort()
275 ? sort === 'asc'
276 ? 'ascending'
277 : sort === 'desc'
278 ? 'descending'
279 : 'none'
280 : undefined
281 }
282 className={columnId === DELIVERY_ACTIONS_COLUMN_ID ? 'w-1' : ''}
283 >
284 {header.isPlaceholder
285 ? null
286 : flexRender(header.column.columnDef.header, header.getContext())}
287 </TableHead>
288 )
289 })}
290 </TableRow>
291 ))}
292 </TableHeader>
293 <TableBody>
294 {paginatedDeliveries.length > 0 ? (
295 paginatedDeliveries.map((row) => (
296 <TableRow
297 key={row.id}
298 className="cursor-pointer inset-focus"
299 onClick={() => onOpenDelivery(row.original.id)}
300 onKeyDown={(event) => {
301 if (event.key === 'Enter' || event.key === ' ') {
302 event.preventDefault()
303 onOpenDelivery(row.original.id)
304 }
305 }}
306 tabIndex={0}
307 >
308 {row.getVisibleCells().map((cell) => (
309 <TableCell
310 key={cell.id}
311 className={
312 cell.column.id === DELIVERY_ACTIONS_COLUMN_ID ? 'w-1 text-right' : ''
313 }
314 >
315 {flexRender(cell.column.columnDef.cell, cell.getContext())}
316 </TableCell>
317 ))}
318 </TableRow>
319 ))
320 ) : (
321 <TableRow className="[&>td]:hover:bg-inherit">
322 <TableCell colSpan={DELIVERY_COLUMNS.length}>
323 <p className="text-sm text-foreground">No deliveries found</p>
324 <p className="text-sm text-foreground-lighter">
325 Try adjusting your search to see more webhook attempts.
326 </p>
327 </TableCell>
328 </TableRow>
329 )}
330 </TableBody>
331 </Table>
332 {filteredDeliveries.length > 0 && (
333 <CardFooter className="border-t p-4 flex items-center justify-between">
334 <p className="text-foreground-muted text-sm">
335 Showing {deliveryRangeStart} to {deliveryRangeEnd} of {filteredDeliveries.length}{' '}
336 deliveries
337 </p>
338 <div className="flex items-center gap-x-2" aria-label="Pagination">
339 <Button
340 icon={<ChevronLeft />}
341 className="w-7 hit-area-2"
342 aria-label="Previous page"
343 type="default"
344 size="tiny"
345 disabled={!table.getCanPreviousPage()}
346 onClick={() => table.previousPage()}
347 />
348 <Button
349 icon={<ChevronRight />}
350 className="w-7 hit-area-2"
351 aria-label="Next page"
352 type="default"
353 size="tiny"
354 disabled={!table.getCanNextPage()}
355 onClick={() => table.nextPage()}
356 />
357 </div>
358 </CardFooter>
359 )}
360 </Card>
361 </div>
362 </div>
363 )
364}