Webhook UX Improvements (#1323)

Adds the ability to edit existing webhooks and also test them

<img width="1884" height="1674" alt="Screenshot 2025-11-04 at 8 31 00 AM" src="https://github.com/user-attachments/assets/5f220ec4-c5ab-4ec7-89b9-cf39c215b87b" />
<img width="1922" height="590" alt="Screenshot 2025-11-04 at 8 52 39 AM" src="https://github.com/user-attachments/assets/5238df2c-90b7-465a-a029-5392c45a1e1a" />

Fixes HDX-2672

Closes https://github.com/hyperdxio/hyperdx/issues/1069
This commit is contained in:
Brandon Pereira 2025-11-07 07:45:50 -07:00 committed by GitHub
parent 2faa15a0a3
commit 99cb17c620
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 980 additions and 305 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/app": patch
---
Add ability to edit and test webhook integrations

View file

@ -402,4 +402,257 @@ describe('webhooks router', () => {
expect(response.body[0].errors).toBeDefined();
});
});
describe('PUT /:id - update webhook', () => {
it('updates an existing webhook', async () => {
const { agent, team } = await getLoggedInAgent(server);
// Create test webhook
const webhook = await Webhook.create({
...MOCK_WEBHOOK,
team: team._id,
});
const updatedData = {
name: 'Updated Webhook Name',
service: WebhookService.Slack,
url: 'https://hooks.slack.com/services/T11111111/B11111111/YYYYYYYYYYYYYYYYYYYYYYYY',
description: 'Updated description',
queryParams: { param2: 'value2' },
headers: { 'X-Updated-Header': 'Updated Value' },
body: '{"text": "Updated message"}',
};
const response = await agent
.put(`/webhooks/${webhook._id}`)
.send(updatedData)
.expect(200);
expect(response.body.data).toMatchObject({
name: updatedData.name,
service: updatedData.service,
url: updatedData.url,
description: updatedData.description,
});
// Verify webhook was updated in database
const updatedWebhook = await Webhook.findById(webhook._id);
expect(updatedWebhook).toMatchObject({
name: updatedData.name,
url: updatedData.url,
description: updatedData.description,
});
});
it('returns 404 when webhook does not exist', async () => {
const { agent } = await getLoggedInAgent(server);
const nonExistentId = new Types.ObjectId().toString();
const response = await agent
.put(`/webhooks/${nonExistentId}`)
.send(MOCK_WEBHOOK)
.expect(404);
expect(response.body.message).toBe('Webhook not found');
});
it('returns 400 when trying to update to a URL that already exists', async () => {
const { agent, team } = await getLoggedInAgent(server);
// Create two webhooks
await Webhook.create({
...MOCK_WEBHOOK,
name: 'Webhook Two',
team: team._id,
});
const webhook2 = await Webhook.create({
...MOCK_WEBHOOK,
url: 'https://hooks.slack.com/services/T11111111/B11111111/YYYYYYYYYYYYYYYYYYYYYYYY',
team: team._id,
});
// Try to update webhook2 to use webhook1's URL
const response = await agent
.put(`/webhooks/${webhook2._id}`)
.send({
...MOCK_WEBHOOK,
name: 'Different Name',
})
.expect(400);
expect(response.body.message).toBe(
'A webhook with this service and URL already exists',
);
});
it('returns 400 when ID is invalid', async () => {
const { agent } = await getLoggedInAgent(server);
await agent.put('/webhooks/invalid-id').send(MOCK_WEBHOOK).expect(400);
});
it('updates webhook with valid headers', async () => {
const { agent, team } = await getLoggedInAgent(server);
const webhook = await Webhook.create({
...MOCK_WEBHOOK,
team: team._id,
});
const updatedHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer updated-token',
'X-New-Header': 'new-value',
};
const response = await agent
.put(`/webhooks/${webhook._id}`)
.send({
...MOCK_WEBHOOK,
headers: updatedHeaders,
})
.expect(200);
expect(response.body.data.headers).toMatchObject(updatedHeaders);
});
it('rejects update with invalid headers', async () => {
const { agent, team } = await getLoggedInAgent(server);
const webhook = await Webhook.create({
...MOCK_WEBHOOK,
team: team._id,
});
const response = await agent
.put(`/webhooks/${webhook._id}`)
.send({
...MOCK_WEBHOOK,
headers: {
'Invalid\nHeader': 'value',
},
})
.expect(400);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body[0].type).toBe('Body');
expect(response.body[0].errors).toBeDefined();
});
});
describe('POST /test - test webhook', () => {
it('successfully sends a test message to a Slack webhook', async () => {
const { agent } = await getLoggedInAgent(server);
// Note: This will actually attempt to send to the URL in a real test
// In a production test suite, you'd want to mock the fetch/slack client
const response = await agent.post('/webhooks/test').send({
service: WebhookService.Slack,
url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX',
body: '{"text": "Test message"}',
});
// The test will likely fail due to invalid URL, but we're testing the endpoint structure
// In a real implementation, you'd mock the slack client
expect([200, 500]).toContain(response.status);
});
it('successfully sends a test message to a generic webhook', async () => {
const { agent } = await getLoggedInAgent(server);
// Note: This will actually attempt to send to the URL
// In a production test suite, you'd want to mock the fetch call
const response = await agent.post('/webhooks/test').send({
service: WebhookService.Generic,
url: 'https://example.com/webhook',
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'test-value',
},
body: '{"message": "{{body}}"}',
});
// The test will likely fail due to network/URL, but we're testing the endpoint structure
expect([200, 500]).toContain(response.status);
});
it('returns 400 when service is missing', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/webhooks/test')
.send({
url: 'https://example.com/webhook',
})
.expect(400);
});
it('returns 400 when URL is missing', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/webhooks/test')
.send({
service: WebhookService.Generic,
})
.expect(400);
});
it('returns 400 when URL is invalid', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/webhooks/test')
.send({
service: WebhookService.Generic,
url: 'not-a-valid-url',
})
.expect(400);
});
it('returns 400 when service is invalid', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/webhooks/test')
.send({
service: 'INVALID_SERVICE',
url: 'https://example.com/webhook',
})
.expect(400);
});
it('accepts optional headers and body', async () => {
const { agent } = await getLoggedInAgent(server);
const response = await agent.post('/webhooks/test').send({
service: WebhookService.Generic,
url: 'https://example.com/webhook',
headers: {
Authorization: 'Bearer test-token',
},
body: '{"custom": "body"}',
});
// Network call will likely fail, but endpoint should accept the request
expect([200, 500]).toContain(response.status);
});
it('rejects invalid headers in test request', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/webhooks/test')
.send({
service: WebhookService.Generic,
url: 'https://example.com/webhook',
headers: {
'Invalid\nHeader': 'value',
},
})
.expect(400);
});
});
});

View file

@ -1,9 +1,15 @@
import express from 'express';
import { ObjectId } from 'mongodb';
import mongoose from 'mongoose';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
import { AlertState } from '@/models/alert';
import Webhook, { WebhookService } from '@/models/webhook';
import {
handleSendGenericWebhook,
handleSendSlackWebhook,
} from '@/tasks/checkAlerts/template';
const router = express.Router();
@ -102,6 +108,84 @@ router.post(
},
);
router.put(
'/:id',
validateRequest({
params: z.object({
id: z.string().refine(val => {
return mongoose.Types.ObjectId.isValid(val);
}),
}),
body: z.object({
body: z.string().optional(),
description: z.string().optional(),
headers: z
.record(httpHeaderNameValidator, httpHeaderValueValidator)
.optional(),
name: z.string(),
queryParams: z.record(z.string()).optional(),
service: z.nativeEnum(WebhookService),
url: z.string().url(),
}),
}),
async (req, res, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
return res.sendStatus(403);
}
const { name, service, url, description, queryParams, headers, body } =
req.body;
const { id } = req.params;
// Check if webhook exists and belongs to team
const existingWebhook = await Webhook.findOne({
_id: id,
team: teamId,
});
if (!existingWebhook) {
return res.status(404).json({
message: 'Webhook not found',
});
}
// Check if another webhook with same service and url already exists (excluding current webhook)
const duplicateWebhook = await Webhook.findOne({
team: teamId,
service,
url,
_id: { $ne: id },
});
if (duplicateWebhook) {
return res.status(400).json({
message: 'A webhook with this service and URL already exists',
});
}
// Update webhook
const updatedWebhook = await Webhook.findOneAndUpdate(
{ _id: id, team: teamId },
{
name,
service,
url,
description,
queryParams,
headers,
body,
},
{ new: true, select: { __v: 0, team: 0 } },
);
res.json({
data: updatedWebhook,
});
} catch (err) {
next(err);
}
},
);
router.delete(
'/:id',
validateRequest({
@ -125,4 +209,69 @@ router.delete(
},
);
router.post(
'/test',
validateRequest({
body: z.object({
body: z.string().optional(),
headers: z
.record(httpHeaderNameValidator, httpHeaderValueValidator)
.optional(),
queryParams: z.record(z.string()).optional(),
service: z.nativeEnum(WebhookService),
url: z.string().url(),
}),
}),
async (req, res, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
return res.sendStatus(403);
}
const { service, url, queryParams, headers, body } = req.body;
// Create a temporary webhook object for testing
const testWebhook = new Webhook({
team: new ObjectId(teamId),
service,
url,
queryParams: queryParams,
headers: headers,
body,
});
// Send test message
const testMessage = {
hdxLink: 'https://hyperdx.io',
title: 'Test Webhook from HyperDX',
body: 'This is a test message to verify your webhook configuration is working correctly.',
startTime: Date.now(),
endTime: Date.now(),
state: AlertState.INSUFFICIENT_DATA,
eventId: 'test-event-id',
};
if (service === WebhookService.Slack) {
await handleSendSlackWebhook(testWebhook, testMessage);
} else if (
service === WebhookService.Generic ||
service === WebhookService.IncidentIO
) {
await handleSendGenericWebhook(testWebhook, testMessage);
} else {
return res.status(400).json({
message: 'Unsupported webhook service type',
});
}
res.json({
message: 'Test webhook sent successfully',
});
} catch (err) {
next(err);
}
},
);
export default router;

