PlatformWebhooksEndpointDetails.tsx364 lines · main
| 1 | import { |
| 2 | flexRender, |
| 3 | getCoreRowModel, |
| 4 | getPaginationRowModel, |
| 5 | getSortedRowModel, |
| 6 | useReactTable, |
| 7 | type ColumnDef, |
| 8 | type PaginationState, |
| 9 | type SortingState, |
| 10 | } from '@tanstack/react-table' |
| 11 | import { ChevronLeft, ChevronRight, RotateCcw, Search } from 'lucide-react' |
| 12 | import { useEffect, useState, type ReactNode } from 'react' |
| 13 | import { |
| 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' |
| 26 | import { TimestampInfo } from 'ui-patterns' |
| 27 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 28 | import { TanStackTableHeadSort } from 'ui-patterns/Table' |
| 29 | |
| 30 | import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' |
| 31 | import { statusBadgeVariant } from './PlatformWebhooksView.utils' |
| 32 | import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' |
| 33 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 34 | import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' |
| 35 | |
| 36 | interface DetailItemProps { |
| 37 | label: string |
| 38 | children: ReactNode |
| 39 | ddClassName?: string |
| 40 | } |
| 41 | |
| 42 | const 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 | |
| 49 | interface 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 | |
| 58 | const DELIVERIES_PAGE_SIZE = 5 |
| 59 | const DELIVERY_ACTIONS_COLUMN_ID = 'actions' |
| 60 | const DEFAULT_DELIVERY_SORTING: SortingState = [{ id: 'attemptAt', desc: true }] |
| 61 | |
| 62 | const 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 | |
| 149 | export 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 | } |