PlatformWebhooksEndpointDetails.tsx 13 KB

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