Generic Webhooks v0 (#337)

[Generic Webhook Option for Alerts
](https://github.com/hyperdxio/hyperdx/issues/2#issue-1893833428
)

### DETAILS:

This PR enables the creation and use of generic webhooks alongside the existing slack webhooks. This allows users to configure arbitrary webhook consumers/destinations with custom payloads.

For now the lack of signage/security features means more complex webhooks that perform actions on alert will most likely be gated off due to their internal requirements, but this should unlock a variety of message-focused consumers alongside the existing slack implementation. Query parameter usage was built into the migration and logic, and can be enabled in a later version when security options make those more complex use cases (like caching) worthwhile. For the time being many consumers allow/mirror QP functionality in the body of the request, and otherwise building into the url manually achieves the same purpose.

This implementation assumes and is limited to POST requests only, which is the ideal sender behavior and has exceptionally large coverage, but optionality for GETs and PUTs can be added in later versions if they are desired.

Message templating is still quite limited while the more robust templating system is in development, and users should refer to their specific consumer documentation for implementation.

As a minor addition, with the added complexity beyond just single slack webhooks, optional descriptions were also added to the webhook model and displayed on the settings page.

### V1+ NEXT STEPS:
- security/signature functionality
- user facing webhook edit functionality
- functionality to send webhook tests during creation
- alignment with current in-progress alert templating
- user facing queryParam functionality (and/or url building for ease of use)

### VISUALS:

**TEAM SETTINGS UPDATE:**
![Screenshot 2024-03-07 at 3 16 11 PM](https://github.com/hyperdxio/hyperdx/assets/4743932/e0c3a60c-d9b6-4893-a8bb-81a4c6c829ea)

**GENERIC WEBHOOK CREATION:**
![Screenshot 2024-03-07 at 3 17 50 PM](https://github.com/hyperdxio/hyperdx/assets/4743932/ffc0945b-2dae-444b-9ef2-0e0d705c0c3d)

**ALERT CREATION UPDATE:**
![Screenshot 2024-03-07 at 3 18 27 PM](https://github.com/hyperdxio/hyperdx/assets/4743932/a948ef19-9978-4a6f-9beb-3ee49f660aac)
This commit is contained in:
CHP 2024-03-11 00:18:58 -04:00 committed by GitHub
parent d723a790ed
commit 0e365bfff2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1317 additions and 82 deletions

View file

@ -0,0 +1,7 @@
---
'@hyperdx/api': minor
'@hyperdx/app': minor
---
this change enables generic webhooks. no existing webhook behavior will be
impacted by this change.

View file

@ -3,6 +3,17 @@ import mongoose, { Schema } from 'mongoose';
export enum WebhookService {
Slack = 'slack',
Generic = 'generic',
}
interface MongooseMap extends Map<string, string> {
// https://mongoosejs.com/docs/api/map.html#MongooseMap.prototype.toJSON()
// Converts this map to a native JavaScript Map for JSON.stringify(). Set the flattenMaps option to convert this map to a POJO instead.
// doc.myMap.toJSON() instanceof Map; // true
// doc.myMap.toJSON({ flattenMaps: true }) instanceof Map; // false
toJSON: (options?: {
flattenMaps?: boolean;
}) => { [key: string]: any } | Map<string, any>;
}
export interface IWebhook {
@ -12,7 +23,13 @@ export interface IWebhook {
service: WebhookService;
team: ObjectId;
updatedAt: Date;
url: string;
url?: string;
description?: string;
// reminder to serialize/convert the Mongoose model instance to a plain javascript object when using
// to strip the additional properties that are related to the Mongoose internal representation -> webhook.headers.toJSON()
queryParams?: MongooseMap;
headers?: MongooseMap;
body?: MongooseMap;
}
const WebhookSchema = new Schema<IWebhook>(
@ -31,6 +48,25 @@ const WebhookSchema = new Schema<IWebhook>(
type: String,
required: false,
},
description: {
type: String,
required: false,
},
queryParams: {
type: Map,
of: String,
required: false,
},
headers: {
type: Map,
of: String,
required: false,
},
body: {
type: Map,
of: String,
required: false,
},
},
{ timestamps: true },
);

View file

@ -29,7 +29,8 @@ router.post('/', async (req, res, next) => {
if (teamId == null) {
return res.sendStatus(403);
}
const { name, service, url } = req.body;
const { name, service, url, description, queryParams, headers, body } =
req.body;
if (!service || !url || !name) return res.sendStatus(400);
const totalWebhooks = await Webhook.countDocuments({
team: teamId,
@ -45,7 +46,16 @@ router.post('/', async (req, res, next) => {
message: 'Webhook already exists',
});
}
const webhook = new Webhook({ team: teamId, service, url, name });
const webhook = new Webhook({
team: teamId,
service,
url,
name,
description,
queryParams,
headers,
body,
});
await webhook.save();
res.json({
data: webhook,

View file

@ -17,12 +17,15 @@ import Dashboard from '../../models/dashboard';
import LogView from '../../models/logView';
import Webhook from '../../models/webhook';
import * as slack from '../../utils/slack';
import * as checkAlert from '../checkAlerts';
import {
buildAlertMessageTemplateHdxLink,
buildAlertMessageTemplateTitle,
buildLogSearchLink,
doesExceedThreshold,
escapeJsonValues,
getDefaultExternalAction,
injectIntoPlaceholders,
processAlert,
renderAlertTemplate,
roundDownToXMinutes,
@ -57,6 +60,61 @@ describe('checkAlerts', () => {
).toBe('2023-03-17T22:55:00.000Z');
});
describe('injectIntoPlaceholders', () => {
const message = {
hdxLink: 'https://www.example.com/random-testing-url1234',
title: 'Alert for "All Events" - 776 lines found',
body: '145 lines found, expected less than 1 lines',
};
const valuesToInject = {
$HDX_ALERT_URL: message.hdxLink,
$HDX_ALERT_TITLE: message.title,
$HDX_ALERT_BODY: message.body,
};
it('should correctly inject message values into placeholders', () => {
const placeholderString =
'{"text":"$HDX_ALERT_URL | $HDX_ALERT_TITLE | $HDX_ALERT_BODY"}';
const result = injectIntoPlaceholders(placeholderString, valuesToInject);
const expectedObj = {
text: `${message.hdxLink} | ${message.title} | ${message.body}`,
};
const expected = JSON.stringify(expectedObj);
expect(result).toEqual(expected);
});
it('should retain invalid placeholders if no matching valid key', () => {
const placeholderString =
'{"text":"$HDX_ALERT_LINK | $HDX_ALERT_TITLE | $HDX_ALERT_BODY"}';
const result = injectIntoPlaceholders(placeholderString, valuesToInject);
const expectedObj = {
text: `$HDX_ALERT_LINK | ${message.title} | ${message.body}`,
};
const expected = JSON.stringify(expectedObj);
expect(result).toEqual(expected);
});
it('should escape JSON values correctly', () => {
const placeholderString = 'escapetest: $HDX_ALERT_BODY';
const valuesToInject = {
$HDX_ALERT_BODY: '{"key":"value\nnew line"}',
};
const expected = 'escapetest: {\\"key\\":\\"value\\nnew line\\"}';
const result = injectIntoPlaceholders(placeholderString, valuesToInject);
expect(result).toEqual(expected);
});
});
describe('escapeJsonValues', () => {
it('should escape special JSON characters', () => {
const input = '"Simple\nEscapeJson"\tTest\\';
const expected = '\\"Simple\\nEscapeJson\\"\\tTest\\\\';
const result = escapeJsonValues(input);
expect(result).toEqual(expected);
});
});
it('buildLogSearchLink', () => {
expect(
buildLogSearchLink({
@ -316,7 +374,7 @@ describe('checkAlerts', () => {
await server.stop();
});
it('LOG alert', async () => {
it('LOG alert - slack webhook', async () => {
jest
.spyOn(slack, 'postMessageToWebhook')
.mockResolvedValueOnce(null as any);
@ -444,7 +502,7 @@ describe('checkAlerts', () => {
);
});
it('CHART alert (logs table series)', async () => {
it('CHART alert (logs table series) - slack webhook', async () => {
jest
.spyOn(slack, 'postMessageToWebhook')
.mockResolvedValueOnce(null as any);
@ -609,7 +667,7 @@ describe('checkAlerts', () => {
jest.resetAllMocks();
});
it('CHART alert (metrics table series)', async () => {
it('CHART alert (metrics table series) - slack webhook', async () => {
const team = await createTeam({ name: 'My Team' });
const runId = Math.random().toString(); // dedup watch mode runs
@ -843,5 +901,520 @@ describe('checkAlerts', () => {
},
);
});
it('LOG alert - generic webhook', async () => {
jest.spyOn(checkAlert, 'handleSendGenericWebhook');
jest
.spyOn(clickhouse, 'checkAlert')
.mockResolvedValueOnce({
rows: 1,
data: [
{
data: '11',
group: 'HyperDX',
ts_bucket: 1700172600,
},
],
} as any)
// no logs found in the next window
.mockResolvedValueOnce({
rows: 0,
data: [],
} as any);
jest.spyOn(clickhouse, 'getLogBatch').mockResolvedValueOnce({
rows: 1,
data: [
{
timestamp: '2023-11-16T22:10:00.000Z',
severity_text: 'error',
body: 'Oh no! Something went wrong!',
},
],
} as any);
const fetchMock = jest.fn().mockResolvedValue({});
global.fetch = fetchMock;
const team = await createTeam({ name: 'My Team' });
const logView = await new LogView({
name: 'My Log View',
query: `level:error`,
team: team._id,
}).save();
const webhook = await new Webhook({
team: team._id,
service: 'generic',
url: 'https://webhook.site/123',
name: 'Generic Webhook',
description: 'generic webhook description',
body: { text: '$HDX_ALERT_URL | $HDX_ALERT_TITLE' },
headers: {
'Content-Type': 'application/json',
'X-HyperDX-Signature': 'XXXXX-XXXXX',
},
}).save();
const alert = await createAlert(team._id, {
source: 'LOG',
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
type: 'presence',
threshold: 10,
groupBy: 'span_name',
logViewId: logView._id.toString(),
});
const now = new Date('2023-11-16T22:12:00.000Z');
// shoud fetch 5m of logs
await processAlert(now, alert);
expect(alert.state).toBe('ALERT');
// skip since time diff is less than 1 window size
const later = new Date('2023-11-16T22:14:00.000Z');
await processAlert(later, alert);
// alert should still be in alert state
expect(alert.state).toBe('ALERT');
const nextWindow = new Date('2023-11-16T22:16:00.000Z');
await processAlert(nextWindow, alert);
// alert should be in ok state
expect(alert.state).toBe('OK');
// check alert history
const alertHistories = await AlertHistory.find({
alert: alert._id,
}).sort({
createdAt: 1,
});
expect(alertHistories.length).toBe(2);
expect(alertHistories[0].state).toBe('ALERT');
expect(alertHistories[0].counts).toBe(1);
expect(alertHistories[0].createdAt).toEqual(
new Date('2023-11-16T22:10:00.000Z'),
);
expect(alertHistories[1].state).toBe('OK');
expect(alertHistories[1].counts).toBe(0);
expect(alertHistories[1].createdAt).toEqual(
new Date('2023-11-16T22:15:00.000Z'),
);
// check if checkAlert query + webhook were triggered
expect(clickhouse.checkAlert).toHaveBeenNthCalledWith(1, {
endTime: new Date('2023-11-16T22:10:00.000Z'),
groupBy: alert.groupBy,
q: logView.query,
startTime: new Date('2023-11-16T22:05:00.000Z'),
tableVersion: team.logStreamTableVersion,
teamId: logView.team._id.toString(),
windowSizeInMins: 5,
});
// check if generic webhook was triggered, injected, and parsed, and sent correctly
expect(fetchMock).toHaveBeenCalledWith(
'https://webhook.site/123',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
text: `http://localhost:9090/search/${logView._id}?from=1700172600000&to=1700172900000&q=level%3Aerror+span_name%3A%22HyperDX%22 | Alert for "My Log View" - 11 lines found`,
}),
headers: {
'Content-Type': 'application/json',
'X-HyperDX-Signature': 'XXXXX-XXXXX',
},
}),
);
});
it('CHART alert (logs table series) - generic webhook', async () => {
jest.spyOn(checkAlert, 'handleSendGenericWebhook');
mockLogsPropertyTypeMappingsModel({
runId: 'string',
});
const fetchMock = jest.fn().mockResolvedValue({});
global.fetch = fetchMock;
const team = await createTeam({ name: 'My Team' });
const runId = Math.random().toString(); // dedup watch mode runs
const teamId = team._id.toString();
const now = new Date('2023-11-16T22:12:00.000Z');
// Send events in the last alert window 22:05 - 22:10
const eventMs = now.getTime() - ms('5m');
const buildEvent = generateBuildTeamEventFn(teamId, {
runId,
span_name: 'HyperDX',
type: LogType.Span,
level: 'error',
});
await clickhouse.bulkInsertLogStream([
buildEvent({
timestamp: eventMs,
end_timestamp: eventMs + 100,
}),
buildEvent({
timestamp: eventMs + 5,
end_timestamp: eventMs + 7,
}),
]);
const webhook = await new Webhook({
team: team._id,
service: 'generic',
url: 'https://webhook.site/123',
name: 'Generic Webhook',
description: 'generic webhook description',
body: { text: '$HDX_ALERT_URL | $HDX_ALERT_TITLE' },
headers: { 'Content-Type': 'application/json' },
}).save();
const dashboard = await new Dashboard({
name: 'My Dashboard',
team: team._id,
charts: [
{
id: '198hki',
name: 'Max Duration',
x: 0,
y: 0,
w: 6,
h: 3,
series: [
{
table: 'logs',
type: 'time',
aggFn: 'sum',
field: 'duration',
where: `level:error runId:${runId}`,
groupBy: ['span_name'],
},
{
table: 'logs',
type: 'time',
aggFn: 'min',
field: 'duration',
where: `level:error runId:${runId}`,
groupBy: ['span_name'],
},
],
seriesReturnType: 'column',
},
{
id: 'obil1',
name: 'Min Duratioin',
x: 6,
y: 0,
w: 6,
h: 3,
series: [
{
table: 'logs',
type: 'time',
aggFn: 'min',
field: 'duration',
where: '',
groupBy: [],
},
],
},
],
}).save();
const alert = await createAlert(team._id, {
source: 'CHART',
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
type: 'presence',
threshold: 10,
dashboardId: dashboard._id.toString(),
chartId: '198hki',
});
// should fetch 5m of logs
await processAlert(now, alert);
expect(alert.state).toBe('ALERT');
// skip since time diff is less than 1 window size
const later = new Date('2023-11-16T22:14:00.000Z');
await processAlert(later, alert);
// alert should still be in alert state
expect(alert.state).toBe('ALERT');
const nextWindow = new Date('2023-11-16T22:16:00.000Z');
await processAlert(nextWindow, alert);
// alert should be in ok state
expect(alert.state).toBe('OK');
// check alert history
const alertHistories = await AlertHistory.find({
alert: alert._id,
}).sort({
createdAt: 1,
});
expect(alertHistories.length).toBe(2);
const [history1, history2] = alertHistories;
expect(history1.state).toBe('ALERT');
expect(history1.counts).toBe(1);
expect(history1.createdAt).toEqual(new Date('2023-11-16T22:10:00.000Z'));
expect(history1.lastValues.length).toBe(2);
expect(history1.lastValues.length).toBeGreaterThan(0);
expect(history1.lastValues[0].count).toBeGreaterThanOrEqual(1);
expect(history2.state).toBe('OK');
expect(history2.counts).toBe(0);
expect(history2.createdAt).toEqual(new Date('2023-11-16T22:15:00.000Z'));
// check if generic webhook was triggered, injected, and parsed, and sent correctly
expect(fetchMock).toHaveBeenCalledWith(
'https://webhook.site/123',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
text: `http://localhost:9090/dashboards/${dashboard._id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Max Duration" in "My Dashboard" - 102 exceeds 10`,
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
});
it('CHART alert (metrics table series) - generic webhook', async () => {
const team = await createTeam({ name: 'My Team' });
const runId = Math.random().toString(); // dedup watch mode runs
const teamId = team._id.toString();
jest.spyOn(checkAlert, 'handleSendGenericWebhook');
const fetchMock = jest.fn().mockResolvedValue({});
global.fetch = fetchMock;
const now = new Date('2023-11-16T22:12:00.000Z');
// Need data in 22:00 - 22:05 to calculate a rate for 22:05 - 22:10
const metricNowTs = new Date('2023-11-16T22:00:00.000Z').getTime();
mockSpyMetricPropertyTypeMappingsModel({
runId: 'string',
host: 'string',
'cloud.provider': 'string',
});
await clickhouse.bulkInsertTeamMetricStream(
buildMetricSeries({
name: 'redis.memory.rss',
tags: {
host: 'HyperDX',
'cloud.provider': 'aws',
runId,
series: '1',
},
data_type: clickhouse.MetricsDataType.Sum,
is_monotonic: true,
is_delta: true,
unit: 'Bytes',
points: [
{ value: 1, timestamp: metricNowTs },
{ value: 8, timestamp: metricNowTs + ms('1m') },
{ value: 8, timestamp: metricNowTs + ms('2m') },
{ value: 9, timestamp: metricNowTs + ms('3m') },
{ value: 15, timestamp: metricNowTs + ms('4m') }, // 15
{ value: 30, timestamp: metricNowTs + ms('5m') },
{ value: 31, timestamp: metricNowTs + ms('6m') },
{ value: 32, timestamp: metricNowTs + ms('7m') },
{ value: 33, timestamp: metricNowTs + ms('8m') },
{ value: 34, timestamp: metricNowTs + ms('9m') }, // 34
{ value: 35, timestamp: metricNowTs + ms('10m') },
{ value: 36, timestamp: metricNowTs + ms('11m') },
],
team_id: teamId,
}),
);
await clickhouse.bulkInsertTeamMetricStream(
buildMetricSeries({
name: 'redis.memory.rss',
tags: {
host: 'HyperDX',
'cloud.provider': 'aws',
runId,
series: '2',
},
data_type: clickhouse.MetricsDataType.Sum,
is_monotonic: true,
is_delta: true,
unit: 'Bytes',
points: [
{ value: 1000, timestamp: metricNowTs },
{ value: 8000, timestamp: metricNowTs + ms('1m') },
{ value: 8000, timestamp: metricNowTs + ms('2m') },
{ value: 9000, timestamp: metricNowTs + ms('3m') },
{ value: 15000, timestamp: metricNowTs + ms('4m') }, // 15000
{ value: 30000, timestamp: metricNowTs + ms('5m') },
{ value: 30001, timestamp: metricNowTs + ms('6m') },
{ value: 30002, timestamp: metricNowTs + ms('7m') },
{ value: 30003, timestamp: metricNowTs + ms('8m') },
{ value: 30004, timestamp: metricNowTs + ms('9m') }, // 30004
{ value: 30005, timestamp: metricNowTs + ms('10m') },
{ value: 30006, timestamp: metricNowTs + ms('11m') },
],
team_id: teamId,
}),
);
await clickhouse.bulkInsertTeamMetricStream(
buildMetricSeries({
name: 'redis.memory.rss',
tags: { host: 'test2', 'cloud.provider': 'aws', runId, series: '0' },
data_type: clickhouse.MetricsDataType.Sum,
is_monotonic: true,
is_delta: true,
unit: 'Bytes',
points: [
{ value: 1, timestamp: metricNowTs },
{ value: 8, timestamp: metricNowTs + ms('1m') },
{ value: 8, timestamp: metricNowTs + ms('2m') },
{ value: 9, timestamp: metricNowTs + ms('3m') },
{ value: 15, timestamp: metricNowTs + ms('4m') }, // 15
{ value: 17, timestamp: metricNowTs + ms('5m') },
{ value: 18, timestamp: metricNowTs + ms('6m') },
{ value: 19, timestamp: metricNowTs + ms('7m') },
{ value: 20, timestamp: metricNowTs + ms('8m') },
{ value: 21, timestamp: metricNowTs + ms('9m') }, // 21
{ value: 22, timestamp: metricNowTs + ms('10m') },
{ value: 23, timestamp: metricNowTs + ms('11m') },
],
team_id: teamId,
}),
);
const webhook = await new Webhook({
team: team._id,
service: 'generic',
url: 'https://webhook.site/123',
name: 'Generic Webhook',
description: 'generic webhook description',
body: { text: '$HDX_ALERT_URL | $HDX_ALERT_TITLE' },
headers: { 'Content-Type': 'application/json' },
}).save();
const dashboard = await new Dashboard({
name: 'My Dashboard',
team: team._id,
charts: [
{
id: '198hki',
name: 'Redis Memory',
x: 0,
y: 0,
w: 6,
h: 3,
series: [
{
table: 'metrics',
type: 'time',
aggFn: 'avg_rate',
field: 'redis.memory.rss - Sum',
where: `cloud.provider:"aws" runId:${runId}`,
groupBy: ['host'],
},
{
table: 'metrics',
type: 'time',
aggFn: 'min_rate',
field: 'redis.memory.rss - Sum',
where: `cloud.provider:"aws" runId:${runId}`,
groupBy: ['host'],
},
],
seriesReturnType: 'ratio',
},
{
id: 'obil1',
name: 'Min Duratioin',
x: 6,
y: 0,
w: 6,
h: 3,
series: [
{
table: 'logs',
type: 'time',
aggFn: 'min',
field: 'duration',
where: '',
groupBy: [],
},
],
},
],
}).save();
const alert = await createAlert(team._id, {
source: 'CHART',
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
type: 'presence',
threshold: 10,
dashboardId: dashboard._id.toString(),
chartId: '198hki',
});
// shoud fetch 5m of metrics
await processAlert(now, alert);
expect(alert.state).toBe('ALERT');
// skip since time diff is less than 1 window size
const later = new Date('2023-11-16T22:14:00.000Z');
await processAlert(later, alert);
// alert should still be in alert state
expect(alert.state).toBe('ALERT');
const nextWindow = new Date('2023-11-16T22:16:00.000Z');
await processAlert(nextWindow, alert);
// alert should be in ok state
expect(alert.state).toBe('OK');
// check alert history
const alertHistories = await AlertHistory.find({
alert: alert._id,
}).sort({
createdAt: 1,
});
expect(alertHistories.length).toBe(2);
expect(alertHistories[0].state).toBe('ALERT');
expect(alertHistories[0].counts).toBe(1);
expect(alertHistories[0].createdAt).toEqual(
new Date('2023-11-16T22:10:00.000Z'),
);
expect(alertHistories[1].state).toBe('OK');
expect(alertHistories[1].counts).toBe(0);
expect(alertHistories[1].createdAt).toEqual(
new Date('2023-11-16T22:15:00.000Z'),
);
// check if generic webhook was triggered, injected, and parsed, and sent correctly
expect(fetchMock).toHaveBeenCalledWith(
'https://webhook.site/123',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
text: `http://localhost:9090/dashboards/${dashboard._id}?from=1700170200000&granularity=5+minute&to=1700174700000 | Alert for "Redis Memory" in "My Dashboard" - 395.3421052631579 exceeds 10`,
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
jest.resetAllMocks();
});
});
});

View file

@ -20,7 +20,7 @@ import AlertHistory, { IAlertHistory } from '@/models/alertHistory';
import Dashboard, { IDashboard } from '@/models/dashboard';
import LogView from '@/models/logView';
import { ITeam } from '@/models/team';
import Webhook from '@/models/webhook';
import Webhook, { IWebhook } from '@/models/webhook';
import { convertMsToGranularityString, truncateString } from '@/utils/common';
import { translateDashboardDocumentToExternalDashboard } from '@/utils/externalApi';
import logger from '@/utils/logger';
@ -160,20 +160,10 @@ export const notifyChannel = async ({
}),
});
// ONLY SUPPORTS SLACK WEBHOOKS FOR NOW
if (webhook?.service === 'slack') {
await slack.postMessageToWebhook(webhook.url, {
text: message.title,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*<${message.hdxLink} | ${message.title}>*\n${message.body}`,
},
},
],
});
await handleSendSlackWebhook(webhook, message);
} else if (webhook?.service === 'generic') {
await handleSendGenericWebhook(webhook, message);
}
break;
}
@ -181,6 +171,129 @@ export const notifyChannel = async ({
throw new Error(`Unsupported channel type: ${channel}`);
}
};
const handleSendSlackWebhook = async (
webhook: IWebhook,
message: {
hdxLink: string;
title: string;
body: string;
},
) => {
if (!webhook.url) {
throw new Error('Webhook URL is not set');
}
await slack.postMessageToWebhook(webhook.url, {
text: message.title,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*<${message.hdxLink} | ${message.title}>*\n${message.body}`,
},
},
],
});
};
export const handleSendGenericWebhook = async (
webhook: IWebhook,
message: {
hdxLink: string;
title: string;
body: string;
},
) => {
// QUERY PARAMS
if (!webhook.url) {
throw new Error('Webhook URL is not set');
}
let url: string;
// user input of queryParams is disabled on the frontend for now
if (webhook.queryParams) {
// user may have included params in both the url and the query params
// so they should be merged
const tmpURL = new URL(webhook.url);
for (const [key, value] of Object.entries(webhook.queryParams.toJSON())) {
tmpURL.searchParams.append(key, value);
}
url = tmpURL.toString();
} else {
// if there are no query params given, just use the url
url = webhook.url;
}
// HEADERS
// TODO: handle real webhook security and signage after v0
// X-HyperDX-Signature FROM PRIVATE SHA-256 HMAC, time based nonces, caching functionality etc
const headers = {
'Content-Type': 'application/json', // default, will be overwritten if user has set otherwise
...(webhook.headers?.toJSON() ?? {}),
};
// BODY
let parsedBody: Record<string, string | number | symbol> = {};
if (webhook.body) {
const injectedBody = injectIntoPlaceholders(JSON.stringify(webhook.body), {
$HDX_ALERT_URL: message.hdxLink,
$HDX_ALERT_TITLE: message.title,
$HDX_ALERT_BODY: message.body,
});
parsedBody = JSON.parse(injectedBody);
}
try {
// TODO: retries/backoff etc -> switch to request-error-tolerant api client
const response = await fetch(url, {
method: 'POST',
headers: headers as Record<string, string>,
body: JSON.stringify(parsedBody),
});
if (!response.ok) {
throw new Error('Failed to send generic webhook message');
}
} catch (e) {
logger.error({
message: 'Failed to send generic webhook message',
error: serializeError(e),
});
}
};
type HDXGenericWebhookTemplateValues = {
$HDX_ALERT_URL?: string;
$HDX_ALERT_TITLE?: string;
$HDX_ALERT_BODY?: string;
};
export function injectIntoPlaceholders(
placeholderString: string,
valuesToInject: HDXGenericWebhookTemplateValues,
) {
return placeholderString.replace(/(\$\w+)/g, function (match) {
const replacement =
valuesToInject[match as keyof HDXGenericWebhookTemplateValues] || match;
return escapeJsonValues(replacement);
});
}
export function escapeJsonValues(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}
export const buildAlertMessageTemplateHdxLink = ({
alert,
dashboard,
@ -272,7 +385,7 @@ export const getDefaultExternalAction = (
};
export const translateExternalActionsToInternal = (template: string) => {
// ex: @slack_webhook-1234_5678 -> "{{NOTIFY_FN_NAME channel="slack_webhook" id="1234_5678}}"
// ex: @webhook-1234_5678 -> "{{NOTIFY_FN_NAME channel="webhook" id="1234_5678}}"
return template.replace(/(?: |^)@([a-zA-Z0-9.@_-]+)/g, (match, input) => {
const prefix = match.startsWith(' ') ? ' ' : '';
const [channel, ...ids] = input.split('-');

View file

@ -16,8 +16,10 @@
"ci:unit": "jest --ci --coverage"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@hyperdx/browser": "^0.20.0",
"@hyperdx/lucene": "^3.1.1",
"@lezer/highlight": "^1.2.0",
"@mantine/core": "7.5.2",
"@mantine/hooks": "7.5.2",
"@mantine/spotlight": "7.5.2",
@ -25,6 +27,8 @@
"@monaco-editor/react": "^4.3.1",
"@tanstack/react-table": "^8.7.9",
"@tanstack/react-virtual": "^3.0.1",
"@uiw/codemirror-themes": "^4.21.24",
"@uiw/react-codemirror": "^4.21.24",
"ansi-to-html": "^0.7.2",
"bootstrap": "^5.1.3",
"chrono-node": "^2.5.0",

View file

@ -42,23 +42,22 @@ export const ALERT_INTERVAL_OPTIONS: Record<AlertInterval, string> = {
};
export const ALERT_CHANNEL_OPTIONS: Record<AlertChannelType, string> = {
webhook: 'Slack Webhook',
webhook: 'Webhook',
};
export const SlackChannelForm = ({
export const WebhookChannelForm = ({
webhookSelectProps,
}: {
webhookSelectProps: FormSelectProps;
}) => {
const { data: slackWebhooks } = api.useWebhooks('slack');
const { data: webhooks } = api.useWebhooks(['slack', 'generic']);
const hasSlackWebhooks =
Array.isArray(slackWebhooks?.data) && slackWebhooks.data.length > 0;
const hasWebhooks = Array.isArray(webhooks?.data) && webhooks.data.length > 0;
return (
<>
<div className="mt-3">
<Form.Label className="text-muted">Slack Webhook</Form.Label>
<Form.Label className="text-muted">Webhook</Form.Label>
<Form.Select
className="bg-black border-0 mb-1 px-3"
required
@ -68,11 +67,9 @@ export const SlackChannelForm = ({
>
{/* Ensure user selects a slack webhook before submitting form */}
<option value="" disabled selected>
{hasSlackWebhooks
? 'Select a Slack Webhook'
: 'No Slack Webhooks available'}
{hasWebhooks ? 'Select a Webhook' : 'No Webhooks available'}
</option>
{slackWebhooks?.data.map((sw: any) => (
{webhooks?.data.map((sw: any) => (
<option key={sw._id} value={sw._id}>
{sw.name}
</option>
@ -87,7 +84,7 @@ export const SlackChannelForm = ({
className="text-muted-hover d-flex align-items-center gap-1 fs-8"
>
<i className="bi bi-plus fs-5" />
Add New Slack Incoming Webhook
Add New Incoming Webhook
</a>
</div>
</>

View file

@ -9,7 +9,6 @@ import {
Badge,
Button,
Container,
Divider,
Group,
Stack,
Tooltip,
@ -174,7 +173,7 @@ function AlertDetails({ alert }: { alert: AlertData }) {
<span className="fw-bold">{alert.threshold}</span>
<span className="text-slate-400">&middot;</span>
{alert.channel.type === 'webhook' && (
<span>Notify via Slack Webhook</span>
<span>Notify via Webhook</span>
)}
</div>
</Stack>

View file

@ -8,7 +8,7 @@ import {
ALERT_INTERVAL_OPTIONS,
intervalToDateRange,
intervalToGranularity,
SlackChannelForm,
WebhookChannelForm,
} from './Alert';
import api from './api';
import { FieldSelect } from './ChartUtils';
@ -164,7 +164,7 @@ function AlertForm({
<div className="d-flex align-items-center mb-2"></div>
{channel === 'webhook' && (
<SlackChannelForm webhookSelectProps={register('webhookId')} />
<WebhookChannelForm webhookSelectProps={register('webhookId')} />
)}
<div className="d-flex justify-content-between mt-4">

View file

@ -1,4 +1,3 @@
import * as React from 'react';
import produce from 'immer';
import { omit } from 'lodash';
import { Form } from 'react-bootstrap';
@ -7,7 +6,7 @@ import { Tooltip } from '@mantine/core';
import {
ALERT_CHANNEL_OPTIONS,
ALERT_INTERVAL_OPTIONS,
SlackChannelForm,
WebhookChannelForm,
} from './Alert';
import type { Alert } from './types';
import { NumberFormat } from './types';
@ -142,7 +141,7 @@ export default function EditChartFormAlerts({
</div>
<div className="mt-3">
{alert?.channel?.type === 'webhook' && (
<SlackChannelForm
<WebhookChannelForm
webhookSelectProps={{
value: alert?.channel?.webhookId || '',
onChange: e => {

View file

@ -311,3 +311,20 @@ export function TerraformFlatIcon({ width }: IconProps) {
</svg>
);
}
export function WebhookFlatIcon({ width }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
width={width}
height={width}
viewBox="-10 -5 1034 1034"
>
<path
fill="currentColor"
d="M482 226h-1l-10 2q-33 4 -64.5 18.5t-55.5 38.5q-41 37 -57 91q-9 30 -8 63t12 63q17 45 52 78l13 12l-83 135q-26 -1 -45 7q-30 13 -45 40q-7 15 -9 31t2 32q8 30 33 48q15 10 33 14.5t36 2t34.5 -12.5t27.5 -25q12 -17 14.5 -39t-5.5 -41q-1 -5 -7 -14l-3 -6l118 -192 q6 -9 8 -14l-10 -3q-9 -2 -13 -4q-23 -10 -41.5 -27.5t-28.5 -39.5q-17 -36 -9 -75q4 -23 17 -43t31 -34q37 -27 82 -27q27 -1 52.5 9.5t44.5 30.5q17 16 26.5 38.5t10.5 45.5q0 17 -6 42l70 19l8 1q14 -43 7 -86q-4 -33 -19.5 -63.5t-39.5 -53.5q-42 -42 -103 -56 q-6 -2 -18 -4l-14 -2h-37zM500 350q-17 0 -34 7t-30.5 20.5t-19.5 31.5q-8 20 -4 44q3 18 14 34t28 25q24 15 56 13q3 4 5 8l112 191q3 6 6 9q27 -26 58.5 -35.5t65 -3.5t58.5 26q32 25 43.5 61.5t0.5 73.5q-8 28 -28.5 50t-48.5 33q-31 13 -66.5 8.5t-63.5 -24.5 q-4 -3 -13 -10l-5 -6q-4 3 -11 10l-47 46q23 23 52 38.5t61 21.5l22 4h39l28 -5q64 -13 110 -60q22 -22 36.5 -50.5t19.5 -59.5q5 -36 -2 -71.5t-25 -64.5t-44 -51t-57 -35q-34 -14 -70.5 -16t-71.5 7l-17 5l-81 -137q13 -19 16 -37q5 -32 -13 -60q-16 -25 -44 -35 q-17 -6 -35 -6zM218 614q-58 13 -100 53q-47 44 -61 105l-4 24v37l2 11q2 13 4 20q7 31 24.5 59t42.5 49q50 41 115 49q38 4 76 -4.5t70 -28.5q53 -34 78 -91q7 -17 14 -45q6 -1 18 0l125 2q14 0 20 1q11 20 25 31t31.5 16t35.5 4q28 -3 50 -20q27 -21 32 -54 q2 -17 -1.5 -33t-13.5 -30q-16 -22 -41 -32q-17 -7 -35.5 -6.5t-35.5 7.5q-28 12 -43 37l-3 6q-14 0 -42 -1l-113 -1q-15 -1 -43 -1l-50 -1l3 17q8 43 -13 81q-14 27 -40 45t-57 22q-35 6 -70 -7.5t-57 -42.5q-28 -35 -27 -79q1 -37 23 -69q13 -19 32 -32t41 -19l9 -3z"
></path>
</svg>
);
}

View file

@ -1,6 +1,5 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import {
Badge,
Button,
@ -14,12 +13,18 @@ import {
} from 'react-bootstrap';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { toast } from 'react-toastify';
import { json } from '@codemirror/lang-json';
import { tags as lt } from '@lezer/highlight';
import { Alert } from '@mantine/core';
import { createTheme } from '@uiw/codemirror-themes';
import CodeMirror, { placeholder } from '@uiw/react-codemirror';
import api from './api';
import { withAppNav } from './layout';
import useUserPreferences from './useUserPreferences';
import { TimeFormat } from './useUserPreferences';
import { isValidUrl } from './utils';
import { WebhookFlatIcon } from './SVGIcons';
import { WebhookService } from './types';
import useUserPreferences, { TimeFormat } from './useUserPreferences';
import { isValidJson, isValidUrl } from './utils';
export default function TeamPage() {
const [
@ -30,10 +35,14 @@ export default function TeamPage() {
const [teamInviteUrl, setTeamInviteUrl] = useState('');
const [addSlackWebhookModalShow, setAddSlackWebhookModalShow] =
useState(false);
const [addGenericWebhookModalShow, setAddGenericWebhookModalShow] =
useState(false);
const { data: me, isLoading: isLoadingMe } = api.useMe();
const { data: team, isLoading, refetch: refetchTeam } = api.useTeam();
const { data: slackWebhooks, refetch: refetchSlackWebhooks } =
api.useWebhooks('slack');
api.useWebhooks(['slack']);
const { data: genericWebhooks, refetch: refetchGenericWebhooks } =
api.useWebhooks(['generic']);
const sendTeamInvite = api.useSendTeamInvite();
const rotateTeamApiKey = api.useRotateTeamApiKey();
const saveWebhook = api.useSaveWebhook();
@ -42,6 +51,22 @@ export default function TeamPage() {
const timeFormat = useUserPreferences().timeFormat;
const handleTimeButtonClick = (val: TimeFormat) => setTimeFormat(val);
// Generic Webhook Form State
const [headers, setHeaders] = useState<string>();
const onHeadersChange = useCallback(
(headers: string) => {
setHeaders(headers);
},
[setHeaders],
);
const [body, setBody] = useState<string>();
const onBodyChange = useCallback(
(body: string) => {
setBody(body);
},
[setBody],
);
const hasAllowedAuthMethods =
team?.allowedAuthMethods != null && team?.allowedAuthMethods.length > 0;
@ -113,24 +138,47 @@ export default function TeamPage() {
setTeamInviteModalShow(false);
};
const onSubmitAddSlackWebhookForm = (e: any) => {
const onSubmitAddWebhookForm = (e: any, service: WebhookService) => {
e.preventDefault();
const name = e.target[0].value;
const url = e.target[1].value;
const name = e.target.name.value;
const description = e.target.description.value;
const url = e.target.url.value;
if (!name) {
toast.error('Please enter a name for the Slack webhook');
toast.error('Please enter a name for the Generic webhook');
return;
}
if (!url || !isValidUrl(url)) {
toast.error('Please enter a valid Slack webhook URL');
toast.error('Please enter a valid Generic webhook URL');
return;
}
if (headers && !isValidJson(headers)) {
toast.error('Please enter valid JSON for headers');
return;
}
if (body && !isValidJson(body)) {
toast.error('Please enter valid JSON for body');
return;
}
saveWebhook.mutate(
{ name, service: 'slack', url },
{
name,
service: service,
url,
description,
headers: headers ? JSON.parse(headers) : undefined,
body: body ? JSON.parse(body) : undefined,
},
{
onSuccess: () => {
toast.success('Saved Slack webhook');
refetchSlackWebhooks();
toast.success(`Saved ${service} webhook`);
service === WebhookService.Slack
? refetchSlackWebhooks()
: refetchGenericWebhooks();
},
onError: e => {
e.response
@ -151,18 +199,26 @@ export default function TeamPage() {
},
},
);
setAddSlackWebhookModalShow(false);
service === WebhookService.Slack
? setAddSlackWebhookModalShow(false)
: setAddGenericWebhookModalShow(false);
};
const onConfirmDeleteSlackWebhook = (webhookId: string) => {
const onConfirmDeleteWebhook = (
webhookId: string,
service: WebhookService,
) => {
// TODO: DELETES SHOULD POTENTIALLY WATERFALL DELETE TO ALERTS THAT CONSUME THEM
deleteWebhook.mutate(
{
id: webhookId,
},
{
onSuccess: () => {
toast.success('Deleted Slack webhook');
refetchSlackWebhooks();
toast.success(`Deleted ${service} webhook`);
service === WebhookService.Slack
? refetchSlackWebhooks()
: refetchGenericWebhooks();
},
onError: e => {
e.response
@ -185,6 +241,32 @@ export default function TeamPage() {
);
};
const openAddGenericWebhookModal = () => {
setHeaders(undefined);
setBody(undefined);
setAddGenericWebhookModalShow(true);
};
const hdxJSONTheme = createTheme({
theme: 'dark',
settings: {
background: '#FFFFFF1A',
foreground: '#f8f8f2',
caret: '#50fa7b',
selection: '#4a4eb5',
selectionMatch: '#9357ff',
lineHighlight: '#8a91991a',
gutterBackground: '#1a1d23',
gutterForeground: '#8a919966',
},
styles: [
{ tag: [lt.propertyName], color: '#bb9af7' },
{ tag: [lt.string], color: '#4bb74a' },
{ tag: [lt.number], color: '#ff5d5b' },
{ tag: [lt.bool], color: '#3788f6' },
],
});
return (
<Container>
<Head>
@ -289,18 +371,33 @@ export default function TeamPage() {
slackWebhooks.data.length > 0 &&
slackWebhooks.data.map((webhook: any) => (
<div key={webhook._id} className="my-3 text-muted">
<div className="d-flex mt-3 align-items-center">
<div className="fw-bold text-white">{webhook.name}</div>
<div className="ms-2 me-2">|</div>
<div className="fw-bold text-white">{webhook.url}</div>
<Button
variant="outline-danger"
className="ms-2"
size="sm"
onClick={() => onConfirmDeleteSlackWebhook(webhook._id)}
>
Delete
</Button>
<div className="d-flex flex-column mt-3 w-100">
<div className="d-flex align-items-center justify-content-between w-100">
<div className="d-flex align-items-center">
<div className="fw-bold text-white">{webhook.name}</div>
<div className="ms-2 me-2">|</div>
{/* TODO: truncate long urls responsive width */}
<div className="fw-bold text-white">{webhook.url}</div>
</div>
<Button
variant="outline-danger"
className="ms-2 align-self-end"
size="sm"
onClick={() =>
onConfirmDeleteWebhook(
webhook._id,
WebhookService.Slack,
)
}
>
Delete
</Button>
</div>
{webhook.description && (
<div className="fw-regular text-muted">
{webhook.description}
</div>
)}
</div>
</div>
))}
@ -324,7 +421,11 @@ export default function TeamPage() {
>
<Modal.Body className="bg-grey rounded">
<h5 className="text-muted">Add Slack Incoming Webhook</h5>
<Form onSubmit={onSubmitAddSlackWebhookForm}>
<Form
onSubmit={e =>
onSubmitAddWebhookForm(e, WebhookService.Slack)
}
>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Webhook Name
</Form.Label>
@ -347,6 +448,181 @@ export default function TeamPage() {
className="border-0 mb-4 px-3"
required
/>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Webhook Description (optional)
</Form.Label>
<Form.Control
size="sm"
id="description"
name="description"
placeholder="A description of this webhook"
className="border-0 mb-4 px-3"
/>
<Button
variant="brand-primary"
className="mt-2 px-4 float-end"
type="submit"
size="sm"
>
Add
</Button>
</Form>
</Modal.Body>
</Modal>
</div>
<div className="my-5">
<h2>Generic Webhooks</h2>
{Array.isArray(genericWebhooks?.data) &&
genericWebhooks.data.length > 0 &&
genericWebhooks.data.map((webhook: any) => (
<div key={webhook._id} className="my-3 text-muted">
<div className="d-flex flex-column mt-3 w-100">
<div className="d-flex align-items-center justify-content-between w-100">
<div className="d-flex align-items-center">
<div className="fw-bold text-white">{webhook.name}</div>
<div className="ms-2 me-2">|</div>
{/* TODO: truncate long urls responsive width */}
<div className="fw-bold text-white">{webhook.url}</div>
</div>
<Button
variant="outline-danger"
className="ms-2"
size="sm"
onClick={() =>
onConfirmDeleteWebhook(
webhook._id,
WebhookService.Generic,
)
}
>
Delete
</Button>
</div>
{webhook.description && (
<div className="fw-regular text-muted">
{webhook.description}
</div>
)}
</div>
</div>
))}
<Button
className="mt-2 mb-2"
size="sm"
variant="light"
onClick={openAddGenericWebhookModal}
>
<span
style={{
display: 'flex',
gap: '3px',
}}
>
<WebhookFlatIcon width={16} />
Add Generic Incoming Webhook
</span>
</Button>
<Modal
aria-labelledby="contained-modal-title-vcenter"
centered
onHide={() => setAddGenericWebhookModalShow(false)}
show={addGenericWebhookModalShow}
size="lg"
>
<Modal.Body className="bg-grey rounded">
<h5 className="text-muted">Add Generic Incoming Webhook</h5>
<Form
onSubmit={e =>
onSubmitAddWebhookForm(e, WebhookService.Generic)
}
>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Webhook Name
</Form.Label>
<Form.Control
size="sm"
id="name"
name="name"
placeholder="My Webhook"
className="border-0 mb-4 px-3"
required
/>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Webhook URL
</Form.Label>
<Form.Control
size="sm"
id="url"
name="url"
placeholder="https://webhook.site/6fd51408-4277-455b-aaf2-a50be9b4866b"
className="border-0 mb-4 px-3"
required
/>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Webhook Description (optional)
</Form.Label>
<Form.Control
size="sm"
id="description"
name="description"
placeholder="A description of this webhook"
className="border-0 mb-4 px-3"
/>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Custom Headers (optional)
</Form.Label>
<div className="mb-4">
<CodeMirror
value={headers}
height="100px"
extensions={[
json(),
placeholder(
'{\n\t"Content-Type": "application/json",\n}',
),
]}
theme={hdxJSONTheme}
onChange={onHeadersChange}
/>
</div>
<Form.Label className="text-start text-muted fs-7 mb-2 mt-2">
Custom Body (optional)
</Form.Label>
<div className="mb-2">
<CodeMirror
value={body}
height="100px"
extensions={[
json(),
placeholder(
'{\n\t"text": "$HDX_ALERT_URL | $HDX_ALERT_TITLE | $HDX_ALERT_BODY",\n}',
),
]}
theme={hdxJSONTheme}
onChange={onBodyChange}
/>
</div>
<Alert
icon={
<i className="bi bi-info-circle-fill text-slate-400" />
}
className="mb-4"
color="gray"
>
<span>
Currently the body supports the following message template
variables:
</span>
<br />
<span>
<code>$HDX_ALERT_URL</code>, <code>$HDX_ALERT_TITLE</code>
, <code>$HDX_ALERT_BODY</code>
</span>
</Alert>
<Button
variant="brand-primary"
className="mt-2 px-4 float-end"

View file

@ -2,12 +2,7 @@ import Router from 'next/router';
import type { HTTPError } from 'ky';
import ky from 'ky-universal';
import type { UseQueryOptions } from 'react-query';
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from 'react-query';
import { useInfiniteQuery, useMutation, useQuery } from 'react-query';
import { API_SERVER_URL } from './config';
import type {
@ -704,25 +699,37 @@ const api = {
return useMutation<
any,
HTTPError,
{ service: string; url: string; name: string }
>(async ({ service, url, name }) =>
{
service: string;
url: string;
name: string;
description?: string;
queryParams?: Map<string, string>;
headers?: Map<string, string>;
body?: Map<string, string>;
}
>(async ({ service, url, name, description, queryParams, headers, body }) =>
server(`webhooks`, {
method: 'POST',
json: {
name,
service,
url,
description,
queryParams,
headers,
body,
},
}).json(),
);
},
useWebhooks(service: string) {
useWebhooks(services: string[]) {
return useQuery<any, Error>({
queryKey: [service],
queryKey: [...services],
queryFn: () =>
server('webhooks', {
method: 'GET',
searchParams: [['service', service]],
searchParams: [...services.map(service => ['service', service])],
}).json(),
});
},

View file

@ -181,7 +181,11 @@ ${
threshold: alert.threshold,
threshold_type: alert.type === 'presence' ? 'above' : 'below',
channel: {
type: alert.channel.type === 'webhook' ? 'slack_webhook' : '',
type:
alert.channel.type === 'webhook' ||
alert.channel.type === 'slack_webhook'
? 'webhook'
: '',
...('webhookId' in alert.channel
? { webhookId: alert.channel.webhookId }
: {}),

View file

@ -275,3 +275,8 @@ export enum KubePhase {
Failed = 4,
Unknown = 5,
}
export enum WebhookService {
Slack = 'slack',
Generic = 'generic',
}

View file

@ -83,6 +83,15 @@ export const isValidUrl = (input: string) => {
}
};
export const isValidJson = (input: string) => {
try {
JSON.parse(input);
return true;
} catch {
return false;
}
};
export const capitalizeFirstLetter = (input: string) => {
return input.charAt(0).toUpperCase() + input.slice(1);
};

179
yarn.lock
View file

@ -1059,6 +1059,13 @@
dependencies:
regenerator-runtime "^0.13.11"
"@babel/runtime@^7.18.6":
version "7.24.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.0.tgz#584c450063ffda59697021430cb47101b085951e"
integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.20.1":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
@ -1356,6 +1363,88 @@
dependencies:
"@clickhouse/client-common" "0.2.7"
"@codemirror/autocomplete@^6.0.0":
version "6.13.0"
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.13.0.tgz#fa7df3b2809863df0da4556f72ac4263ea4d7adb"
integrity sha512-SuDrho1klTINfbcMPnyro1ZxU9xJtwDMtb62R8TjL/tOl71IoOsvBo1a9x+hDvHhIzkTcJHy2VC+rmpGgYkRSw==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.17.0"
"@lezer/common" "^1.0.0"
"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0":
version "6.3.3"
resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.3.3.tgz#03face5bf5f3de0fc4e09b177b3c91eda2ceb7e9"
integrity sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.4.0"
"@codemirror/view" "^6.0.0"
"@lezer/common" "^1.1.0"
"@codemirror/lang-json@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.1.tgz#0a0be701a5619c4b0f8991f9b5e95fe33f462330"
integrity sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==
dependencies:
"@codemirror/language" "^6.0.0"
"@lezer/json" "^1.0.0"
"@codemirror/language@^6.0.0":
version "6.10.1"
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.1.tgz#428c932a158cb75942387acfe513c1ece1090b05"
integrity sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.23.0"
"@lezer/common" "^1.1.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
style-mod "^4.0.0"
"@codemirror/lint@^6.0.0":
version "6.5.0"
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.5.0.tgz#ea43b6e653dcc5bcd93456b55e9fe62e63f326d9"
integrity sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
crelt "^1.0.5"
"@codemirror/search@^6.0.0":
version "6.5.6"
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
crelt "^1.0.5"
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0":
version "6.4.1"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b"
integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==
"@codemirror/theme-one-dark@^6.0.0":
version "6.1.2"
resolved "https://registry.yarnpkg.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz#fcef9f9cfc17a07836cb7da17c9f6d7231064df8"
integrity sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
"@lezer/highlight" "^1.0.0"
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0":
version "6.25.1"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.25.1.tgz#826ea345fd757dbeeab6a6165c1823e851c67d16"
integrity sha512-2LXLxsQnHDdfGzDvjzAwZh2ZviNJm7im6tGpa0IONIDnFd8RZ80D2SNi8PDi6YjKcMoMRK20v6OmKIdsrwsyoQ==
dependencies:
"@codemirror/state" "^6.4.0"
style-mod "^4.1.0"
w3c-keyname "^2.2.4"
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@ -2110,6 +2199,34 @@
"@jridgewell/resolve-uri" "3.1.0"
"@jridgewell/sourcemap-codec" "1.4.14"
"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.1.tgz#198b278b7869668e1bebbe687586e12a42731049"
integrity sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==
"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.0.tgz#e5898c3644208b4b589084089dceeea2966f7780"
integrity sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==
dependencies:
"@lezer/common" "^1.0.0"
"@lezer/json@^1.0.0":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.2.tgz#bdc849e174113e2d9a569a5e6fb1a27e2f703eaf"
integrity sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==
dependencies:
"@lezer/common" "^1.2.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
"@lezer/lr@^1.0.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.0.tgz#ed52a75dbbfbb0d1eb63710ea84c35ee647cb67e"
integrity sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==
dependencies:
"@lezer/common" "^1.0.0"
"@lukeed/csprng@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe"
@ -4604,6 +4721,40 @@
"@typescript-eslint/types" "6.20.0"
eslint-visitor-keys "^3.4.1"
"@uiw/codemirror-extensions-basic-setup@4.21.24":
version "4.21.24"
resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.21.24.tgz#b936c3daff0100e1a3d5b0500478747cfc80f7db"
integrity sha512-TJYKlPxNAVJNclW1EGumhC7I02jpdMgBon4jZvb5Aju9+tUzS44IwORxUx8BD8ZtH2UHmYS+04rE3kLk/BtnCQ==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/commands" "^6.0.0"
"@codemirror/language" "^6.0.0"
"@codemirror/lint" "^6.0.0"
"@codemirror/search" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
"@uiw/codemirror-themes@^4.21.24":
version "4.21.24"
resolved "https://registry.yarnpkg.com/@uiw/codemirror-themes/-/codemirror-themes-4.21.24.tgz#69b83d1d77f8ad40a2a8689e8bf54e4b445b88f3"
integrity sha512-InY24KWP8YArDBACWHKFZ6ZU+WCvRHf3ZB2cCVxMVN35P1ANUmRzpAP2ernZQ5OIriL1/A/kXgD0Zg3Y65PNgg==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
"@uiw/react-codemirror@^4.21.24":
version "4.21.24"
resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.21.24.tgz#38b05e0a24d2307313b2e73390b20d0251837170"
integrity sha512-8zs5OuxbhikHocHBsVBMuW1vqlv4ccZAkt4rFwr7ebLP2Q6RwHsjpsR9GeGyAigAqonKRoeHugqF78UMrkaTgg==
dependencies:
"@babel/runtime" "^7.18.6"
"@codemirror/commands" "^6.1.0"
"@codemirror/state" "^6.1.1"
"@codemirror/theme-one-dark" "^6.0.0"
"@uiw/codemirror-extensions-basic-setup" "4.21.24"
codemirror "^6.0.0"
"@xobotyi/scrollbar-width@^1.9.5":
version "1.9.5"
resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d"
@ -5650,6 +5801,19 @@ co@^4.6.0:
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
codemirror@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29"
integrity sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==
dependencies:
"@codemirror/autocomplete" "^6.0.0"
"@codemirror/commands" "^6.0.0"
"@codemirror/language" "^6.0.0"
"@codemirror/lint" "^6.0.0"
"@codemirror/search" "^6.0.0"
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.0.0"
collect-v8-coverage@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"
@ -5841,6 +6005,11 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
crelt@^1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
cron-parser@^4.2.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5"
@ -13423,6 +13592,11 @@ strong-log-transformer@^2.1.0:
minimist "^1.2.0"
through "^2.3.4"
style-mod@^4.0.0, style-mod@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67"
integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==
style-to-object@^0.4.0, style-to-object@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.1.tgz#53cf856f7cf7f172d72939d9679556469ba5de37"
@ -14328,6 +14502,11 @@ w3c-hr-time@^1.0.2:
dependencies:
browser-process-hrtime "^1.0.0"
w3c-keyname@^2.2.4:
version "2.2.8"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
w3c-xmlserializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a"