399 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|