mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:**  **GENERIC WEBHOOK CREATION:**  **ALERT CREATION UPDATE:** 
This commit is contained in:
parent
d723a790ed
commit
0e365bfff2
17 changed files with 1317 additions and 82 deletions
7
.changeset/seven-sloths-hide.md
Normal file
7
.changeset/seven-sloths-hide.md
Normal 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.
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('-');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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">·</span>
|
||||
{alert.channel.type === 'webhook' && (
|
||||
<span>Notify via Slack Webhook</span>
|
||||
<span>Notify via Webhook</span>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -275,3 +275,8 @@ export enum KubePhase {
|
|||
Failed = 4,
|
||||
Unknown = 5,
|
||||
}
|
||||
|
||||
export enum WebhookService {
|
||||
Slack = 'slack',
|
||||
Generic = 'generic',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
179
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue