mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add between and not-between alert thresholds (#2130)
## Summary This PR adds BETWEEN and NOT BETWEEN alert threshold types. ### Screenshots or video <img width="2064" height="678" alt="Screenshot 2026-04-16 at 2 44 10 PM" src="https://github.com/user-attachments/assets/ac74ae00-65f8-44f8-80fb-689157d9adff" /> <img width="2062" height="673" alt="Screenshot 2026-04-16 at 2 44 17 PM" src="https://github.com/user-attachments/assets/9853d3a4-90a0-464b-97b2-1ff659e15688" /> ### How to test locally or on Vercel This must be tested locally, since alerts are not supported in the preview environment. To see the notification content, run an echo server locally and create a webhook that targets it (http://localhost:3000): ```bash npx http-echo-server ``` ### References - Linear Issue: Closes HDX-3988 - Related PRs:
This commit is contained in:
parent
739fe14001
commit
7953c0281b
29 changed files with 1812 additions and 206 deletions
9
.changeset/healthy-eyes-kiss.md
Normal file
9
.changeset/healthy-eyes-kiss.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
"@hyperdx/otel-collector": patch
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
"@hyperdx/cli": patch
|
||||
---
|
||||
|
||||
feat: Add between-type alert thresholds
|
||||
|
|
@ -74,7 +74,9 @@
|
|||
"above_exclusive",
|
||||
"below_or_equal",
|
||||
"equal",
|
||||
"not_equal"
|
||||
"not_equal",
|
||||
"between",
|
||||
"not_between"
|
||||
],
|
||||
"description": "Threshold comparison direction."
|
||||
},
|
||||
|
|
@ -184,9 +186,15 @@
|
|||
},
|
||||
"threshold": {
|
||||
"type": "number",
|
||||
"description": "Threshold value for triggering the alert.",
|
||||
"description": "Threshold value for triggering the alert. For between and not_between threshold types, this is the lower bound.",
|
||||
"example": 100
|
||||
},
|
||||
"thresholdMax": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Upper bound for between and not_between threshold types. Required when thresholdType is between or not_between, must be >= threshold.",
|
||||
"example": 500
|
||||
},
|
||||
"interval": {
|
||||
"$ref": "#/components/schemas/AlertInterval",
|
||||
"description": "Evaluation interval for the alert.",
|
||||
|
|
|
|||
|
|
@ -3,19 +3,13 @@ import {
|
|||
validateRawSqlForAlert,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
|
||||
import { AlertThresholdType } from '@hyperdx/common-utils/dist/types';
|
||||
import { sign, verify } from 'jsonwebtoken';
|
||||
import { groupBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ObjectId } from '@/models';
|
||||
import Alert, {
|
||||
AlertChannel,
|
||||
AlertInterval,
|
||||
AlertSource,
|
||||
IAlert,
|
||||
} from '@/models/alert';
|
||||
import Alert, { AlertSource, IAlert } from '@/models/alert';
|
||||
import Dashboard, { IDashboard } from '@/models/dashboard';
|
||||
import { ISavedSearch, SavedSearch } from '@/models/savedSearch';
|
||||
import { IUser } from '@/models/user';
|
||||
|
|
@ -24,34 +18,23 @@ import { Api400Error } from '@/utils/errors';
|
|||
import logger from '@/utils/logger';
|
||||
import { alertSchema, objectIdSchema } from '@/utils/zod';
|
||||
|
||||
export type AlertInput = {
|
||||
export type AlertInput = Omit<
|
||||
IAlert,
|
||||
| 'id'
|
||||
| 'scheduleStartAt'
|
||||
| 'savedSearchId'
|
||||
| 'createdAt'
|
||||
| 'createdBy'
|
||||
| 'updatedAt'
|
||||
| 'team'
|
||||
| 'state'
|
||||
> & {
|
||||
id?: string;
|
||||
source?: AlertSource;
|
||||
channel: AlertChannel;
|
||||
interval: AlertInterval;
|
||||
scheduleOffsetMinutes?: number;
|
||||
// Replace the Date-type fields from IAlert
|
||||
scheduleStartAt?: string | null;
|
||||
thresholdType: AlertThresholdType;
|
||||
threshold: number;
|
||||
|
||||
// Message template
|
||||
name?: string | null;
|
||||
message?: string | null;
|
||||
|
||||
// Log alerts
|
||||
groupBy?: string;
|
||||
// Replace the ObjectId-type fields from IAlert
|
||||
savedSearchId?: string;
|
||||
|
||||
// Chart alerts
|
||||
dashboardId?: string;
|
||||
tileId?: string;
|
||||
|
||||
// Silenced
|
||||
silenced?: {
|
||||
by?: ObjectId;
|
||||
at: Date;
|
||||
until: Date;
|
||||
};
|
||||
};
|
||||
|
||||
const validateObjectId = (id: string | undefined, message: string) => {
|
||||
|
|
@ -155,6 +138,7 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
|
|||
}),
|
||||
source: alert.source,
|
||||
threshold: alert.threshold,
|
||||
thresholdMax: alert.thresholdMax,
|
||||
thresholdType: alert.thresholdType,
|
||||
...(userId && { createdBy: userId }),
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export interface IAlert {
|
|||
state: AlertState;
|
||||
team: ObjectId;
|
||||
threshold: number;
|
||||
/** The upper bound for BETWEEN and NOT BETWEEN threshold types */
|
||||
thresholdMax?: number;
|
||||
thresholdType: AlertThresholdType;
|
||||
createdBy?: ObjectId;
|
||||
|
||||
|
|
@ -83,6 +85,10 @@ const AlertSchema = new Schema<IAlert>(
|
|||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
thresholdMax: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
thresholdType: {
|
||||
type: String,
|
||||
enum: AlertThresholdType,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const formatAlertResponse = (
|
|||
'scheduleOffsetMinutes',
|
||||
'scheduleStartAt',
|
||||
'threshold',
|
||||
'thresholdMax',
|
||||
'thresholdType',
|
||||
'state',
|
||||
'source',
|
||||
|
|
|
|||
|
|
@ -965,6 +965,197 @@ describe('External API Alerts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('BETWEEN and NOT_BETWEEN threshold types', () => {
|
||||
it('should create an alert with BETWEEN threshold type', async () => {
|
||||
const dashboard = await createTestDashboard();
|
||||
const webhook = await createTestWebhook();
|
||||
|
||||
const response = await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 50,
|
||||
thresholdMax: 200,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const alert = response.body.data;
|
||||
expect(alert.threshold).toBe(50);
|
||||
expect(alert.thresholdMax).toBe(200);
|
||||
expect(alert.thresholdType).toBe(AlertThresholdType.BETWEEN);
|
||||
});
|
||||
|
||||
it('should create an alert with NOT_BETWEEN threshold type', async () => {
|
||||
const dashboard = await createTestDashboard();
|
||||
const webhook = await createTestWebhook();
|
||||
|
||||
const response = await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 10,
|
||||
thresholdMax: 90,
|
||||
interval: '5m',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.NOT_BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const alert = response.body.data;
|
||||
expect(alert.threshold).toBe(10);
|
||||
expect(alert.thresholdMax).toBe(90);
|
||||
expect(alert.thresholdType).toBe(AlertThresholdType.NOT_BETWEEN);
|
||||
});
|
||||
|
||||
it('should reject BETWEEN without thresholdMax', async () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dashboard = await createTestDashboard();
|
||||
const webhook = await createTestWebhook();
|
||||
|
||||
await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 50,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should reject BETWEEN when thresholdMax < threshold', async () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dashboard = await createTestDashboard();
|
||||
const webhook = await createTestWebhook();
|
||||
|
||||
await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 100,
|
||||
thresholdMax: 50,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should allow thresholdMax equal to threshold for BETWEEN', async () => {
|
||||
const dashboard = await createTestDashboard();
|
||||
const webhook = await createTestWebhook();
|
||||
|
||||
const response = await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 100,
|
||||
thresholdMax: 100,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.threshold).toBe(100);
|
||||
expect(response.body.data.thresholdMax).toBe(100);
|
||||
});
|
||||
|
||||
it('should update an alert to use BETWEEN threshold type', async () => {
|
||||
const { alert, dashboard, webhook } = await createTestAlert();
|
||||
|
||||
const updateResponse = await authRequest(
|
||||
'put',
|
||||
`${ALERTS_BASE_URL}/${alert.id}`,
|
||||
)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 20,
|
||||
thresholdMax: 80,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const updatedAlert = updateResponse.body.data;
|
||||
expect(updatedAlert.threshold).toBe(20);
|
||||
expect(updatedAlert.thresholdMax).toBe(80);
|
||||
expect(updatedAlert.thresholdType).toBe(AlertThresholdType.BETWEEN);
|
||||
});
|
||||
|
||||
it('should retrieve a BETWEEN alert with thresholdMax', async () => {
|
||||
const dashboard = await createTestDashboard();
|
||||
const webhook = await createTestWebhook();
|
||||
|
||||
const createResponse = await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 10,
|
||||
thresholdMax: 50,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const getResponse = await authRequest(
|
||||
'get',
|
||||
`${ALERTS_BASE_URL}/${createResponse.body.data.id}`,
|
||||
).expect(200);
|
||||
|
||||
expect(getResponse.body.data.threshold).toBe(10);
|
||||
expect(getResponse.body.data.thresholdMax).toBe(50);
|
||||
expect(getResponse.body.data.thresholdType).toBe(
|
||||
AlertThresholdType.BETWEEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('should require authentication', async () => {
|
||||
// Create an unauthenticated agent
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
|
|||
* description: Evaluation interval.
|
||||
* AlertThresholdType:
|
||||
* type: string
|
||||
* enum: [above, below, above_exclusive, below_or_equal, equal, not_equal]
|
||||
* enum: [above, below, above_exclusive, below_or_equal, equal, not_equal, between, not_between]
|
||||
* description: Threshold comparison direction.
|
||||
* AlertSource:
|
||||
* type: string
|
||||
|
|
@ -110,8 +110,13 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
|
|||
* example: "ServiceName"
|
||||
* threshold:
|
||||
* type: number
|
||||
* description: Threshold value for triggering the alert.
|
||||
* description: Threshold value for triggering the alert. For between and not_between threshold types, this is the lower bound.
|
||||
* example: 100
|
||||
* thresholdMax:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* description: Upper bound for between and not_between threshold types. Required when thresholdType is between or not_between, must be >= threshold.
|
||||
* example: 500
|
||||
* interval:
|
||||
* $ref: '#/components/schemas/AlertInterval'
|
||||
* description: Evaluation interval for the alert.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ exports[`buildAlertMessageTemplateTitle saved search alerts ALERT state below th
|
|||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts ALERT state below_or_equal threshold=5 alertValue=3 1`] = `"🚨 Alert for "My Search" - 3 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts ALERT state between threshold=5 alertValue=6 1`] = `"🚨 Alert for "My Search" - 6 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts ALERT state equal threshold=5 alertValue=5 1`] = `"🚨 Alert for "My Search" - 5 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts ALERT state not_between threshold=5 alertValue=12 1`] = `"🚨 Alert for "My Search" - 12 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts ALERT state not_equal threshold=5 alertValue=10 1`] = `"🚨 Alert for "My Search" - 10 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved) above threshold=5 okValue=3 1`] = `"✅ Alert for "My Search" - 3 lines found"`;
|
||||
|
|
@ -20,8 +24,12 @@ exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved)
|
|||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved) below_or_equal threshold=5 okValue=10 1`] = `"✅ Alert for "My Search" - 10 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved) between threshold=5 okValue=10 1`] = `"✅ Alert for "My Search" - 10 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved) equal threshold=5 okValue=10 1`] = `"✅ Alert for "My Search" - 10 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved) not_between threshold=5 okValue=6 1`] = `"✅ Alert for "My Search" - 6 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle saved search alerts OK state (resolved) not_equal threshold=5 okValue=5 1`] = `"✅ Alert for "My Search" - 5 lines found"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state above threshold=5 alertValue=10 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 10 meets or exceeds 5"`;
|
||||
|
|
@ -32,12 +40,16 @@ exports[`buildAlertMessageTemplateTitle tile alerts ALERT state below threshold=
|
|||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state below_or_equal threshold=5 alertValue=3 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 3 falls to or below 5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state between threshold=5 alertValue=6 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 6 falls between 5 and 7"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state decimal threshold 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 10.1 meets or exceeds 1.5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state equal threshold=5 alertValue=5 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 5 equals 5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state integer threshold rounds value 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 11 meets or exceeds 5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state not_between threshold=5 alertValue=12 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 12 falls outside 5 and 7"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts ALERT state not_equal threshold=5 alertValue=10 1`] = `"🚨 Alert for "Test Chart" in "My Dashboard" - 10 does not equal 5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) above threshold=5 okValue=3 1`] = `"✅ Alert for "Test Chart" in "My Dashboard" - 3 falls below 5"`;
|
||||
|
|
@ -48,8 +60,12 @@ exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) below th
|
|||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) below_or_equal threshold=5 okValue=10 1`] = `"✅ Alert for "Test Chart" in "My Dashboard" - 10 exceeds 5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) between threshold=5 okValue=10 1`] = `"✅ Alert for "Test Chart" in "My Dashboard" - 10 falls outside 5 and 7"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) equal threshold=5 okValue=10 1`] = `"✅ Alert for "Test Chart" in "My Dashboard" - 10 does not equal 5"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) not_between threshold=5 okValue=6 1`] = `"✅ Alert for "Test Chart" in "My Dashboard" - 6 falls between 5 and 7"`;
|
||||
|
||||
exports[`buildAlertMessageTemplateTitle tile alerts OK state (resolved) not_equal threshold=5 okValue=5 1`] = `"✅ Alert for "Test Chart" in "My Dashboard" - 5 equals 5"`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts ALERT state above threshold=5 alertValue=10 1`] = `
|
||||
|
|
@ -100,6 +116,18 @@ Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
|||
\`\`\`"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts ALERT state between threshold=5 alertValue=6 1`] = `
|
||||
"
|
||||
6 lines found, which falls between the threshold of 5 and 7 lines
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
|
||||
\`\`\`
|
||||
"2023-03-17 22:14:01","error","Failed to connect to database"
|
||||
"2023-03-17 22:13:45","error","Connection timeout after 30s"
|
||||
"2023-03-17 22:12:30","error","Retry limit exceeded"
|
||||
\`\`\`"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts ALERT state equal threshold=5 alertValue=5 1`] = `
|
||||
"
|
||||
5 lines found, which equals the threshold of 5 lines
|
||||
|
|
@ -112,6 +140,18 @@ Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
|||
\`\`\`"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts ALERT state not_between threshold=5 alertValue=12 1`] = `
|
||||
"
|
||||
12 lines found, which falls outside the threshold of 5 and 7 lines
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
|
||||
\`\`\`
|
||||
"2023-03-17 22:14:01","error","Failed to connect to database"
|
||||
"2023-03-17 22:13:45","error","Connection timeout after 30s"
|
||||
"2023-03-17 22:12:30","error","Retry limit exceeded"
|
||||
\`\`\`"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts ALERT state not_equal threshold=5 alertValue=10 1`] = `
|
||||
"
|
||||
10 lines found, which does not equal the threshold of 5 lines
|
||||
|
|
@ -160,12 +200,24 @@ Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
|||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts OK state (resolved) between threshold=5 okValue=10 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts OK state (resolved) equal threshold=5 okValue=10 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts OK state (resolved) not_between threshold=5 okValue=6 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate saved search alerts OK state (resolved) not_equal threshold=5 okValue=5 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
|
|
@ -206,6 +258,13 @@ Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
|||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts ALERT state between threshold=5 alertValue=6 1`] = `
|
||||
"
|
||||
6 falls between 5 and 7
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts ALERT state decimal threshold 1`] = `
|
||||
"
|
||||
10.1 meets or exceeds 1.5
|
||||
|
|
@ -227,6 +286,13 @@ Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
|||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts ALERT state not_between threshold=5 alertValue=12 1`] = `
|
||||
"
|
||||
12 falls outside 5 and 7
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts ALERT state not_equal threshold=5 alertValue=10 1`] = `
|
||||
"
|
||||
10 does not equal 5
|
||||
|
|
@ -265,12 +331,24 @@ Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
|||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts OK state (resolved) between threshold=5 okValue=10 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts OK state (resolved) equal threshold=5 okValue=10 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts OK state (resolved) not_between threshold=5 okValue=6 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`renderAlertTemplate tile alerts OK state (resolved) not_equal threshold=5 okValue=5 1`] = `
|
||||
"The alert has been resolved.
|
||||
Time Range (UTC): [Mar 17 10:10:00 PM - Mar 17 10:15:00 PM)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -63,6 +63,7 @@ const makeSearchView = (
|
|||
overrides: Partial<AlertMessageTemplateDefaultView> & {
|
||||
thresholdType?: AlertThresholdType;
|
||||
threshold?: number;
|
||||
thresholdMax?: number;
|
||||
value?: number;
|
||||
group?: string;
|
||||
} = {},
|
||||
|
|
@ -70,6 +71,7 @@ const makeSearchView = (
|
|||
alert: {
|
||||
thresholdType: overrides.thresholdType ?? AlertThresholdType.ABOVE,
|
||||
threshold: overrides.threshold ?? 5,
|
||||
thresholdMax: overrides.thresholdMax,
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: { type: null },
|
||||
interval: '1m',
|
||||
|
|
@ -112,6 +114,7 @@ const makeTileView = (
|
|||
overrides: Partial<AlertMessageTemplateDefaultView> & {
|
||||
thresholdType?: AlertThresholdType;
|
||||
threshold?: number;
|
||||
thresholdMax?: number;
|
||||
value?: number;
|
||||
group?: string;
|
||||
} = {},
|
||||
|
|
@ -119,6 +122,7 @@ const makeTileView = (
|
|||
alert: {
|
||||
thresholdType: overrides.thresholdType ?? AlertThresholdType.ABOVE,
|
||||
threshold: overrides.threshold ?? 5,
|
||||
thresholdMax: overrides.thresholdMax,
|
||||
source: AlertSource.TILE,
|
||||
channel: { type: null },
|
||||
interval: '1m',
|
||||
|
|
@ -158,6 +162,7 @@ const render = (view: AlertMessageTemplateDefaultView, state: AlertState) =>
|
|||
interface AlertCase {
|
||||
thresholdType: AlertThresholdType;
|
||||
threshold: number;
|
||||
thresholdMax?: number; // for between-type thresholds
|
||||
alertValue: number; // value that would trigger the alert
|
||||
okValue: number; // value that would resolve the alert
|
||||
}
|
||||
|
|
@ -199,6 +204,20 @@ const alertCases: AlertCase[] = [
|
|||
alertValue: 10,
|
||||
okValue: 5,
|
||||
},
|
||||
{
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
threshold: 5,
|
||||
thresholdMax: 7,
|
||||
alertValue: 6,
|
||||
okValue: 10,
|
||||
},
|
||||
{
|
||||
thresholdType: AlertThresholdType.NOT_BETWEEN,
|
||||
threshold: 5,
|
||||
thresholdMax: 7,
|
||||
alertValue: 12,
|
||||
okValue: 6,
|
||||
},
|
||||
];
|
||||
|
||||
describe('renderAlertTemplate', () => {
|
||||
|
|
@ -206,9 +225,14 @@ describe('renderAlertTemplate', () => {
|
|||
describe('ALERT state', () => {
|
||||
it.each(alertCases)(
|
||||
'$thresholdType threshold=$threshold alertValue=$alertValue',
|
||||
async ({ thresholdType, threshold, alertValue }) => {
|
||||
async ({ thresholdType, threshold, thresholdMax, alertValue }) => {
|
||||
const result = await render(
|
||||
makeSearchView({ thresholdType, threshold, value: alertValue }),
|
||||
makeSearchView({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
value: alertValue,
|
||||
}),
|
||||
AlertState.ALERT,
|
||||
);
|
||||
expect(result).toMatchSnapshot();
|
||||
|
|
@ -227,9 +251,14 @@ describe('renderAlertTemplate', () => {
|
|||
describe('OK state (resolved)', () => {
|
||||
it.each(alertCases)(
|
||||
'$thresholdType threshold=$threshold okValue=$okValue',
|
||||
async ({ thresholdType, threshold, okValue }) => {
|
||||
async ({ thresholdType, threshold, thresholdMax, okValue }) => {
|
||||
const result = await render(
|
||||
makeSearchView({ thresholdType, threshold, value: okValue }),
|
||||
makeSearchView({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
value: okValue,
|
||||
}),
|
||||
AlertState.OK,
|
||||
);
|
||||
expect(result).toMatchSnapshot();
|
||||
|
|
@ -250,9 +279,14 @@ describe('renderAlertTemplate', () => {
|
|||
describe('ALERT state', () => {
|
||||
it.each(alertCases)(
|
||||
'$thresholdType threshold=$threshold alertValue=$alertValue',
|
||||
async ({ thresholdType, threshold, alertValue }) => {
|
||||
async ({ thresholdType, threshold, thresholdMax, alertValue }) => {
|
||||
const result = await render(
|
||||
makeTileView({ thresholdType, threshold, value: alertValue }),
|
||||
makeTileView({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
value: alertValue,
|
||||
}),
|
||||
AlertState.ALERT,
|
||||
);
|
||||
expect(result).toMatchSnapshot();
|
||||
|
|
@ -295,9 +329,14 @@ describe('renderAlertTemplate', () => {
|
|||
describe('OK state (resolved)', () => {
|
||||
it.each(alertCases)(
|
||||
'$thresholdType threshold=$threshold okValue=$okValue',
|
||||
async ({ thresholdType, threshold, okValue }) => {
|
||||
async ({ thresholdType, threshold, thresholdMax, okValue }) => {
|
||||
const result = await render(
|
||||
makeTileView({ thresholdType, threshold, value: okValue }),
|
||||
makeTileView({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
value: okValue,
|
||||
}),
|
||||
AlertState.OK,
|
||||
);
|
||||
expect(result).toMatchSnapshot();
|
||||
|
|
@ -352,9 +391,14 @@ describe('buildAlertMessageTemplateTitle', () => {
|
|||
describe('ALERT state', () => {
|
||||
it.each(alertCases)(
|
||||
'$thresholdType threshold=$threshold alertValue=$alertValue',
|
||||
({ thresholdType, threshold, alertValue }) => {
|
||||
({ thresholdType, threshold, thresholdMax, alertValue }) => {
|
||||
const result = buildAlertMessageTemplateTitle({
|
||||
view: makeTileView({ thresholdType, threshold, value: alertValue }),
|
||||
view: makeTileView({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
value: alertValue,
|
||||
}),
|
||||
state: AlertState.ALERT,
|
||||
});
|
||||
expect(result).toMatchSnapshot();
|
||||
|
|
@ -389,9 +433,14 @@ describe('buildAlertMessageTemplateTitle', () => {
|
|||
describe('OK state (resolved)', () => {
|
||||
it.each(alertCases)(
|
||||
'$thresholdType threshold=$threshold okValue=$okValue',
|
||||
({ thresholdType, threshold, okValue }) => {
|
||||
({ thresholdType, threshold, thresholdMax, okValue }) => {
|
||||
const result = buildAlertMessageTemplateTitle({
|
||||
view: makeTileView({ thresholdType, threshold, value: okValue }),
|
||||
view: makeTileView({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
value: okValue,
|
||||
}),
|
||||
state: AlertState.OK,
|
||||
});
|
||||
expect(result).toMatchSnapshot();
|
||||
|
|
|
|||
|
|
@ -138,8 +138,11 @@ export async function computeAliasWithClauses(
|
|||
}
|
||||
|
||||
export const doesExceedThreshold = (
|
||||
thresholdType: AlertThresholdType,
|
||||
threshold: number,
|
||||
{
|
||||
threshold,
|
||||
thresholdType,
|
||||
thresholdMax,
|
||||
}: Pick<IAlert, 'thresholdType' | 'threshold' | 'thresholdMax'>,
|
||||
value: number,
|
||||
) => {
|
||||
switch (thresholdType) {
|
||||
|
|
@ -155,6 +158,16 @@ export const doesExceedThreshold = (
|
|||
return value === threshold;
|
||||
case AlertThresholdType.NOT_EQUAL:
|
||||
return value !== threshold;
|
||||
case AlertThresholdType.BETWEEN:
|
||||
case AlertThresholdType.NOT_BETWEEN:
|
||||
if (thresholdMax == null) {
|
||||
throw new Error(
|
||||
`thresholdMax is required for threshold type "${thresholdType}"`,
|
||||
);
|
||||
}
|
||||
return thresholdType === AlertThresholdType.BETWEEN
|
||||
? value >= threshold && value <= thresholdMax
|
||||
: value < threshold || value > thresholdMax;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -318,6 +331,7 @@ const fireChannelEvent = async ({
|
|||
silenced: alert.silenced,
|
||||
source: alert.source,
|
||||
threshold: alert.threshold,
|
||||
thresholdMax: alert.thresholdMax,
|
||||
thresholdType: alert.thresholdType,
|
||||
tileId: alert.tileId,
|
||||
},
|
||||
|
|
@ -956,7 +970,7 @@ export const processAlert = async (
|
|||
: 0;
|
||||
|
||||
history.lastValues.push({ count: value, startTime: alertTimestamp });
|
||||
if (doesExceedThreshold(alert.thresholdType, alert.threshold, value)) {
|
||||
if (doesExceedThreshold(alert, value)) {
|
||||
history.state = AlertState.ALERT;
|
||||
history.counts += 1;
|
||||
await trySendNotification({
|
||||
|
|
@ -1009,11 +1023,7 @@ export const processAlert = async (
|
|||
'No data returned from ClickHouse for time bucket',
|
||||
);
|
||||
|
||||
const zeroValueIsAlert = doesExceedThreshold(
|
||||
alert.thresholdType,
|
||||
alert.threshold,
|
||||
0,
|
||||
);
|
||||
const zeroValueIsAlert = doesExceedThreshold(alert, 0);
|
||||
|
||||
const hasAlertsInPreviousMap = previousMap
|
||||
.values()
|
||||
|
|
@ -1054,7 +1064,7 @@ export const processAlert = async (
|
|||
const groupKey = hasGroupBy ? extraFields.join(', ') : '';
|
||||
const history = getOrCreateHistory(groupKey);
|
||||
|
||||
if (doesExceedThreshold(alert.thresholdType, alert.threshold, value)) {
|
||||
if (doesExceedThreshold(alert, value)) {
|
||||
history.state = AlertState.ALERT;
|
||||
await trySendNotification({
|
||||
state: AlertState.ALERT,
|
||||
|
|
@ -1082,7 +1092,7 @@ export const processAlert = async (
|
|||
if (
|
||||
previousHistory.state === AlertState.ALERT &&
|
||||
!histories.has(groupKey) &&
|
||||
!doesExceedThreshold(alert.thresholdType, alert.threshold, 0)
|
||||
!doesExceedThreshold(alert, 0)
|
||||
) {
|
||||
logger.info(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
AlertThresholdType,
|
||||
ChartConfigWithOptDateRange,
|
||||
DisplayType,
|
||||
isRangeThresholdType,
|
||||
pickSampleWeightExpressionProps,
|
||||
SourceKind,
|
||||
WebhookService,
|
||||
|
|
@ -59,6 +60,10 @@ const describeThresholdViolation = (
|
|||
return 'equals';
|
||||
case AlertThresholdType.NOT_EQUAL:
|
||||
return 'does not equal';
|
||||
case AlertThresholdType.BETWEEN:
|
||||
return 'falls between';
|
||||
case AlertThresholdType.NOT_BETWEEN:
|
||||
return 'falls outside';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -78,9 +83,19 @@ const describeThresholdResolution = (
|
|||
return 'does not equal';
|
||||
case AlertThresholdType.NOT_EQUAL:
|
||||
return 'equals';
|
||||
case AlertThresholdType.BETWEEN:
|
||||
return 'falls outside';
|
||||
case AlertThresholdType.NOT_BETWEEN:
|
||||
return 'falls between';
|
||||
}
|
||||
};
|
||||
|
||||
const describeThreshold = (alert: AlertInput): string => {
|
||||
return isRangeThresholdType(alert.thresholdType)
|
||||
? `${alert.threshold} and ${alert.thresholdMax ?? '?'}`
|
||||
: `${alert.threshold}`;
|
||||
};
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 500;
|
||||
const NOTIFY_FN_NAME = '__hdx_notify_channel__';
|
||||
const IS_MATCH_FN_NAME = 'is_match';
|
||||
|
|
@ -415,10 +430,10 @@ export const buildAlertMessageTemplateTitle = ({
|
|||
const baseTitle = template
|
||||
? handlebars.compile(template)(view)
|
||||
: `Alert for "${tile.config.name}" in "${dashboard.name}" - ${formattedValue} ${
|
||||
doesExceedThreshold(alert.thresholdType, alert.threshold, value)
|
||||
doesExceedThreshold(alert, value)
|
||||
? describeThresholdViolation(alert.thresholdType)
|
||||
: describeThresholdResolution(alert.thresholdType)
|
||||
} ${alert.threshold}`;
|
||||
} ${describeThreshold(alert)}`;
|
||||
return `${emoji}${baseTitle}`;
|
||||
}
|
||||
|
||||
|
|
@ -684,7 +699,7 @@ ${targetTemplate}`;
|
|||
}
|
||||
|
||||
rawTemplateBody = `${group ? `Group: "${group}"` : ''}
|
||||
${value} lines found, which ${describeThresholdViolation(alert.thresholdType)} the threshold of ${alert.threshold} lines\n${timeRangeMessage}
|
||||
${value} lines found, which ${describeThresholdViolation(alert.thresholdType)} the threshold of ${describeThreshold(alert)} lines\n${timeRangeMessage}
|
||||
${targetTemplate}
|
||||
\`\`\`
|
||||
${truncatedResults}
|
||||
|
|
@ -696,10 +711,10 @@ ${truncatedResults}
|
|||
const formattedValue = formatValueToMatchThreshold(value, alert.threshold);
|
||||
rawTemplateBody = `${group ? `Group: "${group}"` : ''}
|
||||
${formattedValue} ${
|
||||
doesExceedThreshold(alert.thresholdType, alert.threshold, value)
|
||||
doesExceedThreshold(alert, value)
|
||||
? describeThresholdViolation(alert.thresholdType)
|
||||
: describeThresholdResolution(alert.thresholdType)
|
||||
} ${alert.threshold}\n${timeRangeMessage}
|
||||
} ${describeThreshold(alert)}\n${timeRangeMessage}
|
||||
${targetTemplate}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ export type ExternalAlert = {
|
|||
name?: string | null;
|
||||
message?: string | null;
|
||||
threshold: number;
|
||||
thresholdMax?: number;
|
||||
interval: AlertInterval;
|
||||
scheduleOffsetMinutes?: number;
|
||||
scheduleStartAt?: string | null;
|
||||
|
|
@ -309,6 +310,7 @@ export function translateAlertDocumentToExternalAlert(
|
|||
name: alertObj.name,
|
||||
message: alertObj.message,
|
||||
threshold: alertObj.threshold,
|
||||
thresholdMax: alertObj.thresholdMax,
|
||||
interval: alertObj.interval,
|
||||
...(alertObj.scheduleOffsetMinutes != null && {
|
||||
scheduleOffsetMinutes: alertObj.scheduleOffsetMinutes,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
scheduleStartAtSchema,
|
||||
SearchConditionLanguageSchema as whereLanguageSchema,
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
validateAlertThresholdMax,
|
||||
WebhookService,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Types } from 'mongoose';
|
||||
|
|
@ -511,12 +512,14 @@ export const alertSchema = z
|
|||
scheduleStartAt: scheduleStartAtSchema,
|
||||
threshold: z.number(),
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
thresholdMax: z.number().optional(),
|
||||
source: z.nativeEnum(AlertSource).default(AlertSource.SAVED_SEARCH),
|
||||
name: z.string().min(1).max(512).nullish(),
|
||||
message: z.string().min(1).max(4096).nullish(),
|
||||
})
|
||||
.and(zSavedSearchAlert.or(zTileAlert))
|
||||
.superRefine(validateAlertScheduleOffsetMinutes);
|
||||
.superRefine(validateAlertScheduleOffsetMinutes)
|
||||
.superRefine(validateAlertThresholdMax);
|
||||
|
||||
// ==============================
|
||||
// Webhooks
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import * as React from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { AlertSource, AlertState } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
AlertSource,
|
||||
AlertState,
|
||||
isRangeThresholdType,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Alert, Anchor, Badge, Container, Group, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
|
|
@ -20,6 +24,7 @@ import EmptyState from '@/components/EmptyState';
|
|||
import { PageHeader } from '@/components/PageHeader';
|
||||
|
||||
import { useBrandDisplayName } from './theme/ThemeProvider';
|
||||
import { TILE_ALERT_THRESHOLD_TYPE_OPTIONS } from './utils/alerts';
|
||||
import { getWebhookChannelIcon } from './utils/webhookIcons';
|
||||
import api from './api';
|
||||
import { withAppNav } from './layout';
|
||||
|
|
@ -74,10 +79,19 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
|
|||
})();
|
||||
|
||||
const alertType = React.useMemo(() => {
|
||||
const thresholdLabel =
|
||||
TILE_ALERT_THRESHOLD_TYPE_OPTIONS[alert.thresholdType] ??
|
||||
alert.thresholdType;
|
||||
return (
|
||||
<>
|
||||
If value is {alert.thresholdType === 'above' ? 'over' : 'under'}{' '}
|
||||
If value {thresholdLabel}{' '}
|
||||
<span className="fw-bold">{alert.threshold}</span>
|
||||
{isRangeThresholdType(alert.thresholdType) && (
|
||||
<>
|
||||
{' '}
|
||||
and <span className="fw-bold">{alert.thresholdMax ?? '-'}</span>
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import {
|
|||
AlertSource,
|
||||
AlertThresholdType,
|
||||
Filter,
|
||||
isRangeThresholdType,
|
||||
scheduleStartAtSchema,
|
||||
SearchCondition,
|
||||
SearchConditionLanguage,
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
validateAlertThresholdMax,
|
||||
zAlertChannel,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
|
|
@ -68,13 +70,15 @@ const SavedSearchAlertFormSchema = z
|
|||
.object({
|
||||
interval: AlertIntervalSchema,
|
||||
threshold: z.number(),
|
||||
thresholdMax: z.number().optional(),
|
||||
scheduleOffsetMinutes: z.number().int().min(0).default(0),
|
||||
scheduleStartAt: scheduleStartAtSchema,
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
channel: zAlertChannel,
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine(validateAlertScheduleOffsetMinutes);
|
||||
.superRefine(validateAlertScheduleOffsetMinutes)
|
||||
.superRefine(validateAlertThresholdMax);
|
||||
|
||||
const AlertForm = ({
|
||||
sourceId,
|
||||
|
|
@ -142,6 +146,7 @@ const AlertForm = ({
|
|||
});
|
||||
const groupByValue = useWatch({ control, name: 'groupBy' });
|
||||
const threshold = useWatch({ control, name: 'threshold' });
|
||||
const thresholdMax = useWatch({ control, name: 'thresholdMax' });
|
||||
const maxScheduleOffsetMinutes = Math.max(
|
||||
intervalToMinutes(interval ?? '5m') - 1,
|
||||
0,
|
||||
|
|
@ -181,6 +186,15 @@ const AlertForm = ({
|
|||
data={optionsToSelectData(ALERT_THRESHOLD_TYPE_OPTIONS)}
|
||||
size="xs"
|
||||
{...field}
|
||||
onChange={e => {
|
||||
field.onChange(e);
|
||||
if (
|
||||
isRangeThresholdType(e.currentTarget.value) &&
|
||||
thresholdMax == null
|
||||
) {
|
||||
setValue('thresholdMax', (threshold ?? 0) + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -191,6 +205,25 @@ const AlertForm = ({
|
|||
<NumberInput size="xs" w={80} {...field} />
|
||||
)}
|
||||
/>
|
||||
{isRangeThresholdType(thresholdType as AlertThresholdType) && (
|
||||
<>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
and
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="thresholdMax"
|
||||
render={({ field, fieldState }) => (
|
||||
<NumberInput
|
||||
size="xs"
|
||||
w={80}
|
||||
{...field}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Text size="sm" opacity={0.7}>
|
||||
lines appear within
|
||||
</Text>
|
||||
|
|
@ -324,6 +357,7 @@ const AlertForm = ({
|
|||
interval={interval}
|
||||
groupBy={groupByValue}
|
||||
threshold={threshold}
|
||||
thresholdMax={thresholdMax}
|
||||
thresholdType={thresholdType}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type AlertPreviewChartProps = {
|
|||
groupBy?: string;
|
||||
thresholdType: AlertThresholdType;
|
||||
threshold: number;
|
||||
thresholdMax?: number;
|
||||
select?: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ export const AlertPreviewChart = ({
|
|||
interval,
|
||||
groupBy,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
thresholdType,
|
||||
select,
|
||||
}: AlertPreviewChartProps) => {
|
||||
|
|
@ -65,7 +67,11 @@ export const AlertPreviewChart = ({
|
|||
showDisplaySwitcher={false}
|
||||
showMVOptimizationIndicator={false}
|
||||
showDateRangeIndicator={false}
|
||||
referenceLines={getAlertReferenceLines({ threshold, thresholdType })}
|
||||
referenceLines={getAlertReferenceLines({
|
||||
threshold,
|
||||
thresholdMax,
|
||||
thresholdType,
|
||||
})}
|
||||
config={{
|
||||
where: where || '',
|
||||
whereLanguage: whereLanguage || undefined,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const WebhookChannelForm = <T extends FieldValues>({
|
|||
<Controller
|
||||
control={control}
|
||||
name={name! as Path<T>}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
data-testid="select-webhook"
|
||||
comboboxProps={{
|
||||
|
|
@ -91,6 +91,7 @@ const WebhookChannelForm = <T extends FieldValues>({
|
|||
}
|
||||
data={options}
|
||||
{...field}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -144,14 +145,51 @@ export const AlertChannelForm = <T extends FieldValues>({
|
|||
export const getAlertReferenceLines = ({
|
||||
thresholdType,
|
||||
threshold,
|
||||
thresholdMax,
|
||||
// TODO: zScore
|
||||
}: {
|
||||
thresholdType: AlertThresholdType;
|
||||
threshold: number;
|
||||
thresholdMax?: number;
|
||||
}) => {
|
||||
if (threshold == null) {
|
||||
return null;
|
||||
}
|
||||
if (thresholdType === AlertThresholdType.BETWEEN && thresholdMax != null) {
|
||||
return (
|
||||
<ReferenceArea
|
||||
y1={threshold}
|
||||
y2={thresholdMax}
|
||||
ifOverflow="extendDomain"
|
||||
fill="red"
|
||||
strokeWidth={0}
|
||||
fillOpacity={0.05}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
thresholdType === AlertThresholdType.NOT_BETWEEN &&
|
||||
thresholdMax != null
|
||||
) {
|
||||
return [
|
||||
<ReferenceArea
|
||||
key="not-between-lower"
|
||||
y2={threshold}
|
||||
ifOverflow="extendDomain"
|
||||
fill="red"
|
||||
strokeWidth={0}
|
||||
fillOpacity={0.05}
|
||||
/>,
|
||||
<ReferenceArea
|
||||
key="not-between-upper"
|
||||
y1={thresholdMax}
|
||||
ifOverflow="extendDomain"
|
||||
fill="red"
|
||||
strokeWidth={0}
|
||||
fillOpacity={0.05}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (
|
||||
thresholdType === AlertThresholdType.BELOW ||
|
||||
thresholdType === AlertThresholdType.BELOW_OR_EQUAL
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@ import Link from 'next/link';
|
|||
import Router, { useRouter } from 'next/router';
|
||||
import cx from 'classnames';
|
||||
import HyperDX from '@hyperdx/browser';
|
||||
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
|
||||
import {
|
||||
AlertState,
|
||||
SavedSearchListApiResponse,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { SavedSearchListApiResponse } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
|
|
@ -232,9 +228,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
const renderDashboardLink = useCallback(
|
||||
(dashboard: Dashboard) => {
|
||||
const alerts = dashboard.tiles
|
||||
.map(t =>
|
||||
isBuilderSavedChartConfig(t.config) ? t.config.alert : undefined,
|
||||
)
|
||||
.map(t => t.config.alert)
|
||||
.filter(a => a != null);
|
||||
return (
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TSource,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
AlertThresholdType,
|
||||
DisplayType,
|
||||
MetricsDataType,
|
||||
SourceKind,
|
||||
|
|
@ -1055,6 +1056,148 @@ describe('validateChartForm', () => {
|
|||
expect(errors.filter(e => e.path === 'series')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Alert thresholdMax validation ───────────────────────────────────
|
||||
|
||||
it('errors when between alert is missing thresholdMax', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: {
|
||||
interval: '1h',
|
||||
threshold: 10,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: { type: 'webhook' },
|
||||
} as ChartEditorFormState['alert'],
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: 'alert.thresholdMax',
|
||||
message:
|
||||
'Upper bound is required for between/not between threshold types',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when not_between alert is missing thresholdMax', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: {
|
||||
interval: '1h',
|
||||
threshold: 10,
|
||||
thresholdType: AlertThresholdType.NOT_BETWEEN,
|
||||
channel: { type: 'webhook' },
|
||||
} as ChartEditorFormState['alert'],
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: 'alert.thresholdMax',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when thresholdMax is less than threshold', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: {
|
||||
interval: '1h',
|
||||
threshold: 10,
|
||||
thresholdMax: 5,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: { type: 'webhook' },
|
||||
} as ChartEditorFormState['alert'],
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: 'alert.thresholdMax',
|
||||
message:
|
||||
'Alert threshold upper bound must be greater than or equal to the lower bound',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not error when thresholdMax equals threshold', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: {
|
||||
interval: '1h',
|
||||
threshold: 10,
|
||||
thresholdMax: 10,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: { type: 'webhook' },
|
||||
} as ChartEditorFormState['alert'],
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors.filter(e => e.path === 'alert.thresholdMax')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not error when thresholdMax is greater than threshold for between', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: {
|
||||
interval: '1h',
|
||||
threshold: 10,
|
||||
thresholdMax: 20,
|
||||
thresholdType: AlertThresholdType.BETWEEN,
|
||||
channel: { type: 'webhook' },
|
||||
} as ChartEditorFormState['alert'],
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors.filter(e => e.path === 'alert.thresholdMax')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not validate thresholdMax for non-range threshold types', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: {
|
||||
interval: '1h',
|
||||
threshold: 10,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
channel: { type: 'webhook' },
|
||||
} as ChartEditorFormState['alert'],
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors.filter(e => e.path === 'alert.thresholdMax')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not validate thresholdMax when no alert is configured', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
source: 'source-log',
|
||||
alert: undefined,
|
||||
}),
|
||||
logSource,
|
||||
setError,
|
||||
);
|
||||
expect(errors.filter(e => e.path === 'alert.thresholdMax')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Multiple validation errors at once ───────────────────────────────
|
||||
|
||||
it('accumulates multiple errors across different validation rules', () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getSampleWeightExpression,
|
||||
isLogSource,
|
||||
isMetricSource,
|
||||
isRangeThresholdType,
|
||||
isTraceSource,
|
||||
RawSqlChartConfig,
|
||||
RawSqlSavedChartConfig,
|
||||
|
|
@ -266,6 +267,23 @@ export const validateChartForm = (
|
|||
}
|
||||
}
|
||||
|
||||
// Validate thresholdMax for range threshold types (between / not between)
|
||||
if (form.alert && isRangeThresholdType(form.alert.thresholdType)) {
|
||||
if (form.alert.thresholdMax == null) {
|
||||
errors.push({
|
||||
path: 'alert.thresholdMax',
|
||||
message:
|
||||
'Upper bound is required for between/not between threshold types',
|
||||
});
|
||||
} else if (form.alert.thresholdMax < form.alert.threshold) {
|
||||
errors.push({
|
||||
path: 'alert.thresholdMax',
|
||||
message:
|
||||
'Alert threshold upper bound must be greater than or equal to the lower bound',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate number and pie charts only have one series
|
||||
if (
|
||||
!isRawSqlChart &&
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export function ChartPreviewPanel({
|
|||
alert &&
|
||||
getAlertReferenceLines({
|
||||
threshold: alert.threshold,
|
||||
thresholdMax: alert.thresholdMax,
|
||||
thresholdType: alert.thresholdType,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import {
|
|||
UseFormSetValue,
|
||||
useWatch,
|
||||
} from 'react-hook-form';
|
||||
import { AlertThresholdType } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
AlertThresholdType,
|
||||
isRangeThresholdType,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
|
|
@ -62,6 +65,8 @@ export function TileAlertEditor({
|
|||
|
||||
const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
|
||||
const alertThresholdType = useWatch({ control, name: 'alert.thresholdType' });
|
||||
const alertThreshold = useWatch({ control, name: 'alert.threshold' });
|
||||
const alertThresholdMax = useWatch({ control, name: 'alert.thresholdMax' });
|
||||
const alertScheduleOffsetMinutes = useWatch({
|
||||
control,
|
||||
name: 'alert.scheduleOffsetMinutes',
|
||||
|
|
@ -151,6 +156,15 @@ export function TileAlertEditor({
|
|||
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
|
||||
size="xs"
|
||||
{...field}
|
||||
onChange={e => {
|
||||
field.onChange(e);
|
||||
if (
|
||||
isRangeThresholdType(e.currentTarget.value) &&
|
||||
alertThresholdMax == null
|
||||
) {
|
||||
setValue('alert.thresholdMax', (alertThreshold ?? 0) + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -161,6 +175,25 @@ export function TileAlertEditor({
|
|||
<NumberInput size="xs" w={80} {...field} />
|
||||
)}
|
||||
/>
|
||||
{isRangeThresholdType(alertThresholdType as AlertThresholdType) && (
|
||||
<>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
and
|
||||
</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="alert.thresholdMax"
|
||||
render={({ field, fieldState }) => (
|
||||
<NumberInput
|
||||
size="xs"
|
||||
w={80}
|
||||
{...field}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
over
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,7 @@ import { groupByTags } from '@/utils/groupByTags';
|
|||
import { withAppNav } from '../../layout';
|
||||
|
||||
function getDashboardAlerts(tiles: Dashboard['tiles']) {
|
||||
return tiles
|
||||
.map(t =>
|
||||
isBuilderSavedChartConfig(t.config) ? t.config.alert : undefined,
|
||||
)
|
||||
.filter(a => a != null);
|
||||
return tiles.map(t => t.config.alert).filter(a => a != null);
|
||||
}
|
||||
|
||||
const PRESET_DASHBOARDS = [
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ export const ALERT_THRESHOLD_TYPE_OPTIONS: Record<string, string> = {
|
|||
below_or_equal: 'At most (≤)',
|
||||
equal: 'Equal to (=)',
|
||||
not_equal: 'Not equal to (≠)',
|
||||
between: 'Between (≤ x ≤)',
|
||||
not_between: 'Outside (< or >)',
|
||||
};
|
||||
|
||||
export const TILE_ALERT_THRESHOLD_TYPE_OPTIONS: Record<string, string> = {
|
||||
|
|
@ -96,6 +98,8 @@ export const TILE_ALERT_THRESHOLD_TYPE_OPTIONS: Record<string, string> = {
|
|||
below_or_equal: 'is at most (≤)',
|
||||
equal: 'equals (=)',
|
||||
not_equal: 'does not equal (≠)',
|
||||
between: 'is between (≤ x ≤)',
|
||||
not_between: 'is outside (< or >)',
|
||||
};
|
||||
|
||||
export const ALERT_INTERVAL_OPTIONS: Record<AlertInterval, string> = {
|
||||
|
|
|
|||
|
|
@ -147,17 +147,13 @@ export class ChartEditorComponent {
|
|||
}
|
||||
|
||||
async selectWebhook(webhookName: string) {
|
||||
// Click to open dropdown
|
||||
await this.webhookSelector.click();
|
||||
|
||||
// Type to filter
|
||||
await this.webhookSelector.fill(webhookName);
|
||||
|
||||
// Use getByRole for more reliable selection
|
||||
const sourceOption = this.page.getByRole('option', { name: webhookName });
|
||||
if ((await sourceOption.getAttribute('data-combobox-active')) != 'true') {
|
||||
await sourceOption.click({ timeout: 5000 });
|
||||
if ((await this.webhookSelector.inputValue()) === webhookName) {
|
||||
return;
|
||||
}
|
||||
await this.webhookSelector.click();
|
||||
await this.page
|
||||
.getByRole('option', { name: webhookName })
|
||||
.click({ timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -267,6 +263,48 @@ export class ChartEditorComponent {
|
|||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a threshold type in the tile alert editor.
|
||||
* Pass the option value (e.g. 'between', 'above', 'below').
|
||||
* Scoped to [data-testid="alert-details"].
|
||||
*/
|
||||
async selectTileAlertThresholdType(value: string) {
|
||||
await this.page
|
||||
.getByTestId('alert-details')
|
||||
.locator('select')
|
||||
.first()
|
||||
.selectOption(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the lower threshold value in the tile alert editor.
|
||||
* Mantine v9 NumberInput renders as <input inputmode="decimal"> (not type="number"),
|
||||
* so getByRole('spinbutton') does not match. We use the inputmode attribute instead.
|
||||
*/
|
||||
async setTileAlertThreshold(value: number) {
|
||||
const input = this.page
|
||||
.getByTestId('alert-details')
|
||||
.locator('input[inputmode="decimal"]')
|
||||
.first();
|
||||
await input.fill(String(value));
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the upper threshold (thresholdMax) in the tile alert editor.
|
||||
* Only visible after selecting a range threshold type (e.g. 'between').
|
||||
* Mantine v9 NumberInput renders as <input inputmode="decimal"> (not type="number"),
|
||||
* so getByRole('spinbutton') does not match. We use the inputmode attribute instead.
|
||||
*/
|
||||
async setTileAlertThresholdMax(value: number) {
|
||||
const input = this.page
|
||||
.getByTestId('alert-details')
|
||||
.locator('input[inputmode="decimal"]')
|
||||
.nth(1);
|
||||
await input.fill(String(value));
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
// Getters for assertions
|
||||
|
||||
get nameInput() {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import { WebhookAlertModalComponent } from './WebhookAlertModalComponent';
|
|||
* that appears on the saved search page (DBSearchPageAlertModal).
|
||||
*/
|
||||
export class SearchPageAlertModalComponent {
|
||||
readonly page: Page;
|
||||
private readonly modal: Locator;
|
||||
private readonly addNewWebhookButtonLocator: Locator;
|
||||
private readonly createAlertButtonLocator: Locator;
|
||||
private readonly webhookSelector: Locator;
|
||||
readonly webhookAlertModal: WebhookAlertModalComponent;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.modal = page.getByTestId('alerts-modal');
|
||||
this.addNewWebhookButtonLocator = page.getByTestId(
|
||||
'add-new-webhook-button',
|
||||
|
|
@ -20,13 +23,68 @@ export class SearchPageAlertModalComponent {
|
|||
this.createAlertButtonLocator = page.getByRole('button', {
|
||||
name: 'Create Alert',
|
||||
});
|
||||
this.webhookSelector = page.getByTestId('select-webhook');
|
||||
this.webhookAlertModal = new WebhookAlertModalComponent(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly select a webhook by name from the alerts modal's select-webhook
|
||||
* combobox. The webhook is usually auto-selected after creation, but not
|
||||
* always — call this to guarantee the selection. The Mantine Select input
|
||||
* is readonly, so we click to open the dropdown and click the option
|
||||
* rather than typing.
|
||||
*/
|
||||
async selectWebhook(webhookName: string) {
|
||||
if ((await this.webhookSelector.inputValue()) === webhookName) {
|
||||
return;
|
||||
}
|
||||
await this.webhookSelector.click();
|
||||
await this.page
|
||||
.getByRole('option', { name: webhookName })
|
||||
.click({ timeout: 5000 });
|
||||
}
|
||||
|
||||
get addNewWebhookButton() {
|
||||
return this.addNewWebhookButtonLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the threshold type from the NativeSelect inside the alerts modal.
|
||||
* Pass the option value (e.g. 'between', 'above', 'below').
|
||||
* The thresholdType NativeSelect is the first <select> rendered inside the modal.
|
||||
*/
|
||||
async selectThresholdType(value: string) {
|
||||
await this.modal.locator('select').first().selectOption(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the lower threshold value (first NumberInput in the alert form).
|
||||
* Mantine v9 NumberInput renders as <input inputmode="decimal"> (not type="number"),
|
||||
* so getByRole('spinbutton') does not match. We use the inputmode attribute instead.
|
||||
*/
|
||||
async setThreshold(value: number) {
|
||||
const input = this.modal.locator('input[inputmode="decimal"]').first();
|
||||
await input.fill(String(value));
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the upper threshold value (thresholdMax — second NumberInput).
|
||||
* Only present after selecting a range threshold type (e.g. 'between').
|
||||
* Mantine v9 NumberInput renders as <input inputmode="decimal"> (not type="number"),
|
||||
* so getByRole('spinbutton') does not match. We use the inputmode attribute instead.
|
||||
*/
|
||||
async setThresholdMax(value: number) {
|
||||
const input = this.modal.locator('input[inputmode="decimal"]').nth(1);
|
||||
await input.fill(String(value));
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
/** Returns the thresholdMax NumberInput locator for visibility assertions. */
|
||||
get thresholdMaxInput() {
|
||||
return this.modal.locator('input[inputmode="decimal"]').nth(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new incoming webhook and wait for the webhook creation modal to
|
||||
* fully unmount. Uses `detached` state (not just `hidden`) because the
|
||||
|
|
|
|||
|
|
@ -313,4 +313,135 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should create a between-threshold alert from a saved search and verify on the alerts page',
|
||||
{ tag: '@full-stack' },
|
||||
async () => {
|
||||
const ts = Date.now();
|
||||
const savedSearchName = `E2E Between Alert Search ${ts}`;
|
||||
const webhookName = `E2E Webhook SS Between ${ts}`;
|
||||
const webhookUrl = `https://example.com/ss-between-${ts}`;
|
||||
|
||||
await test.step('Create a saved search', async () => {
|
||||
await searchPage.goto();
|
||||
await searchPage.openSaveSearchModal();
|
||||
await searchPage.savedSearchModal.saveSearchAndWaitForNavigation(
|
||||
savedSearchName,
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Open the alerts modal from the saved search page', async () => {
|
||||
await expect(searchPage.alertsButton).toBeVisible();
|
||||
await searchPage.openAlertsModal();
|
||||
await expect(searchPage.alertModal.addNewWebhookButton).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Select the Between (≤ x ≤) threshold type', async () => {
|
||||
await searchPage.alertModal.selectThresholdType('between');
|
||||
await expect(searchPage.alertModal.thresholdMaxInput).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Set threshold to 1 and thresholdMax to 5', async () => {
|
||||
await searchPage.alertModal.setThreshold(1);
|
||||
await searchPage.alertModal.setThresholdMax(5);
|
||||
});
|
||||
|
||||
await test.step('Create a new incoming webhook for the alert channel', async () => {
|
||||
await searchPage.alertModal.addWebhookAndWait(
|
||||
'Generic',
|
||||
webhookName,
|
||||
webhookUrl,
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Explicitly select the webhook (auto-select is unreliable)', async () => {
|
||||
await searchPage.alertModal.selectWebhook(webhookName);
|
||||
});
|
||||
|
||||
await test.step('Create the alert', async () => {
|
||||
await searchPage.alertModal.createAlert();
|
||||
});
|
||||
|
||||
await test.step('Verify the alert is visible on the alerts page', async () => {
|
||||
await alertsPage.goto();
|
||||
await expect(alertsPage.pageContainer).toBeVisible();
|
||||
await expect(
|
||||
alertsPage.pageContainer
|
||||
.getByRole('link')
|
||||
.filter({ hasText: savedSearchName }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should create a between-threshold alert from a dashboard tile and verify on the alerts page',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const tileName = `E2E Between Alert Tile ${ts}`;
|
||||
const webhookName = `E2E Webhook Tile Between ${ts}`;
|
||||
const webhookUrl = `https://example.com/tile-between-${ts}`;
|
||||
|
||||
await test.step('Create a new dashboard', async () => {
|
||||
await dashboardPage.goto();
|
||||
await dashboardPage.createNewDashboard();
|
||||
});
|
||||
|
||||
await test.step('Add a tile to the dashboard', async () => {
|
||||
await dashboardPage.addTile();
|
||||
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
|
||||
await dashboardPage.chartEditor.waitForDataToLoad();
|
||||
await dashboardPage.chartEditor.setChartName(tileName);
|
||||
await dashboardPage.chartEditor.runQuery();
|
||||
});
|
||||
|
||||
await test.step('Enable alert and select the Between (≤ x ≤) threshold type', async () => {
|
||||
await expect(dashboardPage.chartEditor.alertButton).toBeVisible();
|
||||
await dashboardPage.chartEditor.clickAddAlert();
|
||||
await expect(
|
||||
dashboardPage.chartEditor.addNewWebhookButton,
|
||||
).toBeVisible();
|
||||
await dashboardPage.chartEditor.selectTileAlertThresholdType('between');
|
||||
});
|
||||
|
||||
await test.step('Set alert.threshold to 1 and alert.thresholdMax to 5', async () => {
|
||||
await dashboardPage.chartEditor.setTileAlertThreshold(1);
|
||||
await dashboardPage.chartEditor.setTileAlertThresholdMax(5);
|
||||
});
|
||||
|
||||
await test.step('Create a new incoming webhook for the alert channel', async () => {
|
||||
await dashboardPage.chartEditor.addNewWebhookButton.click();
|
||||
await expect(page.getByTestId('webhook-name-input')).toBeVisible();
|
||||
await dashboardPage.chartEditor.webhookAlertModal.addWebhook(
|
||||
'Generic',
|
||||
webhookName,
|
||||
webhookUrl,
|
||||
);
|
||||
await expect(page.getByTestId('alert-modal')).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Explicitly select the webhook (auto-select is unreliable)', async () => {
|
||||
await dashboardPage.chartEditor.selectWebhook(webhookName);
|
||||
});
|
||||
|
||||
await test.step('Save the tile with the alert configured', async () => {
|
||||
await dashboardPage.chartEditor.save();
|
||||
await expect(dashboardPage.getTiles()).toHaveCount(1, {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Verify the alert is visible on the alerts page', async () => {
|
||||
await alertsPage.goto();
|
||||
await expect(alertsPage.pageContainer).toBeVisible();
|
||||
await expect(
|
||||
alertsPage.pageContainer
|
||||
.getByRole('link')
|
||||
.filter({ hasText: tileName }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -282,8 +282,14 @@ export enum AlertThresholdType {
|
|||
BELOW_OR_EQUAL = 'below_or_equal',
|
||||
EQUAL = 'equal',
|
||||
NOT_EQUAL = 'not_equal',
|
||||
BETWEEN = 'between',
|
||||
NOT_BETWEEN = 'not_between',
|
||||
}
|
||||
|
||||
export const isRangeThresholdType = (type: string): boolean =>
|
||||
type === AlertThresholdType.BETWEEN ||
|
||||
type === AlertThresholdType.NOT_BETWEEN;
|
||||
|
||||
export enum AlertState {
|
||||
ALERT = 'ALERT',
|
||||
DISABLED = 'DISABLED',
|
||||
|
|
@ -370,6 +376,32 @@ export const validateAlertScheduleOffsetMinutes = (
|
|||
}
|
||||
};
|
||||
|
||||
export const validateAlertThresholdMax = (
|
||||
alert: {
|
||||
thresholdType: AlertThresholdType;
|
||||
threshold: number;
|
||||
thresholdMax?: number;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (isRangeThresholdType(alert.thresholdType)) {
|
||||
if (alert.thresholdMax == null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
'thresholdMax is required for between/not_between threshold types',
|
||||
path: ['thresholdMax'],
|
||||
});
|
||||
} else if (alert.thresholdMax < alert.threshold) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'thresholdMax must be greater than or equal to threshold',
|
||||
path: ['thresholdMax'],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_SCHEDULE_START_AT_FUTURE_MS = 1000 * 60 * 60 * 24 * 365;
|
||||
const MAX_SCHEDULE_START_AT_PAST_MS = 1000 * 60 * 60 * 24 * 365 * 10;
|
||||
const MAX_SCHEDULE_OFFSET_MINUTES = 1439;
|
||||
|
|
@ -406,6 +438,7 @@ export const AlertBaseObjectSchema = z.object({
|
|||
scheduleStartAt: scheduleStartAtSchema,
|
||||
threshold: z.number(),
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
thresholdMax: z.number().optional(),
|
||||
channel: zAlertChannel,
|
||||
state: z.nativeEnum(AlertState).optional(),
|
||||
name: z.string().min(1).max(512).nullish(),
|
||||
|
|
@ -425,7 +458,7 @@ export const AlertBaseSchema = AlertBaseObjectSchema;
|
|||
|
||||
const AlertBaseValidatedSchema = AlertBaseObjectSchema.superRefine(
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
);
|
||||
).superRefine(validateAlertThresholdMax);
|
||||
|
||||
export const ChartAlertBaseSchema = AlertBaseObjectSchema.extend({
|
||||
threshold: z.number(),
|
||||
|
|
@ -433,7 +466,7 @@ export const ChartAlertBaseSchema = AlertBaseObjectSchema.extend({
|
|||
|
||||
const ChartAlertBaseValidatedSchema = ChartAlertBaseSchema.superRefine(
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
);
|
||||
).superRefine(validateAlertThresholdMax);
|
||||
|
||||
export const AlertSchema = z.union([
|
||||
z.intersection(AlertBaseValidatedSchema, zSavedSearchAlert),
|
||||
|
|
@ -1286,6 +1319,7 @@ export const AlertsPageItemSchema = z.object({
|
|||
scheduleOffsetMinutes: z.number().optional(),
|
||||
scheduleStartAt: z.union([z.string(), z.date()]).nullish(),
|
||||
threshold: z.number(),
|
||||
thresholdMax: z.number().optional(),
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
channel: z.object({ type: z.string().optional().nullable() }),
|
||||
state: z.nativeEnum(AlertState).optional(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue