Webhook deliveries UI improvement (#139)

* chore(api): improve webhook delivery history aggregation query

* ui(web): improve webhook delivery history page ui

* chore(web): clean up unnecessary comments
This commit is contained in:
Israel Abebe
2025-09-21 23:56:06 +03:00
committed by GitHub
7 changed files with 249 additions and 143 deletions

View File

@@ -84,27 +84,6 @@ export class WebhookService {
matchStage.createdAt = { $gte: new Date(start), $lte: new Date(end) }
}
if (status) {
switch (status) {
case 'delivered':
matchStage.deliveredAt = { $ne: null }
break
case 'failed':
matchStage.deliveryAttemptAbortedAt = { $ne: null }
break
case 'retrying':
matchStage.deliveredAt = null
matchStage.deliveryAttemptAbortedAt = null
matchStage.deliveryAttemptCount = { $gt: 0 }
matchStage.nextDeliveryAttemptAt = { $ne: null }
break
case 'pending':
matchStage.deliveredAt = null
matchStage.deliveryAttemptAbortedAt = null
matchStage.deliveryAttemptCount = 0
break
}
}
const pageNum = Math.max(1, Number.parseInt(page.toString()) || 1)
const limitNum = Math.max(1, Number.parseInt(limit.toString()) || 10)
@@ -144,8 +123,52 @@ export class WebhookService {
preserveNullAndEmptyArrays: true,
},
},
{
$addFields: {
computedStatus: {
$cond: {
if: { $ne: ['$deliveredAt', null] },
then: 'delivered',
else: {
$cond: {
if: {
$or: [
{ $ne: ['$deliveryAttemptAbortedAt', null] },
{ $gte: ['$deliveryAttemptCount', 10] }
]
},
then: 'failed',
else: {
$cond: {
if: {
$and: [
{ $eq: ['$deliveredAt', null] },
{ $eq: ['$deliveryAttemptAbortedAt', null] },
{ $gt: ['$deliveryAttemptCount', 0] },
{ $lt: ['$deliveryAttemptCount', 10] }
]
},
then: 'retrying',
else: 'pending'
}
}
}
}
}
}
}
},
]
// Apply status filter
if (status) {
commonPipeline.push({
$match: {
computedStatus: status
}
})
}
if (deviceId) {
commonPipeline.push({
$match: {

View File

@@ -53,9 +53,9 @@ const WebhooksHistory = () => {
.then((res) => res.data),
})
const [currentDevice, setCurrentDevice] = useState('')
const [eventType, setEventType] = useState('MESSAGE_RECEIVED')
const [status, setStatus] = useState('delivered')
const [currentDevice, setCurrentDevice] = useState('all')
const [eventType, setEventType] = useState('all')
const [status, setStatus] = useState('all')
const [dateRange, setDateRange] = useState<any>('90')
const [openCal, setOpenCal] = useState(false)
const [dateQuery, setDateQuery] = useState<{ start: string; end: string }>({
@@ -67,10 +67,9 @@ const WebhooksHistory = () => {
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (devices?.data?.length) {
setCurrentDevice(devices?.data?.[0]?._id)
if (devices?.data?.length && currentDevice === 'all') {
}
}, [devices])
}, [devices, currentDevice])
const {
data: webhookNotifications,
@@ -86,11 +85,11 @@ const WebhooksHistory = () => {
currentDevice,
status,
],
enabled: !!currentDevice,
enabled: true,
queryFn: () =>
httpBrowserClient
.get(
`${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType}&page=${page}&limit=${limit}&status=${status}&start=${
`${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType === 'all' ? '' : eventType}&page=${page}&limit=${limit}&status=${status === 'all' ? '' : status}&start=${
dateQuery.start
}&end=${dateQuery.end}&deviceId=${
currentDevice === 'all' ? '' : currentDevice
@@ -125,6 +124,15 @@ const WebhooksHistory = () => {
case '90':
start.setDate(end.getDate() - 90)
break
case '90_months':
start.setMonth(end.getMonth() - 3)
break
case '180':
start.setMonth(end.getMonth() - 6)
break
case '365':
start.setMonth(end.getMonth() - 12)
break
case 'custom':
setDateQuery({ start: '', end: '' })
setDateRange('custom')
@@ -156,6 +164,9 @@ const WebhooksHistory = () => {
<SelectValue placeholder="Select a device" />
</SelectTrigger>
<SelectContent>
<SelectItem key="all" value="all">
All devices
</SelectItem>
{devices?.data?.map((device) => (
<SelectItem key={device._id} value={device._id}>
<div className="flex items-center gap-2">
@@ -173,9 +184,6 @@ const WebhooksHistory = () => {
</div>
</SelectItem>
))}
<SelectItem key="all" value="all">
All devices
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -192,6 +200,12 @@ const WebhooksHistory = () => {
<SelectValue placeholder="Message type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500" />
All Events
</div>
</SelectItem>
<SelectItem value="MESSAGE_RECEIVED">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
@@ -218,6 +232,12 @@ const WebhooksHistory = () => {
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500" />
All
</div>
</SelectItem>
<SelectItem value="delivered">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
@@ -252,8 +272,6 @@ const WebhooksHistory = () => {
Date Range
</h3>{' '}
</div>
{/* date range ( today, last 3 days, last 7 days, last 30 days-default, last 90 days, custom)
Custom should trigger a popover to set a custom date range ( two date inputs) */}
<Select value={dateRange} onValueChange={handleDateRangeChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70">
<SelectValue placeholder="Date Range" />
@@ -272,96 +290,103 @@ Custom should trigger a popover to set a custom date range ( two date inputs) */
Last 90 Days
</div>
</SelectItem>
<SelectItem value="90_months">
<div className="flex items-center gap-1.5">
Last 3 Months
</div>
</SelectItem>
<SelectItem value="180">
<div className="flex items-center gap-1.5">
Last 6 Months
</div>
</SelectItem>
<SelectItem value="365">
<div className="flex items-center gap-1.5">
Last 12 Months
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex items-center gap-1.5">Custom</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-fit">
<div className="flex items-center gap-2 mb-1.5">
<div className="h-3.5 w-3.5 text-brand-500" />
<div className="text-sm font-medium text-foreground" />
</div>
{dateRange === 'custom' && (
<Popover>
<PopoverTrigger asChild>
<button
onClick={() => {
setOpenCal(true)
}}
className="flex items-center gap-2 mt-2 w-full border rounded-md px-2 py-1 text-sm"
>
<Calendar className="h-4 w-4" />
{dateQuery.start && dateQuery.end
? `${new Date(
dateQuery.start
).toLocaleDateString()}${new Date(
dateQuery.end
).toLocaleDateString()}`
: 'Select custom range'}
</button>
</PopoverTrigger>
{openCal && (
<PopoverContent className="p-4 w-64">
<div className="flex flex-col gap-3">
<div>
<label
className="block text-xs mb-1"
htmlFor="start-date"
>
Start Date
</label>
<input
id="start-date"
type="date"
className="w-full border rounded px-2 py-1 text-sm"
value={dateQuery.start}
onChange={(e) =>
setDateQuery({
...dateQuery,
start: e.target.value,
})
}
/>
</div>
<div>
<label
className="block text-xs mb-1"
htmlFor="end-date"
>
End Date
</label>
<input
id="end-date"
type="date"
className="w-full border rounded px-2 py-1 text-sm"
value={dateQuery.end}
onChange={(e) =>
setDateQuery({
...dateQuery,
end: e.target.value,
})
}
/>
</div>
<Button
size="sm"
onClick={() => {
setPage(1)
setOpenCal(false)
refetch() // react-query refetch
}}
>
Apply
</Button>
</div>
</PopoverContent>
)}
</Popover>
)}
</div>
</div>
<Popover open={openCal} onOpenChange={setOpenCal}>
<PopoverContent className="p-4 w-64">
<div className="flex flex-col gap-3">
<div>
<label
className="block text-xs mb-1"
htmlFor="start-date"
>
Start Date
</label>
<input
id="start-date"
type="date"
className="w-full border rounded px-2 py-1 text-sm"
value={dateQuery.start ? dateQuery.start.split('T')[0] : ''}
onChange={(e) =>
setDateQuery({
...dateQuery,
start: e.target.value ? new Date(e.target.value).toISOString() : '',
})
}
/>
</div>
<div>
<label
className="block text-xs mb-1"
htmlFor="end-date"
>
End Date
</label>
<input
id="end-date"
type="date"
className="w-full border rounded px-2 py-1 text-sm"
value={dateQuery.end ? dateQuery.end.split('T')[0] : ''}
onChange={(e) =>
setDateQuery({
...dateQuery,
end: e.target.value ? new Date(e.target.value).toISOString() : '',
})
}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setOpenCal(false)
setDateRange('90')
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - 90)
setDateQuery({ start: start.toISOString(), end: end.toISOString() })
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setPage(1)
setOpenCal(false)
refetch()
}}
disabled={!dateQuery.start || !dateQuery.end}
>
Apply
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{isLoadingNotifications || isLoading ? (
<ProductClient data={[]} isLoading={true} />
) : (
@@ -383,7 +408,7 @@ Custom should trigger a popover to set a custom date range ( two date inputs) */
</Button>
<div className="flex flex-wrap items-center gap-2 justify-center sm:justify-start">
{/* First page */}
{totalPages > 1 && (
<Button
onClick={() => handlePageChange(1)}
@@ -404,7 +429,7 @@ Custom should trigger a popover to set a custom date range ( two date inputs) */
<span className="px-1">...</span>
)}
{/* Middle pages */}
{Array.from(
{
length: Math.min(6, totalPages - 2),

View File

@@ -130,7 +130,7 @@ export default function WebhooksSection() {
className='w-full sm:w-auto'
>
<Bell className='mr-2 h-4 w-4' />
Webhook Notification
Notification Deliveries
</Button>
</div>
</div>

View File

@@ -20,7 +20,7 @@ import {
} from '@/components/ui/table'
import React from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { SmsModal } from './sms-modal' // Import your modal
import { WebhookPayloadModal } from './webhook-payload-modal' // Import your modal
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
@@ -39,6 +39,7 @@ export function DataTable<TData, TValue>({
[]
)
const [selectedSms, setSelectedSms] = React.useState<any>(null)
const [selectedPayload, setSelectedPayload] = React.useState<any>(null)
const [isModalOpen, setIsModalOpen] = React.useState(false)
const table = useReactTable({
@@ -57,6 +58,7 @@ export function DataTable<TData, TValue>({
// Assuming your row data has smsData property
if (row.original.smsData) {
setSelectedSms(row.original.smsData)
setSelectedPayload(row.original.payload)
setIsModalOpen(true)
}
}
@@ -64,6 +66,7 @@ export function DataTable<TData, TValue>({
const handleCloseModal = () => {
setIsModalOpen(false)
setSelectedSms(null)
setSelectedPayload(null)
}
return (
@@ -133,11 +136,12 @@ export function DataTable<TData, TValue>({
</Table>
</div>
{/* SMS Modal */}
<SmsModal
{/* Webhook Payload Modal */}
<WebhookPayloadModal
isOpen={isModalOpen}
onClose={handleCloseModal}
smsData={selectedSms}
payload={selectedPayload}
/>
</div>
)

View File

@@ -1,4 +1,3 @@
// components/sms-modal.tsx
'use client'
import {
@@ -9,13 +8,9 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
X,
Calendar,
MessageSquare,
Phone,
CreditCard,
Bell,
Copy,
} from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
interface SmsData {
_id: string
@@ -31,13 +26,16 @@ interface SmsData {
__v: number
}
interface SmsModalProps {
interface WebhookPayloadModalProps {
isOpen: boolean
onClose: () => void
smsData: SmsData | null
payload?: any
}
export function SmsModal({ isOpen, onClose, smsData }: SmsModalProps) {
export function WebhookPayloadModal({ isOpen, onClose, smsData, payload }: WebhookPayloadModalProps) {
const { toast } = useToast()
if (!smsData) return null
const formatDate = (dateString: string) => {
@@ -51,23 +49,64 @@ export function SmsModal({ isOpen, onClose, smsData }: SmsModalProps) {
})
}
const copyPayloadToClipboard = async () => {
if (!payload) return
try {
const jsonString = JSON.stringify(payload, null, 2)
await navigator.clipboard.writeText(jsonString)
toast({
title: "Copied!",
description: "Payload copied to clipboard",
duration: 2000,
})
} catch (err) {
console.error('Failed to copy payload:', err)
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = JSON.stringify(payload, null, 2)
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
toast({
title: "Copied!",
description: "Payload copied to clipboard",
duration: 2000,
})
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="flex items-center gap-2">
Webhook Notification
</DialogTitle>
</DialogHeader>
<h3 className="font-semibold text-sm flex items-center gap-2">
Message Content
</h3>
<div className="space-y-4">
{/* Message Content */}
<div className="bg-muted/50 rounded-lg p-4 max-h-32 overflow-y-scroll scrollbar-hide">
<p className="text-sm whitespace-pre-wrap">{smsData.message}</p>
</div>
<div className="space-y-6">
{payload && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Payload</h3>
<Button
variant="outline"
size="sm"
onClick={copyPayloadToClipboard}
className="h-8"
>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-md border p-4 overflow-auto max-h-64">
<pre className="text-xs font-mono whitespace-pre-wrap text-gray-800 dark:text-gray-200">
{JSON.stringify(payload, null, 2)}
</pre>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Sender Information */}
@@ -95,6 +134,7 @@ export function SmsModal({ isOpen, onClose, smsData }: SmsModalProps) {
{smsData.status}
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-sm flex items-center gap-x-2">
Created At

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { DataTable } from './data-table'
import type { ColumnDef } from '@tanstack/react-table'
import { format } from 'date-fns'
import { Eye } from 'lucide-react'
interface ProductClientProps {
data: ProductColumns[]
@@ -20,8 +21,11 @@ export type ProductColumns = {
}
createdAt?: string
status: string
computedStatus?: string
payload?: any
}
export const columns: ColumnDef<ProductColumns>[] = [
{
accessorKey: 'event',
@@ -59,6 +63,15 @@ export const columns: ColumnDef<ProductColumns>[] = [
accessorKey: 'createdAt',
header: 'Created At',
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<Button variant="ghost" size="sm" disabled={!row.original.payload}>
<Eye className="h-4 w-4" />
</Button>
),
},
]
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'MMM dd, yyyy h:mm a')
@@ -74,7 +87,8 @@ const ProductClient = ({ data, isLoading, status = 'delivered' }) => {
` ${d.smsData._id}`,
],
createdAt: formatDate(d.createdAt.toString()),
status,
status: d.computedStatus || status,
payload: d.payload,
}))
return (

View File

@@ -10,7 +10,7 @@ export default function MessagingPage() {
<div className="flex items-center space-x-2">
<MessageSquareTextIcon className="h-6 w-6 text-primary" />
<h2 className="text-3xl font-bold tracking-tight">
Webhook Notifications
Webhook Notification Delivery History
</h2>
</div>
</div>