diff --git a/client/src/App.tsx b/client/src/App.tsx index 15ca99d..bb13f0a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,6 +15,7 @@ import ProvidersPage from "@/pages/providers"; import SettingsPage from "@/pages/settings"; import OrganizationsPage from "@/pages/organizations"; import WebhooksPage from "@/pages/webhooks"; +import WebhookLogsPage from "@/pages/webhook-logs"; import { AuthProvider } from "@/hooks/use-auth"; import { ProtectedRoute } from "@/lib/protected-route"; import { ThemeProvider } from "@/hooks/use-theme"; @@ -34,6 +35,7 @@ function Router() { + diff --git a/client/src/pages/users-roles.tsx b/client/src/pages/users-roles.tsx index c35fd4b..6bdd0b5 100644 --- a/client/src/pages/users-roles.tsx +++ b/client/src/pages/users-roles.tsx @@ -314,7 +314,7 @@ export default function UsersRolesPage() { Role Permissions - Understanding the different access levels in DynamiDNS + Understanding the different access levels in DynamoDNS diff --git a/client/src/pages/webhook-logs.tsx b/client/src/pages/webhook-logs.tsx new file mode 100644 index 0000000..80d8d5c --- /dev/null +++ b/client/src/pages/webhook-logs.tsx @@ -0,0 +1,470 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { MainLayout } from "@/components/layouts/main-layout"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Pagination } from "@/components/shared/pagination"; +import { Loader2, RefreshCw, CheckCircle, XCircle, Info, Search, Code, Clock, Webhook, Calendar } from "lucide-react"; +import { formatDistanceToNow, format } from "date-fns"; +import { queryClient, apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; + +type WebhookDeliveryLog = { + id: string; + webhookId: string; + webhookUrl: string; + payload: any; + status: boolean; + statusCode: number | null; + error: string | null; + responseBody: string | null; + retryCount: number; + createdAt: string; +}; + +type Webhook = { + id: string; + name: string; + url: string; + organizationId: string; + events: string[]; + active: boolean; + secretKey?: string; +}; + +export default function WebhookLogsPage() { + const { toast } = useToast(); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [selectedLog, setSelectedLog] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | "success" | "failed">("all"); + const [sortOrder, setSortOrder] = useState<"newest" | "oldest">("newest"); + + // Fetch all webhook logs across all webhooks + const { data: allLogs = [], isLoading } = useQuery({ + queryKey: ["/api/webhook-logs"], + select: (data: WebhookDeliveryLog[]) => { + // Apply filters + let filtered = data; + + // Status filter + if (statusFilter === "success") { + filtered = filtered.filter(log => log.status); + } else if (statusFilter === "failed") { + filtered = filtered.filter(log => !log.status); + } + + // Search filter (search in webhook URL or response) + if (searchTerm) { + const term = searchTerm.toLowerCase(); + filtered = filtered.filter(log => + log.webhookUrl.toLowerCase().includes(term) || + (log.responseBody && log.responseBody.toLowerCase().includes(term)) || + (log.error && log.error.toLowerCase().includes(term)) + ); + } + + // Apply sorting + return [...filtered].sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return sortOrder === "newest" ? dateB - dateA : dateA - dateB; + }); + } + }); + + // Fetch webhooks (for name display) + const { data: webhooks = [] } = useQuery({ + queryKey: ["/api/webhooks"], + }); + + // Retry mutation + const retryMutation = useMutation({ + mutationFn: async (logId: string) => { + const res = await apiRequest("POST", `/api/webhook-logs/${logId}/retry`); + return res.json(); + }, + onSuccess: () => { + toast({ + title: "Webhook retry initiated", + description: "The webhook delivery retry has been initiated.", + }); + queryClient.invalidateQueries({ queryKey: ["/api/webhook-logs"] }); + setDetailsOpen(false); + }, + onError: (error: Error) => { + toast({ + title: "Retry failed", + description: error.message, + variant: "destructive", + }); + }, + }); + + const handleRetry = (log: WebhookDeliveryLog) => { + retryMutation.mutate(log.id); + }; + + const viewDetails = (log: WebhookDeliveryLog) => { + setSelectedLog(log); + setDetailsOpen(true); + }; + + // Calculate pagination + const totalPages = Math.ceil(allLogs.length / pageSize); + const startIndex = (currentPage - 1) * pageSize; + const paginatedLogs = allLogs.slice(startIndex, startIndex + pageSize); + + // Get webhook name by ID + const getWebhookName = (webhookId: string) => { + const webhook = webhooks.find(w => w.id === webhookId); + return webhook ? webhook.name : `Webhook ${webhookId}`; + }; + + return ( + + {/* Filters and controls */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ + + +
+
+ +
+
+ + {/* Logs table */} + + + Webhook Delivery Logs + + Comprehensive view of all webhook delivery attempts across the system + + + + {isLoading ? ( +
+ +
+ ) : allLogs.length === 0 ? ( +
+ +

No webhook logs found

+

+ {searchTerm || statusFilter !== "all" + ? "Try adjusting your search or filters" + : "Webhook logs will appear here after webhooks are triggered"} +

+
+ ) : ( + <> +
+ + + + Status + Webhook + URL + Response + Retries + Time + Actions + + + + {paginatedLogs.map((log) => ( + viewDetails(log)}> + + {log.status ? ( + + + Success + + ) : ( + + + Failed + + )} + + + {getWebhookName(log.webhookId)} + + + {log.webhookUrl} + + + {log.status ? ( + + {log.statusCode || 200} OK + + ) : ( + + {log.error || `Error ${log.statusCode || 'unknown'}`} + + )} + + + {log.retryCount > 0 ? ( + + {log.retryCount} + + ) : ( + None + )} + + +
+ + + {formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })} + +
+
+ + + +
+ ))} +
+
+
+ + {/* Pagination */} + {allLogs.length > pageSize && ( +
+ +
+ )} + + )} +
+
+ + {/* Details Dialog */} + {selectedLog && ( + + + + + {selectedLog.status ? ( +
+ + Successful Webhook Delivery +
+ ) : ( +
+ + Failed Webhook Delivery +
+ )} +
+ + Detailed information about this webhook delivery attempt + +
+ +
+
+
+

Webhook

+

{getWebhookName(selectedLog.webhookId)}

+
+
+

Time

+

{format(new Date(selectedLog.createdAt), 'PPpp')}

+
+
+

Status

+

+ {selectedLog.status + ? `Success (${selectedLog.statusCode || 200})` + : `Failed${selectedLog.statusCode ? ` (${selectedLog.statusCode})` : ''}`} +

+
+
+

Retry Count

+

{selectedLog.retryCount}

+
+
+ +
+

Webhook URL

+
+ {selectedLog.webhookUrl} +
+
+ + {!selectedLog.status && selectedLog.error && ( +
+

Error

+
+ {selectedLog.error} +
+
+ )} + +
+

Payload

+
+                  {JSON.stringify(selectedLog.payload, null, 2)}
+                
+
+ + {selectedLog.responseBody && ( +
+

Response Body

+
+                    {typeof selectedLog.responseBody === 'string' 
+                      ? (() => {
+                          try {
+                            return JSON.stringify(JSON.parse(selectedLog.responseBody), null, 2);
+                          } catch {
+                            return selectedLog.responseBody;
+                          }
+                        })() 
+                      : JSON.stringify(selectedLog.responseBody, null, 2)}
+                  
+
+ )} +
+ + + {!selectedLog.status && ( + + )} + + +
+
+ )} +
+ ); +} \ No newline at end of file