mirror of
https://github.com/freedbygrace/DynamoDNS.git
synced 2026-08-17 21:37:50 +00:00
999c934494
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/a40c03fa-fc32-4a1a-9744-9d7f692bf7eb.jpg
1000 lines
35 KiB
TypeScript
1000 lines
35 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useOrganization } from "@/context/organization-context";
|
|
import { Webhook, WebhookDeliveryLog } from "@shared/schema";
|
|
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from "@/components/ui/select";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from "@/components/ui/sheet";
|
|
|
|
import {
|
|
Loader2,
|
|
Plus,
|
|
Trash,
|
|
Edit,
|
|
ExternalLink,
|
|
RefreshCw,
|
|
Clock,
|
|
Check,
|
|
X,
|
|
Activity,
|
|
Eye,
|
|
Bell,
|
|
BellRing
|
|
} from "lucide-react";
|
|
|
|
export default function WebhooksPage() {
|
|
const { toast } = useToast();
|
|
const { user } = useAuth();
|
|
const { currentOrganization } = useOrganization();
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
const [isTestResultOpen, setIsTestResultOpen] = useState(false);
|
|
const [isHistorySheetOpen, setIsHistorySheetOpen] = useState(false);
|
|
const [isLogsDialogOpen, setIsLogsDialogOpen] = useState(false);
|
|
const [currentWebhook, setCurrentWebhook] = useState<Webhook | null>(null);
|
|
const [currentWebhookId, setCurrentWebhookId] = useState<string | null>(null);
|
|
const [testResult, setTestResult] = useState<any>(null);
|
|
const [selectedLog, setSelectedLog] = useState<WebhookDeliveryLog | null>(null);
|
|
|
|
// Form state for create/edit
|
|
const [formData, setFormData] = useState({
|
|
name: "",
|
|
url: "",
|
|
events: ["dns.*"],
|
|
secret: "",
|
|
isActive: true,
|
|
});
|
|
|
|
// Reset form when dialog closes
|
|
const resetForm = () => {
|
|
setFormData({
|
|
name: "",
|
|
url: "",
|
|
events: ["dns.*"],
|
|
secret: "",
|
|
isActive: true,
|
|
});
|
|
};
|
|
|
|
// Fetch webhooks for the selected organization
|
|
const {
|
|
data: webhooks = [],
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ["/api/webhooks", currentOrganization?.id],
|
|
queryFn: async () => {
|
|
if (!currentOrganization) return [];
|
|
const res = await apiRequest(
|
|
"GET",
|
|
`/api/webhooks?organizationId=${currentOrganization.id}`
|
|
);
|
|
return await res.json();
|
|
},
|
|
enabled: !!currentOrganization,
|
|
});
|
|
|
|
// Create webhook mutation
|
|
const createWebhookMutation = useMutation({
|
|
mutationFn: async (data: any) => {
|
|
const res = await apiRequest("POST", "/api/webhooks", {
|
|
...data,
|
|
organizationId: currentOrganization?.id,
|
|
});
|
|
return await res.json();
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Webhook created",
|
|
description: "The webhook has been created successfully.",
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["/api/webhooks", currentOrganization?.id],
|
|
});
|
|
setIsCreateOpen(false);
|
|
resetForm();
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Error creating webhook",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Update webhook mutation
|
|
const updateWebhookMutation = useMutation({
|
|
mutationFn: async ({ id, data }: { id: string; data: any }) => {
|
|
const res = await apiRequest("PUT", `/api/webhooks/${id}`, data);
|
|
return await res.json();
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Webhook updated",
|
|
description: "The webhook has been updated successfully.",
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["/api/webhooks", currentOrganization?.id],
|
|
});
|
|
setIsEditOpen(false);
|
|
resetForm();
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Error updating webhook",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Delete webhook mutation
|
|
const deleteWebhookMutation = useMutation({
|
|
mutationFn: async (id: string) => {
|
|
await apiRequest("DELETE", `/api/webhooks/${id}`);
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Webhook deleted",
|
|
description: "The webhook has been deleted successfully.",
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["/api/webhooks", currentOrganization?.id],
|
|
});
|
|
setIsDeleteOpen(false);
|
|
setCurrentWebhook(null);
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Error deleting webhook",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Test webhook mutation
|
|
const testWebhookMutation = useMutation({
|
|
mutationFn: async (id: string) => {
|
|
const res = await apiRequest("POST", `/api/webhooks/${id}/test`);
|
|
return await res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
setTestResult({
|
|
success: true,
|
|
message: data.message || "Webhook test successful",
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
setIsTestResultOpen(true);
|
|
toast({
|
|
title: "Webhook tested",
|
|
description: "The webhook has been tested successfully.",
|
|
});
|
|
},
|
|
onError: (error: Error) => {
|
|
setTestResult({
|
|
success: false,
|
|
message: error.message || "Webhook test failed",
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
setIsTestResultOpen(true);
|
|
toast({
|
|
title: "Error testing webhook",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Retry webhook delivery mutation
|
|
const retryWebhookDeliveryMutation = useMutation({
|
|
mutationFn: async (logId: string) => {
|
|
const res = await apiRequest("POST", `/api/webhook-logs/${logId}/retry`);
|
|
return await res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({
|
|
title: "Webhook delivery retried",
|
|
description: "The webhook delivery has been retried successfully.",
|
|
});
|
|
// Refresh the logs to show the new retry attempt
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["/api/webhooks", currentWebhookId, "logs"],
|
|
});
|
|
// Reset the selected log
|
|
setSelectedLog(null);
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Error retrying webhook",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Fetch webhook delivery logs
|
|
const {
|
|
data: webhookLogs = [],
|
|
isLoading: isLogsLoading,
|
|
error: logsError,
|
|
} = useQuery({
|
|
queryKey: ["/api/webhooks", currentWebhookId, "logs"],
|
|
queryFn: async () => {
|
|
if (!currentWebhookId) return [];
|
|
const res = await apiRequest("GET", `/api/webhooks/${currentWebhookId}/logs`);
|
|
return await res.json();
|
|
},
|
|
enabled: !!currentWebhookId && isLogsDialogOpen,
|
|
});
|
|
|
|
const handleCreateSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
createWebhookMutation.mutate(formData);
|
|
};
|
|
|
|
const handleEditSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (currentWebhook) {
|
|
updateWebhookMutation.mutate({
|
|
id: currentWebhook.id,
|
|
data: formData,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (currentWebhook) {
|
|
deleteWebhookMutation.mutate(currentWebhook.id);
|
|
}
|
|
};
|
|
|
|
const handleTestWebhook = (id: string) => {
|
|
testWebhookMutation.mutate(id);
|
|
};
|
|
|
|
const handleRetryWebhook = (logId: string) => {
|
|
retryWebhookDeliveryMutation.mutate(logId);
|
|
};
|
|
|
|
const handleEditClick = (webhook: Webhook) => {
|
|
setCurrentWebhook(webhook);
|
|
setFormData({
|
|
name: webhook.name,
|
|
url: webhook.url,
|
|
events: webhook.events,
|
|
secret: webhook.secret || "",
|
|
isActive: webhook.isActive,
|
|
});
|
|
setIsEditOpen(true);
|
|
};
|
|
|
|
const handleDeleteClick = (webhook: Webhook) => {
|
|
setCurrentWebhook(webhook);
|
|
setIsDeleteOpen(true);
|
|
};
|
|
|
|
const handleViewLogs = (webhookId: string) => {
|
|
setCurrentWebhookId(webhookId);
|
|
const webhook = webhooks.find((w: Webhook) => w.id === webhookId);
|
|
if (webhook) {
|
|
setCurrentWebhook(webhook);
|
|
}
|
|
setIsLogsDialogOpen(true);
|
|
};
|
|
|
|
// Event options for the select component
|
|
const eventOptions = [
|
|
{ label: "All DNS events", value: "dns.*" },
|
|
{ label: "DNS create events", value: "dns.create" },
|
|
{ label: "DNS update events", value: "dns.update" },
|
|
{ label: "DNS delete events", value: "dns.delete" },
|
|
];
|
|
|
|
// Helper to format the date
|
|
const formatDate = (dateString: string | Date | null | undefined) => {
|
|
if (!dateString) return "Never";
|
|
if (dateString instanceof Date) {
|
|
return dateString.toLocaleString();
|
|
}
|
|
return new Date(dateString).toLocaleString();
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full">
|
|
<div className="text-destructive text-lg">Error loading webhooks</div>
|
|
<p className="text-muted-foreground mt-2">{(error as Error).message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container py-6 space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Webhooks</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Manage notifications for DNS changes
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setIsCreateOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" /> Add Webhook
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Webhooks</CardTitle>
|
|
<CardDescription>
|
|
Receive notifications when DNS records change
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{webhooks.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<BellRing className="mx-auto h-12 w-12 text-muted-foreground opacity-50" />
|
|
<h3 className="mt-4 text-lg font-medium">No webhooks configured</h3>
|
|
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
|
Webhooks allow your systems to be notified when DNS records change.
|
|
Add a webhook to get started.
|
|
</p>
|
|
<Button
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="mt-4"
|
|
variant="outline"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" /> Add Webhook
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="border rounded-md overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>URL</TableHead>
|
|
<TableHead>Events</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Last Triggered</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{webhooks.map((webhook: Webhook) => (
|
|
<TableRow key={webhook.id}>
|
|
<TableCell className="font-medium">{webhook.name}</TableCell>
|
|
<TableCell className="max-w-[200px] truncate">{webhook.url}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{webhook.events.map((event) => (
|
|
<Badge key={event} variant="outline">
|
|
{event}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{webhook.isActive ? (
|
|
<Badge className="bg-green-500 text-white">Active</Badge>
|
|
) : (
|
|
<Badge variant="secondary">Inactive</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{webhook.lastTriggered ? (
|
|
<div className="flex items-center">
|
|
<Clock className="mr-1 h-3 w-3" />
|
|
{formatDate(webhook.lastTriggered)}
|
|
</div>
|
|
) : (
|
|
"Never"
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end space-x-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleViewLogs(webhook.id)}
|
|
>
|
|
<Activity className="h-4 w-4" />
|
|
<span className="sr-only">Logs</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleTestWebhook(webhook.id)}
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
<span className="sr-only">Test</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleEditClick(webhook)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
<span className="sr-only">Edit</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-destructive"
|
|
onClick={() => handleDeleteClick(webhook)}
|
|
>
|
|
<Trash className="h-4 w-4" />
|
|
<span className="sr-only">Delete</span>
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Create Webhook Dialog */}
|
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
|
<DialogContent className="sm:max-w-[525px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Create Webhook</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new webhook to receive notifications when DNS records change.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleCreateSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="name">Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, name: e.target.value })
|
|
}
|
|
placeholder="Production DNS Alerts"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="url">URL</Label>
|
|
<Input
|
|
id="url"
|
|
value={formData.url}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, url: e.target.value })
|
|
}
|
|
placeholder="https://example.com/webhook"
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
The URL that will receive webhook events
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="events">Events</Label>
|
|
<div className="space-y-2">
|
|
{eventOptions.map((option) => (
|
|
<div key={option.value} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`event-${option.value}`}
|
|
checked={formData.events.includes(option.value)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setFormData({
|
|
...formData,
|
|
events: [...formData.events, option.value],
|
|
});
|
|
} else {
|
|
setFormData({
|
|
...formData,
|
|
events: formData.events.filter(
|
|
(e) => e !== option.value
|
|
),
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor={`event-${option.value}`}
|
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
>
|
|
{option.label}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="secret">Secret Key (Optional)</Label>
|
|
<Input
|
|
id="secret"
|
|
value={formData.secret}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, secret: e.target.value })
|
|
}
|
|
placeholder="webhook_secret_key"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
Used to sign webhook payloads so you can verify they came from us
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="active"
|
|
checked={formData.isActive}
|
|
onCheckedChange={(checked) =>
|
|
setFormData({ ...formData, isActive: checked })
|
|
}
|
|
/>
|
|
<Label htmlFor="active">Active</Label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsCreateOpen(false);
|
|
resetForm();
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={createWebhookMutation.isPending}>
|
|
{createWebhookMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Create Webhook
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Webhook Dialog */}
|
|
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
|
<DialogContent className="sm:max-w-[525px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Webhook</DialogTitle>
|
|
<DialogDescription>
|
|
Update the webhook configuration.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleEditSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="edit-name">Name</Label>
|
|
<Input
|
|
id="edit-name"
|
|
value={formData.name}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, name: e.target.value })
|
|
}
|
|
placeholder="Production DNS Alerts"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="edit-url">URL</Label>
|
|
<Input
|
|
id="edit-url"
|
|
value={formData.url}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, url: e.target.value })
|
|
}
|
|
placeholder="https://example.com/webhook"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="edit-events">Events</Label>
|
|
<div className="space-y-2">
|
|
{eventOptions.map((option) => (
|
|
<div key={option.value} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`edit-event-${option.value}`}
|
|
checked={formData.events.includes(option.value)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setFormData({
|
|
...formData,
|
|
events: [...formData.events, option.value],
|
|
});
|
|
} else {
|
|
setFormData({
|
|
...formData,
|
|
events: formData.events.filter(
|
|
(e) => e !== option.value
|
|
),
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor={`edit-event-${option.value}`}
|
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
>
|
|
{option.label}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="edit-secret">Secret Key (Optional)</Label>
|
|
<Input
|
|
id="edit-secret"
|
|
value={formData.secret}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, secret: e.target.value })
|
|
}
|
|
placeholder="webhook_secret_key"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
Used to sign webhook payloads so you can verify they came from us
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="edit-active"
|
|
checked={formData.isActive}
|
|
onCheckedChange={(checked) =>
|
|
setFormData({ ...formData, isActive: checked })
|
|
}
|
|
/>
|
|
<Label htmlFor="edit-active">Active</Label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsEditOpen(false);
|
|
resetForm();
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={updateWebhookMutation.isPending}>
|
|
{updateWebhookMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Save Changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Webhook Dialog */}
|
|
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This action cannot be undone. This will permanently delete the
|
|
webhook{" "}
|
|
<span className="font-medium">{currentWebhook?.name}</span> and
|
|
stop all notifications.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setCurrentWebhook(null)}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
disabled={deleteWebhookMutation.isPending}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{deleteWebhookMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Test Result Dialog */}
|
|
<Dialog open={isTestResultOpen} onOpenChange={setIsTestResultOpen}>
|
|
<DialogContent className="sm:max-w-[525px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Test Result</DialogTitle>
|
|
<DialogDescription>
|
|
Result of the webhook test request.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<div className="flex items-center mb-4 gap-2">
|
|
{testResult?.success ? (
|
|
<Badge className="bg-green-500 text-white px-2 py-1">
|
|
<Check className="h-4 w-4 mr-1" />
|
|
Success
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="destructive" className="px-2 py-1">
|
|
<X className="h-4 w-4 mr-1" />
|
|
Failed
|
|
</Badge>
|
|
)}
|
|
<span className="text-sm text-muted-foreground">
|
|
{formatDate(testResult?.timestamp)}
|
|
</span>
|
|
</div>
|
|
<div className="bg-muted p-4 rounded-md">
|
|
<p className="text-sm whitespace-pre-wrap">
|
|
{testResult?.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => setIsTestResultOpen(false)}>Close</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Webhook Delivery Logs Dialog */}
|
|
<Dialog
|
|
open={isLogsDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setIsLogsDialogOpen(open);
|
|
if (!open) {
|
|
setCurrentWebhookId(null);
|
|
setSelectedLog(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Delivery Logs - {currentWebhook?.name}</DialogTitle>
|
|
<DialogDescription>
|
|
View webhook delivery history and status
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{isLogsLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
) : logsError ? (
|
|
<div className="py-4 text-center">
|
|
<p className="text-destructive">Error loading logs</p>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
{(logsError as Error).message}
|
|
</p>
|
|
</div>
|
|
) : webhookLogs.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<Activity className="mx-auto h-12 w-12 text-muted-foreground opacity-50" />
|
|
<h3 className="mt-4 text-lg font-medium">No delivery logs found</h3>
|
|
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
|
This webhook hasn't been triggered yet or logs have been cleared.
|
|
</p>
|
|
<Button
|
|
onClick={() => handleTestWebhook(currentWebhookId!)}
|
|
className="mt-4"
|
|
variant="outline"
|
|
>
|
|
<RefreshCw className="mr-2 h-4 w-4" /> Test Webhook Now
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
<div className="border rounded-md overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Event</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{webhookLogs.map((log: WebhookDeliveryLog) => (
|
|
<TableRow key={log.id}>
|
|
<TableCell className="font-medium">{log.event}</TableCell>
|
|
<TableCell>
|
|
{log.status ? (
|
|
<Badge className="bg-green-500 text-white">Success</Badge>
|
|
) : (
|
|
<Badge variant="destructive">Failed</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{formatDate(log.createdAt)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setSelectedLog(log)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
<span className="sr-only">View Details</span>
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{selectedLog && (
|
|
<div className="space-y-4 border p-4 rounded-md">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-medium">Delivery Details</h3>
|
|
<div className="flex space-x-2">
|
|
{!selectedLog.status && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRetryWebhook(selectedLog.id)}
|
|
disabled={retryWebhookDeliveryMutation.isPending}
|
|
>
|
|
{retryWebhookDeliveryMutation.isPending && (
|
|
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
|
)}
|
|
<RefreshCw className="mr-1 h-3 w-3" /> Retry
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setSelectedLog(null)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-1">Status</h4>
|
|
<div className="flex items-center gap-2">
|
|
{selectedLog.status ? (
|
|
<Badge className="bg-green-500 text-white">
|
|
Success ({selectedLog.statusCode || 200})
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="destructive">
|
|
Failed {selectedLog.statusCode && `(${selectedLog.statusCode})`}
|
|
</Badge>
|
|
)}
|
|
{selectedLog.retryCount > 0 && (
|
|
<Badge variant="outline">
|
|
Retry {selectedLog.retryCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedLog.message && (
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-1">Message</h4>
|
|
<div className="bg-muted p-3 rounded-md text-sm">
|
|
{selectedLog.message}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-1">Payload</h4>
|
|
<div className="bg-muted p-3 rounded-md overflow-x-auto">
|
|
<pre className="text-xs whitespace-pre-wrap">
|
|
{JSON.stringify(selectedLog.payload, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedLog.responseBody && (
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-1">Response</h4>
|
|
<div className="bg-muted p-3 rounded-md overflow-x-auto">
|
|
<pre className="text-xs whitespace-pre-wrap">
|
|
{selectedLog.responseBody}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedLog.signature && (
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-1">Signature</h4>
|
|
<div className="bg-muted p-3 rounded-md text-xs font-mono truncate">
|
|
{selectedLog.signature}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsLogsDialogOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
} |