mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
2faa15a0a3
commit
99cb17c620
8 changed files with 980 additions and 305 deletions
6
.changeset/afraid-readers-lick.md
Normal file
6
.changeset/afraid-readers-lick.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
Add ability to edit and test webhook integrations
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
422
packages/app/src/components/TeamSettings/WebhookForm.tsx
Normal file
422
packages/app/src/components/TeamSettings/WebhookForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue