From 6e791571280e844bd3d168d519a5a863f14a949b Mon Sep 17 00:00:00 2001 From: Litezy Date: Mon, 29 Jun 2026 21:16:11 +0100 Subject: [PATCH] feat: persist webhooks to SQLite, add delivery UI, and switch event indexer to Horizon SSE - Migration 028: add `webhooks` and `webhook_deliveries` tables with cascade delete and retry index - Replace in-memory Map WebhookRepository with sqliteWebhookRepository backed by better-sqlite3 - Add missing webhook routes: GET delivery by ID, POST replay, POST test, POST /verify (public, no auth) - Wire event indexer to Horizon SSE ledger stream for near-instant indexing; fall back to 30s polling when horizonUrl is absent - Frontend: add webhook management page at /admin/webhooks with create, edit, delete, test, and delivery history with one-click replay - Remove dead import of unused createWebhookRoutes from routes/webhooks.js Co-Authored-By: Claude Sonnet 4.6 --- backend/src/dal/index.js | 4 +- backend/src/dal/sqliteWebhookRepository.js | 208 +++++++++ backend/src/db/migrations/028_webhooks.js | 43 ++ backend/src/index.js | 100 +++- backend/src/jobs/eventIndexer.js | 53 ++- frontend/src/App.jsx | 13 + frontend/src/lib/apiClient.js | 65 +++ frontend/src/pages/WebhookManagement.jsx | 506 +++++++++++++++++++++ 8 files changed, 988 insertions(+), 4 deletions(-) create mode 100644 backend/src/dal/sqliteWebhookRepository.js create mode 100644 backend/src/db/migrations/028_webhooks.js create mode 100644 frontend/src/pages/WebhookManagement.jsx diff --git a/backend/src/dal/index.js b/backend/src/dal/index.js index 20366e1f..ee443bc7 100644 --- a/backend/src/dal/index.js +++ b/backend/src/dal/index.js @@ -7,7 +7,7 @@ import { } from './sqliteCampaignRepository.js'; import { assertAuditLogRepository } from './auditLogRepository.js'; import { createSqliteAuditLogRepository } from './sqliteAuditLogRepository.js'; -import { WebhookRepository } from './webhookRepository.js'; +import { createSqliteWebhookRepository } from './sqliteWebhookRepository.js'; import { createSqliteReferralRepository } from './sqliteReferralRepository.js'; import { assertApiKeyRepository } from './apiKeyRepository.js'; import { createSqliteApiKeyRepository } from './sqliteApiKeyRepository.js'; @@ -83,7 +83,7 @@ export async function createDal({ auditLogs: assertAuditLogRepository( auditLogRepository ?? pgAuditLogs ?? createSqliteAuditLogRepository({ db }), ), - webhooks: webhookRepository ?? new WebhookRepository(db), + webhooks: webhookRepository ?? createSqliteWebhookRepository({ db }), referrals: createSqliteReferralRepository({ db }), variants: createSqliteVariantRepository({ db }), cohorts: createSqliteCohortRepository({ db }), diff --git a/backend/src/dal/sqliteWebhookRepository.js b/backend/src/dal/sqliteWebhookRepository.js new file mode 100644 index 00000000..0bbbea00 --- /dev/null +++ b/backend/src/dal/sqliteWebhookRepository.js @@ -0,0 +1,208 @@ +import { randomUUID } from 'node:crypto'; + +function rowToWebhook(row) { + if (!row) return null; + return { + id: row.id, + url: row.url, + secret: row.secret, + events: JSON.parse(row.events || '[]'), + active: row.active === 1, + description: row.description || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function rowToDelivery(row) { + if (!row) return null; + let payload = null; + try { + payload = row.payload ? JSON.parse(row.payload) : null; + } catch { + payload = row.payload; + } + return { + id: row.id, + webhookId: row.webhook_id, + event: row.event, + payload, + statusCode: row.status_code, + error: row.error || null, + attempts: row.attempts, + nextRetryAt: row.next_retry_at || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function createSqliteWebhookRepository({ db }) { + function create({ url, events, secret, description = '' }) { + const id = randomUUID(); + const now = new Date().toISOString(); + const resolvedSecret = secret || randomUUID(); + const eventsJson = JSON.stringify(Array.isArray(events) ? events : []); + + db.prepare(` + INSERT INTO webhooks (id, url, secret, events, active, description, created_at, updated_at) + VALUES (?, ?, ?, ?, 1, ?, ?, ?) + `).run(id, url, resolvedSecret, eventsJson, description, now, now); + + return { + id, + url, + secret: resolvedSecret, + events: Array.isArray(events) ? events : [], + active: true, + description, + createdAt: now, + updatedAt: now, + }; + } + + function getById(id) { + const row = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id); + return rowToWebhook(row); + } + + function list(filters = {}) { + if (filters.active !== undefined) { + const rows = db + .prepare('SELECT * FROM webhooks WHERE active = ? ORDER BY created_at DESC') + .all(filters.active ? 1 : 0); + return rows.map(rowToWebhook); + } + return db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map(rowToWebhook); + } + + function update(id, updates) { + const existing = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id); + if (!existing) return null; + + const now = new Date().toISOString(); + const merged = rowToWebhook(existing); + + if (updates.url !== undefined) merged.url = updates.url; + if (updates.events !== undefined) merged.events = updates.events; + if (updates.active !== undefined) merged.active = updates.active; + if (updates.description !== undefined) merged.description = updates.description; + if (updates.secret !== undefined) merged.secret = updates.secret; + merged.updatedAt = now; + + db.prepare(` + UPDATE webhooks + SET url = ?, events = ?, active = ?, description = ?, secret = ?, updated_at = ? + WHERE id = ? + `).run( + merged.url, + JSON.stringify(merged.events), + merged.active ? 1 : 0, + merged.description, + merged.secret, + merged.updatedAt, + id, + ); + + return merged; + } + + function del(id) { + const result = db.prepare('DELETE FROM webhooks WHERE id = ?').run(id); + return result.changes > 0; + } + + function recordDelivery({ webhookId, event, payload, statusCode, error }) { + const id = randomUUID(); + const now = new Date().toISOString(); + const failed = statusCode >= 400 || statusCode === 0; + const nextRetryAt = failed ? new Date(Date.now() + 60_000).toISOString() : null; + const payloadJson = payload !== undefined ? JSON.stringify(payload) : null; + + db.prepare(` + INSERT INTO webhook_deliveries + (id, webhook_id, event, payload, status_code, error, attempts, next_retry_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) + `).run(id, webhookId, event, payloadJson, statusCode, error || null, nextRetryAt, now, now); + + return { + id, + webhookId, + event, + payload, + statusCode, + error: error || null, + attempts: 1, + nextRetryAt, + createdAt: now, + updatedAt: now, + }; + } + + function getDeliveryById(id) { + const row = db.prepare('SELECT * FROM webhook_deliveries WHERE id = ?').get(id); + return rowToDelivery(row); + } + + function listDeliveries(webhookId, filters = {}) { + const limit = filters.limit || 100; + const rows = db + .prepare( + 'SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT ?', + ) + .all(webhookId, limit); + return rows.map(rowToDelivery); + } + + function getPendingRetries() { + const now = new Date().toISOString(); + const rows = db + .prepare( + `SELECT * FROM webhook_deliveries + WHERE next_retry_at IS NOT NULL AND next_retry_at <= ? AND attempts < 5`, + ) + .all(now); + return rows.map(rowToDelivery); + } + + function updateDelivery(id, updates) { + const existing = db.prepare('SELECT * FROM webhook_deliveries WHERE id = ?').get(id); + if (!existing) return null; + + const now = new Date().toISOString(); + const merged = rowToDelivery(existing); + + if (updates.statusCode !== undefined) merged.statusCode = updates.statusCode; + if (updates.error !== undefined) merged.error = updates.error; + if (updates.attempts !== undefined) merged.attempts = updates.attempts; + if ('nextRetryAt' in updates) merged.nextRetryAt = updates.nextRetryAt; + merged.updatedAt = now; + + db.prepare(` + UPDATE webhook_deliveries + SET status_code = ?, error = ?, attempts = ?, next_retry_at = ?, updated_at = ? + WHERE id = ? + `).run( + merged.statusCode, + merged.error, + merged.attempts, + merged.nextRetryAt, + merged.updatedAt, + id, + ); + + return merged; + } + + return { + create, + getById, + list, + update, + delete: del, + recordDelivery, + getDeliveryById, + listDeliveries, + getPendingRetries, + updateDelivery, + }; +} diff --git a/backend/src/db/migrations/028_webhooks.js b/backend/src/db/migrations/028_webhooks.js new file mode 100644 index 00000000..04c0f5da --- /dev/null +++ b/backend/src/db/migrations/028_webhooks.js @@ -0,0 +1,43 @@ +export const version = 28; +export const description = 'Add webhooks and webhook_deliveries tables for persistent webhook storage'; + +export function up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL DEFAULT '[]', + active INTEGER NOT NULL DEFAULT 1, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + event TEXT NOT NULL, + payload TEXT, + status_code INTEGER NOT NULL DEFAULT 0, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 1, + next_retry_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id + ON webhook_deliveries(webhook_id); + CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_retry + ON webhook_deliveries(next_retry_at) + WHERE next_retry_at IS NOT NULL; + `); +} + +export function down(db) { + db.exec(` + DROP TABLE IF EXISTS webhook_deliveries; + DROP TABLE IF EXISTS webhooks; + `); +} diff --git a/backend/src/index.js b/backend/src/index.js index 104c394f..4f193931 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -92,7 +92,6 @@ import { createOperatorBalanceJob } from './jobs/operatorBalanceJob.js'; import { createModerationService } from './moderation/moderationService.js'; import { createContentModerationMiddleware } from './middleware/contentModeration.js'; import createFaucetRoutes from './routes/faucet.js'; -import createWebhookRoutes from './routes/webhooks.js'; import createStatusRoutes from './routes/status.js'; const DEFAULT_PORT = 3001; @@ -734,6 +733,26 @@ export async function createApp(options = {}) { durableJobQueue.start(); } + // Event indexer: use Horizon SSE for near-instant indexing; fall back to 30s polling + if (!options.disableJobs && (rewardsContractId || campaignContractId)) { + const contractIds = [rewardsContractId, campaignContractId].filter(Boolean); + if (stellarConfig.horizonUrl) { + eventIndexer.startSse({ + contractIds, + horizonUrl: stellarConfig.horizonUrl, + allowHttp: !stellarConfig.horizonUrl.startsWith('https'), + }); + } else { + const indexerPollMs = 30_000; + for (const contractId of contractIds) { + setInterval(async () => { + const cursor = eventIndexer.getCursor(contractId); + await eventIndexer.poll(contractId, cursor); + }, indexerPollMs).unref?.(); + } + } + } + // #552 — Operator balance monitoring job const operatorBalanceJob = createOperatorBalanceJob({ db: dal.db, @@ -2189,6 +2208,85 @@ export async function createApp(options = {}) { return res.json(paginateItems(deliveries, req.query)); }); + app.get(`${prefix}/webhooks/:id/deliveries/:deliveryId`, rateLimiter, ...guard, (req, res) => { + const webhook = webhookRepository.getById(req.params.id); + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' }); + } + const delivery = webhookRepository.getDeliveryById(req.params.deliveryId); + if (!delivery || delivery.webhookId !== req.params.id) { + return res.status(404).json({ error: 'Delivery not found', code: 'DELIVERY_NOT_FOUND' }); + } + return res.json(delivery); + }); + + app.post( + `${prefix}/webhooks/:id/deliveries/:deliveryId/replay`, + rateLimiter, + idempotencyMiddleware, + ...guard, + async (req, res) => { + const webhook = webhookRepository.getById(req.params.id); + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' }); + } + const delivery = webhookRepository.getDeliveryById(req.params.deliveryId); + if (!delivery || delivery.webhookId !== req.params.id) { + return res + .status(404) + .json({ error: 'Delivery not found', code: 'DELIVERY_NOT_FOUND' }); + } + try { + await webhookService.deliverWebhook(webhook, { + type: delivery.event, + data: delivery.payload, + timestamp: new Date().toISOString(), + }); + return res.json({ replayed: true, webhookId: req.params.id, event: delivery.event }); + } catch (err) { + log.warn({ err, webhookId: req.params.id }, 'Webhook replay error'); + return res.status(502).json({ error: 'Replay delivery failed', code: 'REPLAY_FAILED' }); + } + }, + ); + + app.post(`${prefix}/webhooks/:id/test`, rateLimiter, ...guard, async (req, res) => { + const webhook = webhookRepository.getById(req.params.id); + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' }); + } + const eventType = req.body?.eventType || 'campaign.created'; + try { + await webhookService.deliverWebhook(webhook, { + type: eventType, + data: { test: true, timestamp: new Date().toISOString() }, + timestamp: new Date().toISOString(), + }); + return res.json({ sent: true, webhookId: req.params.id, eventType }); + } catch (err) { + log.warn({ err, webhookId: req.params.id }, 'Webhook test error'); + return res.status(502).json({ error: 'Test delivery failed', code: 'TEST_FAILED' }); + } + }); + + // POST /webhooks/verify — signature verification helper for consumers (no auth required) + app.post(`${prefix}/webhooks/verify`, rateLimiter, (req, res) => { + const { signature, secret, payload } = req.body ?? {}; + if (!signature || !secret || payload === undefined) { + return res.status(400).json({ + error: 'signature, secret, and payload are required', + code: 'VALIDATION_ERROR', + }); + } + try { + const payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload); + const valid = webhookService.verifySignature(signature, secret, payloadStr); + return res.json({ valid }); + } catch { + return res.json({ valid: false }); + } + }); + // Referral routes (Issue #350) app.post(`${prefix}/campaigns/:id/referrals`, rateLimiter, (req, res) => { const campaign = campaignRepository.getById(req.params.id); diff --git a/backend/src/jobs/eventIndexer.js b/backend/src/jobs/eventIndexer.js index 991cb9e9..372434c9 100644 --- a/backend/src/jobs/eventIndexer.js +++ b/backend/src/jobs/eventIndexer.js @@ -7,8 +7,11 @@ * - Prometheus metrics for monitoring * - Health status endpoint * - Projection handlers per event type + * - Horizon SSE streaming for near-instant indexing (falls back to polling) */ +import { Horizon } from '@stellar/stellar-sdk'; + export function createEventIndexer({ db, rpcPool, @@ -135,7 +138,55 @@ export function createEventIndexer({ }; } - return { processEvent, poll, getCursor, getHealth, getMetrics }; + function startSse({ contractIds, horizonUrl, allowHttp = false }) { + let stopped = false; + let closeStream = null; + let reconnectTimer = null; + + function connect() { + if (stopped) return; + + let server; + try { + server = new Horizon.Server(horizonUrl, { allowHttp }); + } catch (err) { + logger.error?.('eventIndexer:sse:init', err); + if (!stopped) reconnectTimer = setTimeout(connect, 10_000); + return; + } + + closeStream = server + .ledgers() + .cursor('now') + .stream({ + onmessage: async () => { + metrics.lagLedgers = 0; + for (const contractId of contractIds) { + const cursor = getCursor(contractId); + await poll(contractId, cursor); + } + }, + onerror: (err) => { + metrics.errorsTotal++; + logger.error?.('eventIndexer:sse:error', err); + if (!stopped) reconnectTimer = setTimeout(connect, 5_000); + }, + }); + + logger.info?.(`eventIndexer:sse started horizonUrl=${horizonUrl}`); + } + + function stop() { + stopped = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (typeof closeStream === 'function') closeStream(); + } + + connect(); + return { stop }; + } + + return { processEvent, poll, getCursor, getHealth, getMetrics, startSse }; } async function handleCreditEvent(event, db) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 83865ec2..8b0f920d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,6 +24,7 @@ const TransactionHistory = lazy(() => import('./TransactionHistory')); const EmbedCampaign = lazy(() => import('./pages/EmbedCampaign')); const PublicProfile = lazy(() => import('./pages/PublicProfile')); const UserProfile = lazy(() => import('./pages/UserProfile')); +const WebhookManagement = lazy(() => import('./pages/WebhookManagement')); import { applyTheme, getPreferredTheme, THEME_STORAGE_KEY } from './theme'; import { getRuntimeConfig, initializeRuntimeConfig, setRuntimeStellarNetwork } from './config'; import { @@ -315,6 +316,18 @@ export default function App() { } /> + + + + } + /> = 200 && code < 300; + const style = { + display: 'inline-block', + padding: '2px 8px', + borderRadius: 4, + fontSize: 12, + fontWeight: 600, + background: ok ? '#d1fae5' : code === 0 ? '#fee2e2' : '#fef3c7', + color: ok ? '#065f46' : code === 0 ? '#991b1b' : '#92400e', + }; + return {code === 0 ? 'ERR' : code}; +} + +function DeliveryHistory({ webhookId, apiKey, onClose }) { + const [deliveries, setDeliveries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [replayingId, setReplayingId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const data = await apiClient.listWebhookDeliveries(webhookId, apiKey, { limit: 50 }); + const items = Array.isArray(data) ? data : (data.data ?? data.items ?? []); + setDeliveries(items); + } catch (err) { + setError(err.message || 'Failed to load deliveries'); + } finally { + setLoading(false); + } + }, [webhookId, apiKey]); + + useEffect(() => { + load(); + }, [load]); + + async function handleReplay(delivery) { + setReplayingId(delivery.id); + try { + await apiClient.replayDelivery(webhookId, delivery.id, apiKey); + await load(); + } catch (err) { + alert(`Replay failed: ${err.message}`); + } finally { + setReplayingId(null); + } + } + + return ( +
+
+
+

Delivery History

+ +
+ + {loading &&

Loading…

} + {error &&

{error}

} + {!loading && !error && deliveries.length === 0 && ( +

No deliveries yet.

+ )} + {!loading && deliveries.length > 0 && ( + + + + + + + + + + + + + {deliveries.map((d) => ( + + + + + + + + + ))} + +
EventStatusAttemptsErrorTime
{d.event} + + {d.attempts} + {d.error || '—'} + + {formatDate(d.createdAt)} + + {(d.statusCode === 0 || d.statusCode >= 400) && ( + + )} +
+ )} +
+
+ ); +} + +function WebhookForm({ initial, onSave, onCancel }) { + const [url, setUrl] = useState(initial?.url || ''); + const [events, setEvents] = useState(initial?.events || []); + const [description, setDescription] = useState(initial?.description || ''); + const [active, setActive] = useState(initial?.active !== false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + function toggleEvent(evt) { + setEvents((prev) => + prev.includes(evt) ? prev.filter((e) => e !== evt) : [...prev, evt], + ); + } + + async function handleSubmit(e) { + e.preventDefault(); + if (!url.trim()) return setError('URL is required'); + if (events.length === 0) return setError('Select at least one event'); + setSaving(true); + setError(''); + try { + await onSave({ url: url.trim(), events, description: description.trim(), active }); + } catch (err) { + setError(err.message || 'Save failed'); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/webhook" + required + style={{ width: '100%', padding: '6px 10px', borderRadius: 4, border: '1px solid var(--color-border, #d1d5db)', boxSizing: 'border-box' }} + /> +
+
+ + setDescription(e.target.value)} + placeholder="Optional description" + style={{ width: '100%', padding: '6px 10px', borderRadius: 4, border: '1px solid var(--color-border, #d1d5db)', boxSizing: 'border-box' }} + /> +
+
+ +
+ {SUPPORTED_EVENTS.map((evt) => ( + + ))} +
+
+ {initial && ( + + )} + {error &&

{error}

} +
+ + +
+
+ ); +} + +export default function WebhookManagement() { + const [apiKey, setApiKey] = useState(() => sessionStorage.getItem('trivela_api_key') || ''); + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [editingId, setEditingId] = useState(null); + const [newSecret, setNewSecret] = useState(null); + const [historyWebhookId, setHistoryWebhookId] = useState(null); + const [testingId, setTestingId] = useState(null); + const [testEvent, setTestEvent] = useState('campaign.created'); + + function saveApiKey(key) { + setApiKey(key); + sessionStorage.setItem('trivela_api_key', key); + } + + const load = useCallback(async () => { + if (!apiKey) return; + setLoading(true); + setError(''); + try { + const data = await apiClient.listWebhooks(apiKey); + const items = Array.isArray(data) ? data : (data.data ?? data.items ?? []); + setWebhooks(items); + } catch (err) { + setError(err.message || 'Failed to load webhooks'); + setWebhooks([]); + } finally { + setLoading(false); + } + }, [apiKey]); + + useEffect(() => { + load(); + }, [load]); + + async function handleCreate(data) { + const created = await apiClient.createWebhook(data, apiKey); + setNewSecret({ id: created.id, secret: created.secret }); + setShowCreate(false); + await load(); + } + + async function handleUpdate(id, data) { + await apiClient.updateWebhook(id, data, apiKey); + setEditingId(null); + await load(); + } + + async function handleDelete(id) { + if (!confirm('Delete this webhook?')) return; + await apiClient.deleteWebhook(id, apiKey); + await load(); + } + + async function handleTest(id) { + setTestingId(id); + try { + await apiClient.testWebhook(id, testEvent, apiKey); + alert('Test event sent successfully.'); + } catch (err) { + alert(`Test failed: ${err.message}`); + } finally { + setTestingId(null); + } + } + + return ( +
+

Webhook Management

+ +
+ +
+ saveApiKey(e.target.value)} + placeholder="Enter your API key" + style={{ flex: 1, padding: '6px 10px', borderRadius: 4, border: '1px solid var(--color-border, #d1d5db)' }} + /> + +
+
+ + {newSecret && ( +
+ Webhook created. Copy your signing secret now — it won't be shown again. +
+ {newSecret.secret} +
+ +
+ )} + + {error &&

{error}

} + + {!showCreate && ( + + )} + + {showCreate && ( +
+

New Webhook

+ setShowCreate(false)} /> +
+ )} + + {loading &&

Loading webhooks…

} + {!loading && apiKey && webhooks.length === 0 && !error && ( +

No webhooks registered yet.

+ )} + +
+ {webhooks.map((wh) => ( +
+ {editingId === wh.id ? ( + <> +

Edit Webhook

+ handleUpdate(wh.id, data)} + onCancel={() => setEditingId(null)} + /> + + ) : ( + <> +
+
+
+ {wh.url} +
+ {wh.description && ( +
+ {wh.description} +
+ )} +
+ + {wh.active ? 'active' : 'inactive'} + + {(wh.events || []).map((evt) => ( + + {evt} + + ))} +
+
+ Created {formatDate(wh.createdAt)} +
+
+
+ + + + + +
+
+ + )} +
+ ))} +
+ + {historyWebhookId && ( + setHistoryWebhookId(null)} + /> + )} +
+ ); +}