* doc: update cookbook
* fix(cookbook): preserve tag keys during sync, strip local .md links
- Fix extract_tags_from_readme/notebook to return dict[str,str] preserving
sdk/topic keys instead of bare values, preventing topics like
"Customer Service" from being misclassified as SDK
- Add strip_local_md_links() to remove relative .md references that
would cause broken link errors in Docusaurus build
* ci: run test-doc-examples independently without waiting for test-rust-cli
Build the CLI directly in the job instead of downloading the artifact,
so test-doc-examples can start at the beginning in parallel with all other jobs.
* feat: webhook system with task-owned retry, retain.completed event, and UI
- New webhook system: register per-bank webhooks with HMAC signing, configurable
HTTP method/timeout/headers/params (http_config JSONB), and PATCH support
- Webhook deliveries run as async_operations (webhook_delivery type) with
task-owned retry via RetryTaskAt exception and exponential backoff
(60s / 5m / 30m / 2h / 8h, max 6 attempts)
- New retain.completed event fires per-document for both sync and async retain
- Delivery debug info (status code, response body) stored in result_metadata
- Control plane UI: webhooks tab per bank with create/edit/delete and a
deliveries table with cursor pagination and expandable response details
- 28 webhook tests covering HMAC signing, delivery retries, CRUD endpoints,
PATCH update, and retain.completed queuing
- Docs page at developer/api/webhooks documenting event payloads and delivery
- OpenAPI spec and all client SDKs (Python, TypeScript, Rust, Go) regenerated
* fix: update tests for task-owned retry model and guard _webhook_manager attribute
- test_worker.py: test_executor_exception_triggers_retry now raises RetryTaskAt
(plain exceptions are immediate failures in the new system); rename
test_executor_exception_marks_failed_after_max_retries to
test_executor_exception_marks_failed_immediately to reflect new semantics
- test_batch_api.py: remove max_retries kwarg from WorkerPoller constructor
- memory_engine.py: use getattr for _webhook_manager in _fire_retain_webhook
to avoid AttributeError when engine is created without __init__ (tests)
* fix: remove max_retries from benchmark WorkerPoller call
* fix(webhooks): transactional outbox, observations_deleted tracking, sidebar
- Queue webhook delivery rows atomically with the primary operation using the
transactional outbox pattern — prevents lost events on process crash:
- Retain (sync + async): outbox_callback passed into orchestrator.retain_batch
and called inside the DB transaction, replacing the post-commit fire call
- Consolidation: new _mark_operation_completed_and_fire_webhook combines the
status UPDATE and webhook INSERT in one transaction
- Added fire_event_with_conn() to WebhookManager for in-connection delivery
- Track observations_deleted count in consolidation stats and expose it in the
consolidation.completed webhook payload (was always None)
- Add Webhooks page to docs sidebar
- Document at-least-once delivery guarantee with operation_id dedup guidance
* fix(ui): add retain.completed to available webhook event types
* feat(ui): add delete confirmation dialog for webhooks
* fix(webhooks): include operation_id in task_payload so delivery is marked completed
The task_payload JSON was missing the operation_id field, causing execute_task
to see operation_id=None and skip _mark_operation_completed — leaving every
delivery row stuck in 'pending' forever.
Added a test that inserts a real async_operations row and verifies the status
transitions to 'completed' after a successful execute_task call.
* style: fix prettier formatting in webhooks-view
1110 lines
38 KiB
TypeScript
1110 lines
38 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useBank } from "@/lib/bank-context";
|
|
import { client, Webhook, WebhookDelivery, WebhookHttpConfig } from "@/lib/api";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
RefreshCw,
|
|
Plus,
|
|
Trash2,
|
|
Eye,
|
|
EyeOff,
|
|
Loader2,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Clock,
|
|
X,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Pencil,
|
|
} from "lucide-react";
|
|
|
|
const AVAILABLE_EVENT_TYPES = ["consolidation.completed", "retain.completed"];
|
|
|
|
interface KeyValuePair {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
interface CreateWebhookForm {
|
|
url: string;
|
|
secret: string;
|
|
event_types: string[];
|
|
enabled: boolean;
|
|
http_config: {
|
|
method: string;
|
|
timeout_seconds: number;
|
|
headers: KeyValuePair[];
|
|
params: KeyValuePair[];
|
|
};
|
|
}
|
|
|
|
const DEFAULT_FORM: CreateWebhookForm = {
|
|
url: "",
|
|
secret: "",
|
|
event_types: ["consolidation.completed"],
|
|
enabled: true,
|
|
http_config: {
|
|
method: "POST",
|
|
timeout_seconds: 30,
|
|
headers: [],
|
|
params: [],
|
|
},
|
|
};
|
|
|
|
function kvPairsToRecord(pairs: KeyValuePair[]): Record<string, string> {
|
|
return Object.fromEntries(pairs.filter((p) => p.key.trim()).map((p) => [p.key, p.value]));
|
|
}
|
|
|
|
function buildHttpConfig(cfg: CreateWebhookForm["http_config"]): WebhookHttpConfig {
|
|
return {
|
|
method: cfg.method,
|
|
timeout_seconds: cfg.timeout_seconds,
|
|
headers: kvPairsToRecord(cfg.headers),
|
|
params: kvPairsToRecord(cfg.params),
|
|
};
|
|
}
|
|
|
|
interface KVEditorProps {
|
|
label: string;
|
|
pairs: KeyValuePair[];
|
|
onChange: (pairs: KeyValuePair[]) => void;
|
|
}
|
|
|
|
function KVEditor({ label, pairs, onChange }: KVEditorProps) {
|
|
const addPair = () => onChange([...pairs, { key: "", value: "" }]);
|
|
const removePair = (i: number) => onChange(pairs.filter((_, idx) => idx !== i));
|
|
const updatePair = (i: number, field: "key" | "value", val: string) => {
|
|
const next = pairs.map((p, idx) => (idx === i ? { ...p, [field]: val } : p));
|
|
onChange(next);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>{label}</Label>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 text-xs px-2"
|
|
onClick={addPair}
|
|
>
|
|
<Plus className="w-3 h-3 mr-1" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
{pairs.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
{pairs.map((pair, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="Key"
|
|
value={pair.key}
|
|
onChange={(e) => updatePair(i, "key", e.target.value)}
|
|
className="h-8 text-sm flex-1"
|
|
/>
|
|
<Input
|
|
placeholder="Value"
|
|
value={pair.value}
|
|
onChange={(e) => updatePair(i, "value", e.target.value)}
|
|
className="h-8 text-sm flex-1"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removePair(i)}
|
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function statusBadge(status: string) {
|
|
if (status === "completed")
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
|
<CheckCircle className="w-3 h-3" />
|
|
delivered
|
|
</span>
|
|
);
|
|
if (status === "pending")
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
|
|
<Clock className="w-3 h-3" />
|
|
pending
|
|
</span>
|
|
);
|
|
if (status === "failed")
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20">
|
|
<AlertCircle className="w-3 h-3" />
|
|
failed
|
|
</span>
|
|
);
|
|
return (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border">
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DeliveryTableRow({
|
|
delivery,
|
|
formatDate,
|
|
}: {
|
|
delivery: WebhookDelivery;
|
|
formatDate: (d: string | null) => string;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const hasDetails = delivery.last_response_body || delivery.last_error;
|
|
|
|
return (
|
|
<>
|
|
<TableRow
|
|
className={hasDetails ? "cursor-pointer hover:bg-muted/40" : undefined}
|
|
onClick={() => hasDetails && setExpanded((v) => !v)}
|
|
>
|
|
<TableCell className="w-8 pl-3 pr-0">
|
|
{hasDetails ? (
|
|
expanded ? (
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
)
|
|
) : (
|
|
<span className="w-4 h-4 inline-block" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{statusBadge(delivery.status)}</TableCell>
|
|
<TableCell>
|
|
{delivery.last_response_status != null ? (
|
|
<span
|
|
className={`font-mono text-xs px-1.5 py-0.5 rounded border ${
|
|
delivery.last_response_status >= 200 && delivery.last_response_status < 300
|
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
|
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
|
|
}`}
|
|
>
|
|
{delivery.last_response_status}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground text-xs">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-center text-sm">{delivery.attempts}</TableCell>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
{delivery.event_type}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{formatDate(delivery.created_at)}
|
|
</TableCell>
|
|
</TableRow>
|
|
{expanded && hasDetails && (
|
|
<TableRow className="bg-muted/20 hover:bg-muted/20">
|
|
<TableCell colSpan={6} className="px-4 py-3">
|
|
<div className="space-y-3">
|
|
{delivery.last_error && (
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Error
|
|
</p>
|
|
<p className="font-mono text-xs text-red-600 dark:text-red-400 break-all">
|
|
{delivery.last_error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{delivery.last_response_body && (
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Response Body
|
|
{delivery.last_attempt_at && (
|
|
<span className="ml-2 normal-case font-normal">
|
|
· {formatDate(delivery.last_attempt_at)}
|
|
</span>
|
|
)}
|
|
</p>
|
|
<pre className="font-mono text-xs bg-background rounded p-2 overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto border border-border">
|
|
{delivery.last_response_body}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function webhookToForm(webhook: Webhook): CreateWebhookForm {
|
|
const cfg = webhook.http_config ?? {
|
|
method: "POST",
|
|
timeout_seconds: 30,
|
|
headers: {},
|
|
params: {},
|
|
};
|
|
return {
|
|
url: webhook.url,
|
|
secret: "",
|
|
event_types: webhook.event_types,
|
|
enabled: webhook.enabled,
|
|
http_config: {
|
|
method: cfg.method,
|
|
timeout_seconds: cfg.timeout_seconds,
|
|
headers: Object.entries(cfg.headers ?? {}).map(([key, value]) => ({ key, value })),
|
|
params: Object.entries(cfg.params ?? {}).map(([key, value]) => ({ key, value })),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function WebhooksView() {
|
|
const { currentBank } = useBank();
|
|
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [deleteConfirmWebhook, setDeleteConfirmWebhook] = useState<Webhook | null>(null);
|
|
const [showSecret, setShowSecret] = useState(false);
|
|
|
|
const [form, setForm] = useState<CreateWebhookForm>(DEFAULT_FORM);
|
|
|
|
// Edit dialog state
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null);
|
|
const [editForm, setEditForm] = useState<CreateWebhookForm>(DEFAULT_FORM);
|
|
const [showEditSecret, setShowEditSecret] = useState(false);
|
|
const [clearSecret, setClearSecret] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Deliveries dialog state
|
|
const [deliveriesDialogOpen, setDeliveriesDialogOpen] = useState(false);
|
|
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
|
|
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
|
|
const [loadingDeliveries, setLoadingDeliveries] = useState(false);
|
|
const [deliveriesCursor, setDeliveriesCursor] = useState<string | null>(null);
|
|
const [loadingMoreDeliveries, setLoadingMoreDeliveries] = useState(false);
|
|
|
|
const loadWebhooks = async () => {
|
|
if (!currentBank) return;
|
|
setLoading(true);
|
|
try {
|
|
const data = await client.listWebhooks(currentBank);
|
|
setWebhooks(data.items || []);
|
|
} catch (error) {
|
|
console.error("Error loading webhooks:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (currentBank) {
|
|
loadWebhooks();
|
|
}
|
|
}, [currentBank]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!currentBank || !form.url) return;
|
|
setCreating(true);
|
|
try {
|
|
await client.createWebhook(currentBank, {
|
|
url: form.url,
|
|
secret: form.secret || undefined,
|
|
event_types: form.event_types.length > 0 ? form.event_types : undefined,
|
|
enabled: form.enabled,
|
|
http_config: buildHttpConfig(form.http_config),
|
|
});
|
|
setCreateDialogOpen(false);
|
|
setForm(DEFAULT_FORM);
|
|
setShowSecret(false);
|
|
await loadWebhooks();
|
|
} catch (error) {
|
|
// Error toast shown by API client interceptor
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (webhookId: string) => {
|
|
if (!currentBank) return;
|
|
setDeletingId(webhookId);
|
|
setDeleteConfirmWebhook(null);
|
|
try {
|
|
await client.deleteWebhook(currentBank, webhookId);
|
|
await loadWebhooks();
|
|
} catch (error) {
|
|
// Error toast shown by API client interceptor
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
const handleOpenEdit = (webhook: Webhook) => {
|
|
setEditingWebhook(webhook);
|
|
setEditForm(webhookToForm(webhook));
|
|
setShowEditSecret(false);
|
|
setClearSecret(false);
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!currentBank || !editingWebhook || !editForm.url) return;
|
|
setSaving(true);
|
|
try {
|
|
const patch: Parameters<typeof client.updateWebhook>[2] = {
|
|
url: editForm.url,
|
|
event_types: editForm.event_types,
|
|
enabled: editForm.enabled,
|
|
http_config: buildHttpConfig(editForm.http_config),
|
|
};
|
|
if (clearSecret) {
|
|
patch.secret = null;
|
|
} else if (editForm.secret) {
|
|
patch.secret = editForm.secret;
|
|
}
|
|
await client.updateWebhook(currentBank, editingWebhook.id, patch);
|
|
setEditDialogOpen(false);
|
|
await loadWebhooks();
|
|
} catch {
|
|
// Error toast shown by API client interceptor
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleViewDeliveries = async (webhook: Webhook) => {
|
|
if (!currentBank) return;
|
|
setSelectedWebhook(webhook);
|
|
setDeliveriesDialogOpen(true);
|
|
setDeliveries([]);
|
|
setDeliveriesCursor(null);
|
|
setLoadingDeliveries(true);
|
|
try {
|
|
const data = await client.listWebhookDeliveries(currentBank, webhook.id, 50);
|
|
setDeliveries(data.items || []);
|
|
setDeliveriesCursor(data.next_cursor ?? null);
|
|
} catch (error) {
|
|
console.error("Error loading deliveries:", error);
|
|
} finally {
|
|
setLoadingDeliveries(false);
|
|
}
|
|
};
|
|
|
|
const handleLoadMoreDeliveries = async () => {
|
|
if (!currentBank || !selectedWebhook || !deliveriesCursor) return;
|
|
setLoadingMoreDeliveries(true);
|
|
try {
|
|
const data = await client.listWebhookDeliveries(
|
|
currentBank,
|
|
selectedWebhook.id,
|
|
50,
|
|
deliveriesCursor
|
|
);
|
|
setDeliveries((prev) => [...prev, ...(data.items || [])]);
|
|
setDeliveriesCursor(data.next_cursor ?? null);
|
|
} catch (error) {
|
|
console.error("Error loading more deliveries:", error);
|
|
} finally {
|
|
setLoadingMoreDeliveries(false);
|
|
}
|
|
};
|
|
|
|
const toggleEventType = (eventType: string) => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
event_types: prev.event_types.includes(eventType)
|
|
? prev.event_types.filter((e) => e !== eventType)
|
|
: [...prev.event_types, eventType],
|
|
}));
|
|
};
|
|
|
|
const formatDate = (dateStr: string | null) => {
|
|
if (!dateStr) return "N/A";
|
|
return new Date(dateStr).toLocaleString();
|
|
};
|
|
|
|
if (!currentBank) return null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-lg font-semibold">Webhooks</h3>
|
|
<button
|
|
onClick={() => loadWebhooks()}
|
|
className="p-1 rounded hover:bg-muted transition-colors"
|
|
title="Refresh webhooks"
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw
|
|
className={`w-4 h-4 text-muted-foreground hover:text-foreground ${loading ? "animate-spin" : ""}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{webhooks.length} webhook{webhooks.length !== 1 ? "s" : ""}
|
|
</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Webhook
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Webhooks table */}
|
|
{webhooks.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>URL</TableHead>
|
|
<TableHead>Method</TableHead>
|
|
<TableHead>Event Types</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
<TableHead className="w-[120px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{webhooks.map((webhook) => (
|
|
<TableRow key={webhook.id}>
|
|
<TableCell className="font-mono text-sm max-w-[300px] truncate">
|
|
<span title={webhook.url}>{webhook.url}</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-medium bg-muted text-muted-foreground border border-border">
|
|
{webhook.http_config?.method || "POST"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{webhook.event_types.length > 0 ? (
|
|
webhook.event_types.map((et) => (
|
|
<span
|
|
key={et}
|
|
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20"
|
|
>
|
|
{et}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">All events</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{webhook.enabled ? (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
|
<CheckCircle className="w-3 h-3" />
|
|
Enabled
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border">
|
|
Disabled
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{formatDate(webhook.created_at)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => handleViewDeliveries(webhook)}
|
|
title="View deliveries"
|
|
>
|
|
<Eye className="w-3 h-3 mr-1" />
|
|
Deliveries
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => handleOpenEdit(webhook)}
|
|
title="Edit webhook"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-muted-foreground hover:text-red-600 dark:hover:text-red-400"
|
|
onClick={() => setDeleteConfirmWebhook(webhook)}
|
|
disabled={deletingId === webhook.id}
|
|
title="Delete webhook"
|
|
>
|
|
{deletingId === webhook.id ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3 h-3" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground text-center py-8 text-sm">
|
|
No webhooks configured. Add a webhook to receive event notifications.
|
|
</p>
|
|
)}
|
|
|
|
{/* Create Webhook Dialog */}
|
|
<Dialog
|
|
open={createDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setForm(DEFAULT_FORM);
|
|
setShowSecret(false);
|
|
}
|
|
setCreateDialogOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Webhook</DialogTitle>
|
|
<DialogDescription>
|
|
Configure a webhook endpoint to receive event notifications.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* Type (display only for now) */}
|
|
<div className="space-y-1.5">
|
|
<Label>Type</Label>
|
|
<div className="flex items-center h-9 px-3 rounded-md border border-border bg-muted text-sm text-muted-foreground">
|
|
HTTP
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="webhook-url">
|
|
URL <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="webhook-url"
|
|
type="url"
|
|
placeholder="https://example.com/webhook"
|
|
value={form.url}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Method + Timeout row */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label>Method</Label>
|
|
<Select
|
|
value={form.http_config.method}
|
|
onValueChange={(v) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
http_config: { ...prev.http_config, method: v },
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="POST">POST</SelectItem>
|
|
<SelectItem value="GET">GET</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="webhook-timeout">Timeout (seconds)</Label>
|
|
<Input
|
|
id="webhook-timeout"
|
|
type="number"
|
|
min={1}
|
|
max={300}
|
|
value={form.http_config.timeout_seconds}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
http_config: {
|
|
...prev.http_config,
|
|
timeout_seconds: parseInt(e.target.value) || 30,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Secret */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="webhook-secret">
|
|
Secret <span className="text-muted-foreground text-xs">(optional)</span>
|
|
</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="webhook-secret"
|
|
type={showSecret ? "text" : "password"}
|
|
placeholder="Signing secret"
|
|
value={form.secret}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
|
|
className="pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSecret((v) => !v)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
tabIndex={-1}
|
|
>
|
|
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Headers */}
|
|
<KVEditor
|
|
label="Custom Headers"
|
|
pairs={form.http_config.headers}
|
|
onChange={(pairs) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
http_config: { ...prev.http_config, headers: pairs },
|
|
}))
|
|
}
|
|
/>
|
|
|
|
{/* Custom Query Params */}
|
|
<KVEditor
|
|
label="Query Parameters"
|
|
pairs={form.http_config.params}
|
|
onChange={(pairs) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
http_config: { ...prev.http_config, params: pairs },
|
|
}))
|
|
}
|
|
/>
|
|
|
|
{/* Event Types */}
|
|
<div className="space-y-2">
|
|
<Label>Event Types</Label>
|
|
<div className="space-y-2">
|
|
{AVAILABLE_EVENT_TYPES.map((eventType) => (
|
|
<div key={eventType} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`event-${eventType}`}
|
|
checked={form.event_types.includes(eventType)}
|
|
onCheckedChange={() => toggleEventType(eventType)}
|
|
/>
|
|
<Label
|
|
htmlFor={`event-${eventType}`}
|
|
className="font-mono text-sm cursor-pointer"
|
|
>
|
|
{eventType}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enabled toggle */}
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="webhook-enabled"
|
|
checked={form.enabled}
|
|
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, enabled: checked }))}
|
|
/>
|
|
<Label htmlFor="webhook-enabled">Enabled</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setCreateDialogOpen(false)}
|
|
disabled={creating}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={creating || !form.url}>
|
|
{creating ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
"Create Webhook"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Webhook Dialog */}
|
|
<Dialog
|
|
open={editDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setEditingWebhook(null);
|
|
setEditForm(DEFAULT_FORM);
|
|
setShowEditSecret(false);
|
|
setClearSecret(false);
|
|
}
|
|
setEditDialogOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Webhook</DialogTitle>
|
|
<DialogDescription>Update the webhook configuration.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* Type */}
|
|
<div className="space-y-1.5">
|
|
<Label>Type</Label>
|
|
<div className="flex items-center h-9 px-3 rounded-md border border-border bg-muted text-sm text-muted-foreground">
|
|
HTTP
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="edit-webhook-url">
|
|
URL <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Input
|
|
id="edit-webhook-url"
|
|
type="url"
|
|
placeholder="https://example.com/webhook"
|
|
value={editForm.url}
|
|
onChange={(e) => setEditForm((prev) => ({ ...prev, url: e.target.value }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Method + Timeout */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label>Method</Label>
|
|
<Select
|
|
value={editForm.http_config.method}
|
|
onValueChange={(v) =>
|
|
setEditForm((prev) => ({
|
|
...prev,
|
|
http_config: { ...prev.http_config, method: v },
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="POST">POST</SelectItem>
|
|
<SelectItem value="GET">GET</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="edit-webhook-timeout">Timeout (seconds)</Label>
|
|
<Input
|
|
id="edit-webhook-timeout"
|
|
type="number"
|
|
min={1}
|
|
max={300}
|
|
value={editForm.http_config.timeout_seconds}
|
|
onChange={(e) =>
|
|
setEditForm((prev) => ({
|
|
...prev,
|
|
http_config: {
|
|
...prev.http_config,
|
|
timeout_seconds: parseInt(e.target.value) || 30,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Secret */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="edit-webhook-secret">Secret</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="edit-webhook-secret"
|
|
type={showEditSecret ? "text" : "password"}
|
|
placeholder="Leave blank to keep existing"
|
|
value={editForm.secret}
|
|
disabled={clearSecret}
|
|
onChange={(e) => setEditForm((prev) => ({ ...prev, secret: e.target.value }))}
|
|
className="pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowEditSecret((v) => !v)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
tabIndex={-1}
|
|
>
|
|
{showEditSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<Checkbox
|
|
id="edit-clear-secret"
|
|
checked={clearSecret}
|
|
onCheckedChange={(v) => {
|
|
setClearSecret(!!v);
|
|
if (v) setEditForm((prev) => ({ ...prev, secret: "" }));
|
|
}}
|
|
/>
|
|
<Label
|
|
htmlFor="edit-clear-secret"
|
|
className="text-xs text-muted-foreground cursor-pointer"
|
|
>
|
|
Clear existing secret
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Headers */}
|
|
<KVEditor
|
|
label="Custom Headers"
|
|
pairs={editForm.http_config.headers}
|
|
onChange={(pairs) =>
|
|
setEditForm((prev) => ({
|
|
...prev,
|
|
http_config: { ...prev.http_config, headers: pairs },
|
|
}))
|
|
}
|
|
/>
|
|
|
|
{/* Custom Query Params */}
|
|
<KVEditor
|
|
label="Query Parameters"
|
|
pairs={editForm.http_config.params}
|
|
onChange={(pairs) =>
|
|
setEditForm((prev) => ({
|
|
...prev,
|
|
http_config: { ...prev.http_config, params: pairs },
|
|
}))
|
|
}
|
|
/>
|
|
|
|
{/* Event Types */}
|
|
<div className="space-y-2">
|
|
<Label>Event Types</Label>
|
|
<div className="space-y-2">
|
|
{AVAILABLE_EVENT_TYPES.map((eventType) => (
|
|
<div key={eventType} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`edit-event-${eventType}`}
|
|
checked={editForm.event_types.includes(eventType)}
|
|
onCheckedChange={() =>
|
|
setEditForm((prev) => ({
|
|
...prev,
|
|
event_types: prev.event_types.includes(eventType)
|
|
? prev.event_types.filter((e) => e !== eventType)
|
|
: [...prev.event_types, eventType],
|
|
}))
|
|
}
|
|
/>
|
|
<Label
|
|
htmlFor={`edit-event-${eventType}`}
|
|
className="font-mono text-sm cursor-pointer"
|
|
>
|
|
{eventType}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enabled toggle */}
|
|
<div className="flex items-center gap-3">
|
|
<Switch
|
|
id="edit-webhook-enabled"
|
|
checked={editForm.enabled}
|
|
onCheckedChange={(checked) =>
|
|
setEditForm((prev) => ({ ...prev, enabled: checked }))
|
|
}
|
|
/>
|
|
<Label htmlFor="edit-webhook-enabled">Enabled</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving || !editForm.url}>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
"Save Changes"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog
|
|
open={!!deleteConfirmWebhook}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteConfirmWebhook(null);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Webhook</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete this webhook? This action cannot be undone and all
|
|
pending deliveries will be cancelled.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{deleteConfirmWebhook && (
|
|
<div className="py-2">
|
|
<p className="font-mono text-sm truncate text-muted-foreground bg-muted px-3 py-2 rounded-md border border-border">
|
|
{deleteConfirmWebhook.url}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteConfirmWebhook(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => deleteConfirmWebhook && handleDelete(deleteConfirmWebhook.id)}
|
|
disabled={!!deletingId}
|
|
>
|
|
{deletingId ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Deleting...
|
|
</>
|
|
) : (
|
|
"Delete Webhook"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Deliveries Dialog */}
|
|
<Dialog
|
|
open={deliveriesDialogOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeliveries([]);
|
|
setDeliveriesCursor(null);
|
|
}
|
|
setDeliveriesDialogOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Webhook Deliveries</DialogTitle>
|
|
{selectedWebhook && (
|
|
<DialogDescription className="font-mono text-xs truncate">
|
|
{selectedWebhook.url}
|
|
</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
|
|
{loadingDeliveries ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : deliveries.length > 0 ? (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-8 pl-3 pr-0" />
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>HTTP</TableHead>
|
|
<TableHead className="text-center">Attempts</TableHead>
|
|
<TableHead>Event</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{deliveries.map((delivery) => (
|
|
<DeliveryTableRow
|
|
key={delivery.id}
|
|
delivery={delivery}
|
|
formatDate={formatDate}
|
|
/>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{deliveriesCursor && (
|
|
<div className="flex justify-center pt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleLoadMoreDeliveries}
|
|
disabled={loadingMoreDeliveries}
|
|
>
|
|
{loadingMoreDeliveries && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Load more
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="text-muted-foreground text-center py-8 text-sm">
|
|
No deliveries yet for this webhook.
|
|
</p>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|