Add webhook delivery log page

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9111ef36-26c8-4085-84ca-a35dc1fec1b5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7083d608-d6d3-4a6a-9a27-6286c5109627/46ce7574-6e2a-407f-94e2-683704669d2c.jpg
This commit is contained in:
alphaeusmote
2025-04-10 02:12:50 +00:00
3 changed files with 473 additions and 1 deletions
+2
View File
@@ -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() {
<ProtectedRoute path="/users-roles" component={UsersRolesPage} />
<ProtectedRoute path="/providers" component={ProvidersPage} />
<ProtectedRoute path="/webhooks" component={WebhooksPage} />
<ProtectedRoute path="/webhook-logs" component={WebhookLogsPage} />
<ProtectedRoute path="/settings" component={SettingsPage} />
<Route component={NotFound} />
</Switch>
+1 -1
View File
@@ -314,7 +314,7 @@ export default function UsersRolesPage() {
<CardHeader>
<CardTitle>Role Permissions</CardTitle>
<CardDescription>
Understanding the different access levels in DynamiDNS
Understanding the different access levels in DynamoDNS
</CardDescription>
</CardHeader>
<CardContent>
+470
View File
@@ -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<WebhookDeliveryLog | null>(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<Webhook[]>({
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 (
<MainLayout
title="Webhook Logs Dashboard"
description="View and manage webhook delivery logs across all organizations."
>
{/* Filters and controls */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search webhook URLs or responses..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as "all" | "success" | "failed")}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="success">Successful</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
</SelectContent>
</Select>
<Select
value={sortOrder}
onValueChange={(value) => setSortOrder(value as "newest" | "oldest")}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest first</SelectItem>
<SelectItem value="oldest">Oldest first</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => queryClient.invalidateQueries({ queryKey: ["/api/webhook-logs"] })}
title="Refresh logs"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center space-x-2">
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(parseInt(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="Page size" />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 per page</SelectItem>
<SelectItem value="25">25 per page</SelectItem>
<SelectItem value="50">50 per page</SelectItem>
<SelectItem value="100">100 per page</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Logs table */}
<Card>
<CardHeader className="pb-3">
<CardTitle>Webhook Delivery Logs</CardTitle>
<CardDescription>
Comprehensive view of all webhook delivery attempts across the system
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : allLogs.length === 0 ? (
<div className="text-center py-8">
<Webhook className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium">No webhook logs found</h3>
<p className="mt-2 text-sm text-muted-foreground">
{searchTerm || statusFilter !== "all"
? "Try adjusting your search or filters"
: "Webhook logs will appear here after webhooks are triggered"}
</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Webhook</TableHead>
<TableHead>URL</TableHead>
<TableHead>Response</TableHead>
<TableHead>Retries</TableHead>
<TableHead>Time</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedLogs.map((log) => (
<TableRow key={log.id} className="cursor-pointer hover:bg-muted/50" onClick={() => viewDetails(log)}>
<TableCell>
{log.status ? (
<Badge variant="outline" className="bg-success/10 text-success border-success/20">
<CheckCircle className="w-3.5 h-3.5 mr-1" />
Success
</Badge>
) : (
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20">
<XCircle className="w-3.5 h-3.5 mr-1" />
Failed
</Badge>
)}
</TableCell>
<TableCell className="font-medium">
{getWebhookName(log.webhookId)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{log.webhookUrl}
</TableCell>
<TableCell>
{log.status ? (
<span className="text-sm text-muted-foreground">
{log.statusCode || 200} OK
</span>
) : (
<span className="text-sm text-destructive">
{log.error || `Error ${log.statusCode || 'unknown'}`}
</span>
)}
</TableCell>
<TableCell>
{log.retryCount > 0 ? (
<Badge variant="outline" className="text-muted-foreground">
<RefreshCw className="w-3 h-3 mr-1" /> {log.retryCount}
</Badge>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="w-3.5 h-3.5 mr-1.5" />
<span title={format(new Date(log.createdAt), 'PPpp')}>
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
viewDetails(log);
}}
>
View Details
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{allLogs.length > pageSize && (
<div className="mt-4 p-4 border-t">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemLabel="logs"
totalItems={allLogs.length}
itemsPerPage={pageSize}
/>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Details Dialog */}
{selectedLog && (
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>
{selectedLog.status ? (
<div className="flex items-center">
<CheckCircle className="w-5 h-5 mr-2 text-success" />
Successful Webhook Delivery
</div>
) : (
<div className="flex items-center">
<XCircle className="w-5 h-5 mr-2 text-destructive" />
Failed Webhook Delivery
</div>
)}
</DialogTitle>
<DialogDescription>
Detailed information about this webhook delivery attempt
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium mb-1">Webhook</h3>
<p className="text-sm">{getWebhookName(selectedLog.webhookId)}</p>
</div>
<div>
<h3 className="text-sm font-medium mb-1">Time</h3>
<p className="text-sm">{format(new Date(selectedLog.createdAt), 'PPpp')}</p>
</div>
<div>
<h3 className="text-sm font-medium mb-1">Status</h3>
<p className="text-sm">
{selectedLog.status
? `Success (${selectedLog.statusCode || 200})`
: `Failed${selectedLog.statusCode ? ` (${selectedLog.statusCode})` : ''}`}
</p>
</div>
<div>
<h3 className="text-sm font-medium mb-1">Retry Count</h3>
<p className="text-sm">{selectedLog.retryCount}</p>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-1">Webhook URL</h3>
<div className="text-sm bg-muted p-3 rounded-md">
{selectedLog.webhookUrl}
</div>
</div>
{!selectedLog.status && selectedLog.error && (
<div>
<h3 className="text-sm font-medium mb-1 text-destructive">Error</h3>
<div className="text-sm bg-destructive/10 text-destructive p-3 rounded-md">
{selectedLog.error}
</div>
</div>
)}
<div>
<h3 className="text-sm font-medium mb-1">Payload</h3>
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-40">
{JSON.stringify(selectedLog.payload, null, 2)}
</pre>
</div>
{selectedLog.responseBody && (
<div>
<h3 className="text-sm font-medium mb-1">Response Body</h3>
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-40">
{typeof selectedLog.responseBody === 'string'
? (() => {
try {
return JSON.stringify(JSON.parse(selectedLog.responseBody), null, 2);
} catch {
return selectedLog.responseBody;
}
})()
: JSON.stringify(selectedLog.responseBody, null, 2)}
</pre>
</div>
)}
</div>
<DialogFooter>
{!selectedLog.status && (
<Button
onClick={() => handleRetry(selectedLog)}
disabled={retryMutation.isPending}
className="mr-auto"
>
{retryMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Retry Webhook
</>
)}
</Button>
)}
<Button variant="outline" onClick={() => setDetailsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</MainLayout>
);
}