import { useState } from 'react'; import { api } from '../api/client'; import { useAction, useQuery } from '../lib/hooks'; import { useToast } from '../lib/toast'; import { ago, duration, num, when } from '../lib/format'; import { Banner, Button, Card, Confirm, Empty, EmptyRow, Field, Loading, Note, PageHead, TableWrap, Tag, Toggle, } from '../components/ui'; import type { Integration, IntegrationEventOption, IntegrationsResponse } from '../api/types'; /* Integrations: administrative events going out to somewhere else. * * The whole page is written around one property of the backend, and it is worth stating * because the form would otherwise look careless: the webhook address is never returned. * It is the credential — anybody holding it can post into the channel — so the gateway * sends back only whether one is set and the channel id from the middle of it. That is why * the address field on an existing integration is blank with a placeholder saying so, and * why saving with it blank leaves the stored one alone. */ interface Draft { id: string; name: string; url: string; enabled: boolean; events: string[]; } const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] }; export function IntegrationsPage() { const { wrap, show } = useToast(); const { busy, run } = useAction(); const { data, error, loading, reload } = useQuery('/admin/api/integrations', { pollMs: 60_000, }); const [draft, setDraft] = useState(null); const [confirming, setConfirming] = useState(null); const catalogue = data?.catalogue ?? []; const integrations = data?.integrations ?? []; const edit = (integration: Integration) => setDraft({ id: integration.id, name: integration.name, url: '', enabled: integration.enabled, events: integration.events ?? [], }); const save = () => run('save', async () => { if (!draft) return; const saved = await wrap( () => api.post('/admin/api/integrations', draft), draft.id ? 'Integration saved.' : 'Integration added.', ); if (saved) { setDraft(null); await reload(); } }); const remove = (integration: Integration) => run('remove', async () => { await wrap( () => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`), `${integration.name} removed.`, ); setConfirming(null); await reload(); }); const test = (integration: Integration) => run(`test:${integration.id}`, async () => { const result = await wrap(() => api.post<{ ok: boolean; message: string }>( `/admin/api/integrations/${encodeURIComponent(integration.id)}/test`, ), ); // The test route answers 200 whether or not the webhook accepted it, because the // *request* succeeded — so the verdict is in the body, and reporting it is this // page's job rather than the transport's. if (result) show(result.message, result.ok ? 'ok' : 'bad'); await reload(); }); return ( <> setDraft(NEW_DRAFT)}> Add a webhook } /> {(data?.dropped ?? 0) > 0 ? ( {num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up. ) : null} {loading ? ( ) : integrations.length === 0 && !draft ? ( No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here. ) : ( integrations.map((integration) => ( edit(integration)} onTest={() => void test(integration)} onRemove={() => setConfirming(integration)} /> )) )} {draft ? ( void save()} onCancel={() => setDraft(null)} /> ) : null} {confirming ? ( void remove(confirming)} onCancel={() => setConfirming(null)} /> ) : null} ); } function IntegrationCard({ integration, catalogue, busy, onEdit, onTest, onRemove, }: { integration: Integration; catalogue: IntegrationEventOption[]; busy: string | null; onEdit: () => void; onTest: () => void; onRemove: () => void; }) { const health = integration.health; // "Is it working" is answered by the *last* attempt, not by a failure count: a webhook // that failed once an hour ago and has worked since is healthy. const healthy = !health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure); const selected = integration.events ?? []; return ( {integration.enabled ? on : off} {health.deliveries > 0 ? ( {healthy ? 'delivering' : 'failing'} ) : ( never used )} {draft.events.length === 0 ? ( Nothing selected — this destination would never post. ) : ( {draft.events.length} events selected )} } >
onChange({ ...draft, name: event.target.value })} /> onChange({ ...draft, url: event.target.value })} />
onChange({ ...draft, enabled: next })} /> {groups.map((group) => (

{group}

{catalogue .filter((entry) => entry.group === group) .map((entry) => ( toggleEvent(entry.type, on)} /> ))}
))}
); }