Webhooks: Send test on a saved webhook #54

Merged
jcoffey-dev merged 1 commits from feature/webhook-send-test into main 2026-09-29 01:06:56 +00:00
4 changed files with 134 additions and 1 deletions

No files matched your search

+6 -1
View File
@@ -18,6 +18,7 @@ import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
import { SpamSettingsPage } from '@/features/spamLevel/SpamSettingsPage';
import { WebhookTestCard } from '@/features/events/WebhookTestCard';
import { WebhooksPage } from '@/features/events/WebhooksPage';
import { ListenersPage } from '@/features/ports/ListenersPage';
import { MetricsPage } from '@/features/metricsConnect/MetricsPage';
@@ -311,7 +312,11 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
const page = canUpdate ? (
<DynamicForm viewName={viewName} objectId={id} />
<DynamicForm
viewName={viewName}
objectId={id}
intro={resolved.objectName === 'x:WebHook' ? <WebhookTestCard webhookId={id} /> : undefined}
/>
) : (
<DynamicViewPage viewName={viewName} objectId={id} />
);
+79
View File
@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: "Send test" on a saved webhook (settings-reorg, Webhooks). The
* server sends one sample event, of type webhook.test, the way it sends a
* real batch, and says what came back (POST /api/webhook/test).
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Loader2, Send, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/services/api';
import { describeTestAnswer, type WebhookTestAnswer } from './webhookTest';
export function WebhookTestCard({ webhookId }: { webhookId: string }) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [answer, setAnswer] = useState<WebhookTestAnswer | null>(null);
const send = async () => {
setBusy(true);
setAnswer(null);
try {
const res = await apiFetch('/api/webhook/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhookId }),
});
if (!res.ok) {
const problem = (await res.json().catch(() => ({}))) as { detail?: string; title?: string };
throw new Error(problem.detail ?? problem.title ?? `The test failed (${res.status}).`);
}
setAnswer((await res.json()) as WebhookTestAnswer);
} catch (e) {
setAnswer({ sent: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
return (
<div className="mx-auto max-w-4xl space-y-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center gap-3">
<Send className="h-5 w-5 text-primary" />
<div className="flex-1">
<p className="font-medium">{t('webhookTest.title', 'Send a test event')}</p>
<p className="text-sm text-muted-foreground">
{t(
'webhookTest.body',
'One sample event of type webhook.test, sent with the saved settings, even while the webhook is off. Save changes first to test them.',
)}
</p>
</div>
<Button type="button" variant="outline" onClick={() => void send()} disabled={busy}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{t('webhookTest.send', 'Send test')}
</Button>
</div>
{answer && (
<p
role="status"
className={`flex items-start gap-2 text-sm ${answer.sent ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
>
{answer.sent ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
)}
{describeTestAnswer(answer)}
</p>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { describeTestAnswer } from './webhookTest';
describe('describeTestAnswer', () => {
it('says what came back', () => {
expect(describeTestAnswer({ sent: true, status: 204, ms: 84 })).toBe(
'Delivered: the receiver answered 204 in 84 ms.',
);
expect(describeTestAnswer({ sent: false, status: 403 })).toBe(
'Not accepted: the receiver answered 403. Check the sign-in details or signature key.',
);
expect(describeTestAnswer({ sent: false, error: 'connection refused' })).toBe('Not delivered: connection refused');
});
});
+29
View File
@@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** inbuxa: what POST /api/webhook/test answers, and that answer in words. */
export interface WebhookTestAnswer {
sent: boolean;
status?: number;
ms?: number;
error?: string;
}
/** The answer as a sentence. */
export function describeTestAnswer(a: WebhookTestAnswer): string {
if (a.sent) return `Delivered: the receiver answered ${a.status}${a.ms !== undefined ? ` in ${a.ms} ms` : ''}.`;
if (a.status !== undefined) {
const hint =
a.status === 401 || a.status === 403
? ' Check the sign-in details or signature key.'
: a.status === 404
? ' Check the URL.'
: '';
return `Not accepted: the receiver answered ${a.status}.${hint}`;
}
return `Not delivered: ${a.error ?? 'no answer'}`;
}