import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createWebhook, deleteWebhook, listWebhookDeliveries, listWebhooks, testWebhook, updateWebhook, type OutboundWebhook, type WebhookDelivery, } from '@/lib/api-client'; import { useDemoMode } from '@/lib/demo-mode'; import { formatRelative } from '@/lib/time'; import { DemoRestricted, useToast } from '@/shared/components'; import { Webhook, Plus, Trash2, Send, Loader2, ChevronDown, ChevronRight, Copy, X, } from 'lucide-react'; const inputClass = 'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all'; const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*']; function statusClass(status: WebhookDelivery['status']): string { switch (status) { case 'success': return 'text-[var(--success)]'; case 'failed': return 'text-[var(--error)]'; default: return 'text-[var(--warning)]'; } } function WebhookRow({ webhook, onDelete, onToggle, onTest, testing, }: { webhook: OutboundWebhook; onDelete: () => void; onToggle: () => void; onTest: () => void; testing: boolean; }) { const [expanded, setExpanded] = useState(false); const deliveriesQuery = useQuery({ queryKey: ['webhook-deliveries', webhook.id], queryFn: () => listWebhookDeliveries(webhook.id), enabled: expanded, refetchInterval: expanded ? 10000 : false, }); return (
{webhook.name} {!webhook.enabled && ( disabled )}

{webhook.url}

{webhook.events.map((ev) => ( {ev} ))}
{expanded && (

Recent deliveries

{deliveriesQuery.isLoading ? ( ) : (deliveriesQuery.data ?? []).length === 0 ? (

No deliveries yet.

) : (
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
{d.status} {d.event} {d.response_status != null && ( HTTP {d.response_status} )} {d.attempts} tries {d.duration_ms != null && ( {d.duration_ms}ms )} {formatRelative(d.created_at)}
))}
)}
)}
); } export function WebhooksPage() { const isDemoMode = useDemoMode(); const queryClient = useQueryClient(); const toast = useToast(); const [creating, setCreating] = useState(false); const [form, setForm] = useState({ name: '', url: '', events: [] as string[] }); const [secret, setSecret] = useState(null); const [testingId, setTestingId] = useState(null); const webhooksQuery = useQuery({ queryKey: ['webhooks'], queryFn: listWebhooks, enabled: !isDemoMode, }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] }); const createMutation = useMutation({ mutationFn: () => createWebhook(form), onSuccess: (created) => { invalidate(); setCreating(false); setForm({ name: '', url: '', events: [] }); if (created.secret) { setSecret(created.secret); } }, onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Failed to create webhook', 'error'), }); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteWebhook(id), onSuccess: invalidate, onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Delete failed', 'error'), }); const toggleMutation = useMutation({ mutationFn: (w: OutboundWebhook) => updateWebhook(w.id, { enabled: !w.enabled }), onSuccess: invalidate, }); const testMutation = useMutation({ mutationFn: (id: string) => testWebhook(id), onMutate: (id) => setTestingId(id), onSettled: () => setTestingId(null), onSuccess: () => toast.showToast('Test ping queued', 'info'), onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Test failed', 'error'), }); if (isDemoMode) { return ; } const toggleEvent = (ev: string) => setForm((f) => ({ ...f, events: f.events.includes(ev) ? f.events.filter((e) => e !== ev) : [...f.events, ev], })); return (

Webhooks_

Signed HTTP delivery for every platform event — deploys, restarts, deletes

{secret && (

Signing secret — shown once

Verify deliveries with X-Containr-Signature

{secret}
)} {webhooksQuery.isLoading ? ( ) : (webhooksQuery.data ?? []).length === 0 ? (

No webhooks yet.

Subscribe an HTTP endpoint to platform events like service.* or{' '} deployment.fail.

) : ( (webhooksQuery.data ?? []).map((w) => ( testMutation.mutate(w.id)} onToggle={() => toggleMutation.mutate(w)} onDelete={() => { if (window.confirm(`Delete webhook "${w.name}"?`)) { deleteMutation.mutate(w.id); } }} /> )) )}
{creating && (
setCreating(false)} />

New webhook

setForm((f) => ({ ...f, name: e.target.value }))} className={`${inputClass} w-full`} placeholder="deploy-alerts" />
setForm((f) => ({ ...f, url: e.target.value }))} className={`${inputClass} w-full mono`} placeholder="https://example.com/hooks/containr" />
{eventSuggestions.map((ev) => ( ))}

Events are resource.action — wildcards like{' '} service.* match the whole resource.

)}
); }