Files
memby/admin-ui/src/pages/Integrations.tsx
T
2026-08-14 09:40:03 +12:00

399 lines
12 KiB
TypeScript

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<IntegrationsResponse>('/admin/api/integrations', {
pollMs: 60_000,
});
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState<Integration | null>(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<IntegrationsResponse>('/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 (
<>
<PageHead
title="Integrations"
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
actions={
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
Add a webhook
</Button>
}
/>
<Banner message={error} />
{(data?.dropped ?? 0) > 0 ? (
<Note tone="warn">
{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.
</Note>
) : null}
{loading ? (
<Loading />
) : integrations.length === 0 && !draft ? (
<Card title="Nothing configured" icon="plug" tone="note">
<Empty>
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.
</Empty>
</Card>
) : (
integrations.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
catalogue={catalogue}
busy={busy}
onEdit={() => edit(integration)}
onTest={() => void test(integration)}
onRemove={() => setConfirming(integration)}
/>
))
)}
{draft ? (
<DraftCard
draft={draft}
catalogue={catalogue}
busy={busy === 'save'}
onChange={setDraft}
onSave={() => void save()}
onCancel={() => setDraft(null)}
/>
) : null}
{confirming ? (
<Confirm
title={`Remove ${confirming.name}?`}
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
confirmLabel="Remove"
destructive
busy={busy === 'remove'}
onConfirm={() => 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 (
<Card
title={integration.name}
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
icon="plug"
tone={integration.enabled ? 'ok' : 'warn'}
actions={
<>
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
{health.deliveries > 0 ? (
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
) : (
<Tag>never used</Tag>
)}
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
Test
</Button>
<Button size="sm" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
</>
}
>
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Events sent</b>
<p>
{selected.length === 0
? 'None selected this destination is configured but will never post anything.'
: selected
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
.join(', ')}
</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Last delivered</b>
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
</div>
<div className="list-actions">
{health.deliveries > 0 ? (
<span className="quiet">
{num(health.deliveries)} attempts, {num(health.failures)} failed
</span>
) : null}
</div>
</div>
{health.lastFailure ? (
<div className="list-item">
<div className="list-body">
<b>Last failure</b>
<p>
{when(health.lastFailure)}
{health.lastError ? ` — ${health.lastError}` : ''}
</p>
</div>
</div>
) : null}
</div>
{integration.deliveries.length > 0 ? (
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Attempted</th>
<th>Event</th>
<th>Result</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{integration.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
{ago(delivery.attemptedAt)}
</td>
<td className="muted">{delivery.eventType}</td>
<td>
{delivery.success ? (
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
) : (
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
)}
</td>
<td className="num muted">{duration(delivery.durationMs)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
) : (
<TableWrap>
<table>
<tbody>
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
</tbody>
</table>
</TableWrap>
)}
</Card>
);
}
function DraftCard({
draft,
catalogue,
busy,
onChange,
onSave,
onCancel,
}: {
draft: Draft;
catalogue: IntegrationEventOption[];
busy: boolean;
onChange: (next: Draft) => void;
onSave: () => void;
onCancel: () => void;
}) {
const groups = [...new Set(catalogue.map((entry) => entry.group))];
const toggleEvent = (type: string, on: boolean) =>
onChange({
...draft,
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
});
return (
<Card
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
icon="plug"
tone="info"
footer={
<>
<Button variant="primary" busy={busy} onClick={onSave}>
{draft.id ? 'Save' : 'Add'}
</Button>
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<span className="spacer" />
{draft.events.length === 0 ? (
<span className="quiet">Nothing selected — this destination would never post.</span>
) : (
<span className="quiet">{draft.events.length} events selected</span>
)}
</>
}
>
<div className="fields">
<Field label="Name" hint="What this destination is called in the console.">
<input
type="text"
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</Field>
<Field
label="Webhook address"
hint={
draft.id
? 'Leave blank to keep the address already saved it is a credential and is never sent back to this page.'
: 'Discord channel settings Integrations Webhooks New Webhook Copy Webhook URL.'
}
>
<input
type="url"
value={draft.url}
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
onChange={(event) => onChange({ ...draft, url: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Enabled"
hint="Off keeps the configuration and stops the posts."
checked={draft.enabled}
onChange={(next) => onChange({ ...draft, enabled: next })}
/>
{groups.map((group) => (
<div key={group}>
<div className="card-head" style={undefined}>
<div className="card-head-text">
<h2>{group}</h2>
</div>
</div>
{catalogue
.filter((entry) => entry.group === group)
.map((entry) => (
<Toggle
key={entry.type}
label={entry.label}
hint={entry.description}
checked={draft.events.includes(entry.type)}
onChange={(on) => toggleEvent(entry.type, on)}
/>
))}
</div>
))}
</Card>
);
}