View file

@ -4,22 +4,9 @@ import { HTTPError } from 'ky';
import { Button as BSButton, Modal as BSModal } from 'react-bootstrap';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { SubmitHandler, useForm } from 'react-hook-form';
import type { ZodIssue } from 'zod';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { linter } from '@codemirror/lint';
import { EditorView } from '@codemirror/view';
import { DEFAULT_METADATA_MAX_ROWS_TO_READ } from '@hyperdx/common-utils/dist/core/metadata';
import { SourceKind, WebhookService } from '@hyperdx/common-utils/dist/types';
import {
AlertState,
SourceKind,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import {
isValidSlackUrl,
isValidUrl,
} from '@hyperdx/common-utils/dist/validation';
import {
Alert,
Badge,
Box,
Button,
@ -32,7 +19,6 @@ import {
InputLabel,
Loader,
Modal as MModal,
Radio,
Stack,
Table,
Text,
@ -41,8 +27,7 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { UseQueryResult } from '@tanstack/react-query';
import CodeMirror, { placeholder } from '@uiw/react-codemirror';
import { IconPencil } from '@tabler/icons-react';
import { ConnectionForm } from '@/components/ConnectionForm';
import SelectControlled from '@/components/SelectControlled';
@ -50,32 +35,16 @@ import { TableSourceForm } from '@/components/SourceForm';
import { IS_LOCAL_MODE } from '@/config';
import { PageHeader } from './components/PageHeader';
import { WebhookForm } from './components/TeamSettings/WebhookForm';
import api from './api';
import { useConnections } from './connection';
import { DEFAULT_QUERY_TIMEOUT, DEFAULT_SEARCH_ROW_LIMIT } from './defaults';
import { withAppNav } from './layout';
import { useSources } from './source';
import type { Webhook } from './types';
import { useConfirm } from './useConfirm';
import { capitalizeFirstLetter } from './utils';
const DEFAULT_GENERIC_WEBHOOK_BODY = [
'{{title}}',
'{{body}}',
'{{link}}',
'{{state}}',
'{{startTime}}',
'{{endTime}}',
'{{eventId}}',
];
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');
const jsonLinterWithEmptyCheck = () => (editorView: EditorView) => {
const text = editorView.state.doc.toString().trim();
if (text === '') return [];
return jsonParseLinter()(editorView);
};
function InviteTeamMemberForm({
isSubmitting,
onSubmit,
@ -699,260 +668,6 @@ function TeamMembersSection() {
);
}
type WebhookForm = {
name: string;
url: string;
service: string;
description?: string;
body?: string;
headers?: string;
};
export function CreateWebhookForm({
onClose,
onSuccess,
}: {
onClose: VoidFunction;
onSuccess: (webhookId?: string) => void;
}) {
const saveWebhook = api.useSaveWebhook();
const form = useForm<WebhookForm>({
defaultValues: {
service: WebhookService.Slack,
},
});
const onSubmit: SubmitHandler<WebhookForm> = async values => {
const { service, name, url, description, body, headers } = values;
try {
// Parse headers JSON if provided (API will validate the content)
let parsedHeaders: Record<string, string> | undefined;
if (headers && headers.trim()) {
try {
parsedHeaders = JSON.parse(headers);
} catch (parseError) {
const errorMessage =
parseError instanceof Error
? parseError.message
: 'Invalid JSON format';
notifications.show({
message: `Invalid JSON in headers: ${errorMessage}`,
color: 'red',
autoClose: 5000,
});
return;
}
}
let defaultBody = body;
if (!body) {
if (service === WebhookService.Generic) {
defaultBody = `{"text": "${DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE}"}`;
} else if (service === WebhookService.IncidentIO) {
defaultBody = `{
"title": "{{title}}",
"description": "{{body}}",
"deduplication_key": "{{eventId}}",
"status": "{{#if (eq state "${AlertState.ALERT}")}}firing{{else}}resolved{{/if}}",
"source_url": "{{link}}"
}`;
}
}
const response = await saveWebhook.mutateAsync({
service,
name,
url,
description: description || '',
body: defaultBody,
headers: parsedHeaders,
});
notifications.show({
color: 'green',
message: `Webhook created successfully`,
});
onSuccess(response.data?._id);
onClose();
} catch (e) {
console.error(e);
let message = 'Something went wrong. Please contact HyperDX team.';
if (e instanceof HTTPError) {
try {
const errorData = await e.response.json();
// Handle Zod validation errors from zod-express-middleware
// The library returns errors in format: { error: { issues: [...] } }
if (
errorData.error?.issues &&
Array.isArray(errorData.error.issues)
) {
// TODO: use a library to format Zod validation errors
// Format Zod validation errors
const validationErrors = errorData.error.issues
.map((issue: ZodIssue) => {
const path = issue.path.join('.');
return `${path}: ${issue.message}`;
})
.join(', ');
message = `Validation error: ${validationErrors}`;
} else if (errorData.message) {
message = errorData.message;
} else {
// Fallback: show the entire error object as JSON
message = JSON.stringify(errorData);
}
} catch (parseError) {
console.error('Failed to parse error response:', parseError);
// If parsing fails, use default message
}
}
notifications.show({
message,
color: 'red',
autoClose: 5000,
});
}
};
const service = form.watch('service');
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Stack mt="sm">
<Text>Create Webhook</Text>
<Radio.Group
label="Service Type"
required
value={service}
onChange={value => form.setValue('service', value)}
>
<Group mt="xs">
<Radio
value={WebhookService.Slack}
label="Slack"
{...form.register('service', { required: true })}
/>
<Radio
value={WebhookService.Generic}
label="Generic"
{...form.register('service', { required: true })}
/>
<Radio
value={WebhookService.IncidentIO}
label="Incident.io"
{...form.register('service', { required: true })}
/>
</Group>
</Radio.Group>
<TextInput
label="Webhook Name"
placeholder="Post to #dev-alerts"
required
error={form.formState.errors.name?.message}
{...form.register('name', { required: true })}
/>
<TextInput
label="Webhook URL"
placeholder={
service === WebhookService.Slack
? 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
: service === WebhookService.IncidentIO
? 'https://api.incident.io/v2/alert_events/http/ZZZZZZZZ?token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
: 'https://example.com/webhook'
}
type="url"
required
error={form.formState.errors.url?.message}
{...form.register('url', {
required: true,
validate: (value, formValues) =>
formValues.service === WebhookService.Slack
? isValidSlackUrl(value) ||
'URL must be valid and have a slack.com domain'
: isValidUrl(value) || 'URL must be valid',
})}
/>
<TextInput
label="Webhook Description (optional)"
placeholder="To be used for dev alerts"
error={form.formState.errors.description?.message}
{...form.register('description')}
/>
{service === WebhookService.Generic && [
<label className=".mantine-TextInput-label" key="1">
Webhook Headers (optional)
</label>,
<div className="mb-2" key="2">
<CodeMirror
height="100px"
extensions={[
json(),
linter(jsonLinterWithEmptyCheck()),
placeholder(
`{\n\t"Authorization": "Bearer token",\n\t"X-Custom-Header": "value"\n}`,
),
]}
theme="dark"
onChange={value => form.setValue('headers', value)}
/>
</div>,
<label className=".mantine-TextInput-label" key="3">
Webhook Body (optional)
</label>,
<div className="mb-2" key="4">
<CodeMirror
height="100px"
extensions={[
json(),
linter(jsonLinterWithEmptyCheck()),
placeholder(
`{\n\t"text": "${DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE}"\n}`,
),
]}
theme="dark"
onChange={value => form.setValue('body', value)}
/>
</div>,
<Alert
icon={<i className="bi bi-info-circle-fill text-slate-400" />}
key="5"
className="mb-4"
color="gray"
>
<span>
Currently the body supports the following message template
variables:
</span>
<br />
<span>
{DEFAULT_GENERIC_WEBHOOK_BODY.map((body, index) => (
<span key={index}>
<code>{body}</code>
{index < DEFAULT_GENERIC_WEBHOOK_BODY.length - 1 && ', '}
</span>
))}
</span>
</Alert>,
]}
<Group justify="space-between">
<Button
variant="outline"
type="submit"
loading={saveWebhook.isPending}
>
Add Webhook
</Button>
<Button variant="outline" color="gray" onClick={onClose} type="reset">
Cancel
</Button>
</Group>
</Stack>
</form>
);
}
function DeleteWebhookButton({
webhookId,
webhookName,
@ -1014,10 +729,11 @@ function IntegrationsSection() {
WebhookService.IncidentIO,
]);
const allWebhooks = useMemo(() => {
return Array.isArray(webhookData?.data) ? webhookData?.data : [];
const allWebhooks = useMemo<Webhook[]>(() => {
return Array.isArray(webhookData?.data) ? webhookData.data : [];
}, [webhookData]);
const [editedWebhookId, setEditedWebhookId] = useState<string | null>(null);
const [
isAddWebhookModalOpen,
{ open: openWebhookModal, close: closeWebhookModal },
@ -1033,9 +749,9 @@ function IntegrationsSection() {
<Text mb="xs">Webhooks</Text>
<Stack>
{allWebhooks.map((webhook: any) => (
{allWebhooks.map(webhook => (
<Fragment key={webhook._id}>
<Group justify="space-between">
<Group justify="space-between" align="flex-start">
<Stack gap={0}>
<Text size="sm">
{webhook.name} ({webhook.service})
@ -1049,12 +765,47 @@ function IntegrationsSection() {
</Text>
)}
</Stack>
<DeleteWebhookButton
webhookId={webhook._id}
webhookName={webhook.name}
onSuccess={refetchWebhooks}
/>
<Group gap="xs">
{editedWebhookId !== webhook._id && (
<>
<Button
variant="subtle"
color="gray.4"
onClick={() => setEditedWebhookId(webhook._id)}
size="compact-xs"
leftSection={<IconPencil size={14} />}
>
Edit
</Button>
<DeleteWebhookButton
webhookId={webhook._id}
webhookName={webhook.name}
onSuccess={refetchWebhooks}
/>
</>
)}
{editedWebhookId === webhook._id && (
<Button
variant="subtle"
color="gray.4"
onClick={() => setEditedWebhookId(null)}
size="compact-xs"
>
<i className="bi bi-x-lg me-2" /> Cancel
</Button>
)}
</Group>
</Group>
{editedWebhookId === webhook._id && (
<WebhookForm
webhook={webhook}
onClose={() => setEditedWebhookId(null)}
onSuccess={() => {
setEditedWebhookId(null);
refetchWebhooks();
}}
/>
)}
<Divider />
</Fragment>
))}
@ -1065,7 +816,7 @@ function IntegrationsSection() {
Add Webhook
</Button>
) : (
<CreateWebhookForm
<WebhookForm
onClose={closeWebhookModal}
onSuccess={() => {
refetchWebhooks();

View file

@ -330,6 +330,54 @@ const api = {
}).json(),
});
},
useUpdateWebhook() {
return useMutation<
any,
Error | HTTPError,
{
id: string;
service: string;
url: string;
name: string;
description: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}
>({
mutationFn: async ({
id,
service,
url,
name,
description,
queryParams,
headers,
body,
}: {
id: string;
service: string;
url: string;
name: string;
description: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}) =>
hdxServer(`webhooks/${id}`, {
method: 'PUT',
json: {
name,
service,
url,
description,
queryParams: queryParams || {},
headers: headers || {},
body,
},
}).json(),
});
},
useWebhooks(services: string[]) {
return useQuery<any, Error>({
queryKey: [...services],
@ -348,6 +396,43 @@ const api = {
}).json(),
});
},
useTestWebhook() {
return useMutation<
any,
Error | HTTPError,
{
service: string;
url: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}
>({
mutationFn: async ({
service,
url,
queryParams,
headers,
body,
}: {
service: string;
url: string;
queryParams?: Record<string, string>;
headers?: Record<string, string>;
body?: string;
}) =>
hdxServer(`webhooks/test`, {
method: 'POST',
json: {
service,
url,
queryParams: queryParams || {},
headers: headers || {},
body,
},
}).json(),
});
},
useRegisterPassword() {
return useMutation({
// @ts-ignore

View file

@ -11,7 +11,7 @@ import { useDisclosure } from '@mantine/hooks';
import api from '@/api';
import { CreateWebhookForm } from '../TeamPage';
import { WebhookForm } from '../components/TeamSettings/WebhookForm';
type Webhook = {
_id: string;
@ -92,7 +92,7 @@ const WebhookChannelForm = <T extends object>(
zIndex={9999}
size="lg"
>
<CreateWebhookForm onClose={close} onSuccess={handleWebhookCreated} />
<WebhookForm onClose={close} onSuccess={handleWebhookCreated} />
</Modal>
</div>
);

View file

@ -0,0 +1,422 @@
import { useEffect } from 'react';
import { HTTPError } from 'ky';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { ZodIssue } from 'zod';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { linter } from '@codemirror/lint';
import { AlertState, WebhookService } from '@hyperdx/common-utils/dist/types';
import { isValidSlackUrl } from '@hyperdx/common-utils/dist/validation';
import {
Alert,
Button,
Group,
Radio,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import ReactCodeMirror, {
EditorView,
placeholder,
} from '@uiw/react-codemirror';
import api from '@/api';
import { Webhook } from '@/types';
import { isValidUrl } from '@/utils';
const DEFAULT_GENERIC_WEBHOOK_BODY = [
'{{title}}',
'{{body}}',
'{{link}}',
'{{state}}',
'{{startTime}}',
'{{endTime}}',
'{{eventId}}',
];
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');
const jsonLinterWithEmptyCheck = () => (editorView: EditorView) => {
const text = editorView.state.doc.toString().trim();
if (text === '') return [];
return jsonParseLinter()(editorView);
};
type WebhookForm = {
name: string;
url: string;
service: string;
description?: string;
body?: string;
headers?: string;
};
export function WebhookForm({
webhook,
onClose,
onSuccess,
}: {
webhook?: Webhook;
onClose: VoidFunction;
onSuccess: (webhookId?: string) => void;
}) {
const saveWebhook = api.useSaveWebhook();
const updateWebhook = api.useUpdateWebhook();
const testWebhook = api.useTestWebhook();
const isEditing = webhook != null;
const form = useForm<WebhookForm>({
defaultValues: {
service: webhook?.service || WebhookService.Slack,
name: webhook?.name || '',
url: webhook?.url || '',
description: webhook?.description || '',
body: webhook?.body || '',
headers: webhook?.headers ? JSON.stringify(webhook.headers, null, 2) : '',
},
});
useEffect(() => {
if (webhook) {
form.reset(
{
service: webhook.service,
name: webhook.name,
url: webhook.url,
description: webhook.description,
body: webhook.body,
headers: webhook.headers
? JSON.stringify(webhook.headers, null, 2)
: '',
},
{},
);
}
}, [webhook, form]);
const handleTestWebhook = async (values: WebhookForm) => {
const { service, url, body, headers } = values;
// Parse headers if provided
let parsedHeaders: Record<string, string> | undefined;
if (headers && headers.trim()) {
try {
parsedHeaders = JSON.parse(headers);
} catch (parseError) {
const errorMessage =
parseError instanceof Error
? parseError.message
: 'Invalid JSON format';
notifications.show({
message: `Invalid JSON in headers: ${errorMessage}`,
color: 'red',
autoClose: 5000,
});
return;
}
}
let defaultBody = body;
if (!body) {
if (service === WebhookService.Generic) {
defaultBody = `{"text": "${DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE}"}`;
} else if (service === WebhookService.IncidentIO) {
defaultBody = `{
"title": "{{title}}",
"description": "{{body}}",
"deduplication_key": "{{eventId}}",
"status": "{{#if (eq state "${AlertState.ALERT}")}}firing{{else}}resolved{{/if}}",
"source_url": "{{link}}"
}`;
}
}
try {
await testWebhook.mutateAsync({
service,
url,
body: defaultBody,
headers: parsedHeaders,
});
notifications.show({
color: 'green',
message: 'Test webhook sent successfully',
});
} catch (e) {
console.error(e);
let message =
'Failed to send test webhook. Please check your webhook configuration.';
if (e instanceof HTTPError) {
try {
const errorData = await e.response.json();
if (errorData.message) {
message = errorData.message;
}
} catch (parseError) {
console.error('Failed to parse error response:', parseError);
}
}
notifications.show({
message,
color: 'red',
autoClose: 5000,
});
}
};
const onSubmit: SubmitHandler<WebhookForm> = async values => {
const { service, name, url, description, body, headers } = values;
try {
// Parse headers JSON if provided (API will validate the content)
let parsedHeaders: Record<string, string> | undefined;
if (headers && headers.trim()) {
try {
parsedHeaders = JSON.parse(headers);
} catch (parseError) {
const errorMessage =
parseError instanceof Error
? parseError.message
: 'Invalid JSON format';
notifications.show({
message: `Invalid JSON in headers: ${errorMessage}`,
color: 'red',
autoClose: 5000,
});
return;
}
}
let defaultBody = body;
if (!body) {
if (service === WebhookService.Generic) {
defaultBody = `{"text": "${DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE}"}`;
} else if (service === WebhookService.IncidentIO) {
defaultBody = `{
"title": "{{title}}",
"description": "{{body}}",
"deduplication_key": "{{eventId}}",
"status": "{{#if (eq state "${AlertState.ALERT}")}}firing{{else}}resolved{{/if}}",
"source_url": "{{link}}"
}`;
}
}
const webhookData = {
service,
name,
url,
description: description || '',
body: defaultBody,
headers: parsedHeaders,
};
const response = isEditing
? await updateWebhook.mutateAsync({
id: webhook._id,
...webhookData,
})
: await saveWebhook.mutateAsync(webhookData);
notifications.show({
color: 'green',
message: `Webhook ${isEditing ? 'updated' : 'created'} successfully`,
});
onSuccess(response.data?._id);
onClose();
} catch (e) {
console.error(e);
let message = 'Something went wrong. Please contact HyperDX team.';
if (e instanceof HTTPError) {
try {
const errorData = await e.response.json();
// Handle Zod validation errors from zod-express-middleware
// The library returns errors in format: { error: { issues: [...] } }
if (
errorData.error?.issues &&
Array.isArray(errorData.error.issues)
) {
// TODO: use a library to format Zod validation errors
// Format Zod validation errors
const validationErrors = errorData.error.issues
.map((issue: ZodIssue) => {
const path = issue.path.join('.');
return `${path}: ${issue.message}`;
})
.join(', ');
message = `Validation error: ${validationErrors}`;
} else if (errorData.message) {
message = errorData.message;
} else {
// Fallback: show the entire error object as JSON
message = JSON.stringify(errorData);
}
} catch (parseError) {
console.error('Failed to parse error response:', parseError);
// If parsing fails, use default message
}
}
notifications.show({
message,
color: 'red',
autoClose: 5000,
});
}
};
const service = form.watch('service');
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Stack mt="sm">
<Text>{isEditing ? 'Edit Webhook' : 'Create Webhook'}</Text>
<Radio.Group
label="Service Type"
required
value={service}
onChange={value => {
form.setValue('service', value);
}}
>
<Group mt="xs">
<Radio value={WebhookService.Slack} label="Slack" />
<Radio value={WebhookService.Generic} label="Generic" />
<Radio value={WebhookService.IncidentIO} label="Incident.io" />
</Group>
</Radio.Group>
<TextInput
label="Webhook Name"
placeholder="Post to #dev-alerts"
required
error={form.formState.errors.name?.message}
{...form.register('name', { required: true })}
/>
<TextInput
label="Webhook URL"
placeholder={
service === WebhookService.Slack
? 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
: service === WebhookService.IncidentIO
? 'https://api.incident.io/v2/alert_events/http/ZZZZZZZZ?token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
: 'https://example.com/webhook'
}
type="url"
required
error={form.formState.errors.url?.message}
{...form.register('url', {
required: true,
validate: (value, formValues) =>
formValues.service === WebhookService.Slack
? isValidSlackUrl(value) ||
'URL must be valid and have a slack.com domain'
: isValidUrl(value) || 'URL must be valid',
})}
/>
<TextInput
label="Webhook Description (optional)"
placeholder="To be used for dev alerts"
error={form.formState.errors.description?.message}
{...form.register('description')}
/>
{service === WebhookService.Generic && [
<label className=".mantine-TextInput-label" key="1">
Webhook Headers (optional)
</label>,
<div className="mb-2" key="2">
<Controller
name="headers"
control={form.control}
render={({ field }) => (
<ReactCodeMirror
height="100px"
extensions={[
json(),
linter(jsonLinterWithEmptyCheck()),
placeholder(
`{\n\t"Authorization": "Bearer token",\n\t"X-Custom-Header": "value"\n}`,
),
]}
theme="dark"
value={field.value}
onChange={value => field.onChange(value)}
/>
)}
/>
</div>,
<label className=".mantine-TextInput-label" key="3">
Webhook Body (optional)
</label>,
<div className="mb-2" key="4">
<Controller
name="body"
control={form.control}
render={({ field }) => (
<ReactCodeMirror
height="100px"
extensions={[
json(),
linter(jsonLinterWithEmptyCheck()),
placeholder(
`{\n\t"text": "${DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE}"\n}`,
),
]}
theme="dark"
value={field.value}
onChange={value => field.onChange(value)}
/>
)}
/>
</div>,
<Alert
icon={<i className="bi bi-info-circle-fill text-slate-400" />}
key="5"
className="mb-4"
color="gray"
>
<span>
Currently the body supports the following message template
variables:
</span>
<br />
<span>
{DEFAULT_GENERIC_WEBHOOK_BODY.map((body, index) => (
<span key={index}>
<code>{body}</code>
{index < DEFAULT_GENERIC_WEBHOOK_BODY.length - 1 && ', '}
</span>
))}
</span>
</Alert>,
]}
<Group justify="space-between">
<Group>
<Button
variant="outline"
type="submit"
loading={saveWebhook.isPending || updateWebhook.isPending}
>
{isEditing ? 'Update Webhook' : 'Add Webhook'}
</Button>
<Button
variant="outline"
color="blue"
onClick={form.handleSubmit(handleTestWebhook)}
loading={testWebhook.isPending}
type="button"
>
Test Webhook
</Button>
</Group>
<Button variant="outline" color="gray" onClick={onClose} type="reset">
Cancel
</Button>
</Group>
</Stack>
</form>
);
}

View file

@ -7,6 +7,7 @@ import {
Filter,
NumberFormat as _NumberFormat,
SavedSearchSchema,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
export type NumberFormat = _NumberFormat;
@ -249,10 +250,18 @@ export enum KubePhase {
Unknown = 5,
}
export enum WebhookService {
Slack = 'slack',
Generic = 'generic',
}
export type Webhook = {
_id: string;
name: string;
service: WebhookService;
url: string;
description?: string;
headers?: Record<string, string>;
queryParams?: Record<string, string>;
body?: string;
createdAt: string;
updatedAt: string;
};
export type NextApiConfigResponseData = {
apiKey: string;