diff --git a/.changeset/healthy-eyes-kiss.md b/.changeset/healthy-eyes-kiss.md new file mode 100644 index 00000000..d1709ce7 --- /dev/null +++ b/.changeset/healthy-eyes-kiss.md @@ -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 diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 0f946aee..531322b3 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -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.", diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index edb52a3c..1712ed92 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -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 => { }), source: alert.source, threshold: alert.threshold, + thresholdMax: alert.thresholdMax, thresholdType: alert.thresholdType, ...(userId && { createdBy: userId }), diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index ce1a81ab..159cb07e 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -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( type: Number, required: true, }, + thresholdMax: { + type: Number, + required: false, + }, thresholdType: { type: String, enum: AlertThresholdType, diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 148a06d8..25492a5d 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -75,6 +75,7 @@ const formatAlertResponse = ( 'scheduleOffsetMinutes', 'scheduleStartAt', 'threshold', + 'thresholdMax', 'thresholdType', 'state', 'source', diff --git a/packages/api/src/routers/external-api/__tests__/alerts.test.ts b/packages/api/src/routers/external-api/__tests__/alerts.test.ts index 4dc3a3c3..5c5875bc 100644 --- a/packages/api/src/routers/external-api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/external-api/__tests__/alerts.test.ts @@ -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 diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 2c9a8aac..7633ed8f 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -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. diff --git a/packages/api/src/tasks/checkAlerts/__tests__/__snapshots__/renderAlertTemplate.test.ts.snap b/packages/api/src/tasks/checkAlerts/__tests__/__snapshots__/renderAlertTemplate.test.ts.snap index 66833b94..92b5b56d 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/__snapshots__/renderAlertTemplate.test.ts.snap +++ b/packages/api/src/tasks/checkAlerts/__tests__/__snapshots__/renderAlertTemplate.test.ts.snap @@ -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) diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 49305c68..20aa33e7 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -68,263 +68,902 @@ beforeAll(async () => { describe('checkAlerts', () => { describe('doesExceedThreshold', () => { it('should return true when value exceeds ABOVE threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 10, 11)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 10, 10)).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 10 }, + 11, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 10 }, + 10, + ), + ).toBe(true); }); it('should return true when value is below BELOW threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.BELOW, 10, 9)).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 10 }, + 9, + ), + ).toBe(true); }); it('should return false when value equals BELOW threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.BELOW, 10, 10)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 10 }, + 10, + ), + ).toBe(false); }); it('should return false when value is below ABOVE threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 10, 9)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 10 }, + 9, + ), + ).toBe(false); }); it('should return false when value is above BELOW threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.BELOW, 10, 11)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 10 }, + 11, + ), + ).toBe(false); }); it('should handle zero values correctly', () => { - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 0, 1)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 0, 0)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 0, -1)).toBe(false); - expect(doesExceedThreshold(AlertThresholdType.BELOW, 0, -1)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.BELOW, 0, 0)).toBe(false); - expect(doesExceedThreshold(AlertThresholdType.BELOW, 0, 1)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 0 }, + 1, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 0 }, + 0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 0 }, + -1, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 0 }, + -1, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 0 }, + 0, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 0 }, + 1, + ), + ).toBe(false); }); it('should handle negative values correctly', () => { - expect(doesExceedThreshold(AlertThresholdType.ABOVE, -5, -3)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, -5, -5)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, -5, -7)).toBe(false); - expect(doesExceedThreshold(AlertThresholdType.BELOW, -5, -7)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.BELOW, -5, -5)).toBe(false); - expect(doesExceedThreshold(AlertThresholdType.BELOW, -5, -3)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: -5 }, + -3, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: -5 }, + -5, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: -5 }, + -7, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: -5 }, + -7, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: -5 }, + -5, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: -5 }, + -3, + ), + ).toBe(false); }); it('should handle decimal values correctly', () => { - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 10.5, 11.0)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 10.5, 10.5)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.ABOVE, 10.5, 10.0)).toBe( - false, - ); - expect(doesExceedThreshold(AlertThresholdType.BELOW, 10.5, 10.0)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.BELOW, 10.5, 10.5)).toBe( - false, - ); - expect(doesExceedThreshold(AlertThresholdType.BELOW, 10.5, 11.0)).toBe( - false, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 10.5 }, + 11.0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 10.5 }, + 10.5, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE, threshold: 10.5 }, + 10.0, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 10.5 }, + 10.0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 10.5 }, + 10.5, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW, threshold: 10.5 }, + 11.0, + ), + ).toBe(false); }); // ABOVE_EXCLUSIVE (>) tests it('should return true when value is strictly above ABOVE_EXCLUSIVE threshold', () => { expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 10, 11), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: 10 }, + 11, + ), ).toBe(true); }); it('should return false when value equals ABOVE_EXCLUSIVE threshold', () => { expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 10, 10), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: 10 }, + 10, + ), ).toBe(false); }); it('should return false when value is below ABOVE_EXCLUSIVE threshold', () => { expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 10, 9), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: 10 }, + 9, + ), ).toBe(false); }); it('should handle zero values correctly for ABOVE_EXCLUSIVE', () => { expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 0, 1), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: 0 }, + 1, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 0, 0), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: 0 }, + 0, + ), ).toBe(false); expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 0, -1), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: 0 }, + -1, + ), ).toBe(false); }); it('should handle negative values correctly for ABOVE_EXCLUSIVE', () => { expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, -5, -3), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: -5 }, + -3, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, -5, -5), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: -5 }, + -5, + ), ).toBe(false); expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, -5, -7), + doesExceedThreshold( + { thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, threshold: -5 }, + -7, + ), ).toBe(false); }); it('should handle decimal values correctly for ABOVE_EXCLUSIVE', () => { expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 10.5, 11.0), + doesExceedThreshold( + { + thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, + threshold: 10.5, + }, + 11.0, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 10.5, 10.5), + doesExceedThreshold( + { + thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, + threshold: 10.5, + }, + 10.5, + ), ).toBe(false); expect( - doesExceedThreshold(AlertThresholdType.ABOVE_EXCLUSIVE, 10.5, 10.0), + doesExceedThreshold( + { + thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE, + threshold: 10.5, + }, + 10.0, + ), ).toBe(false); }); // BELOW_OR_EQUAL (<=) tests it('should return true when value is below BELOW_OR_EQUAL threshold', () => { expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 10, 9), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: 10 }, + 9, + ), ).toBe(true); }); it('should return true when value equals BELOW_OR_EQUAL threshold', () => { expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 10, 10), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: 10 }, + 10, + ), ).toBe(true); }); it('should return false when value is above BELOW_OR_EQUAL threshold', () => { expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 10, 11), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: 10 }, + 11, + ), ).toBe(false); }); it('should handle zero values correctly for BELOW_OR_EQUAL', () => { expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 0, -1), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: 0 }, + -1, + ), ).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 0, 0)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 0, 1)).toBe( - false, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: 0 }, + 0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: 0 }, + 1, + ), + ).toBe(false); }); it('should handle negative values correctly for BELOW_OR_EQUAL', () => { expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, -5, -7), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: -5 }, + -7, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, -5, -5), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: -5 }, + -5, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, -5, -3), + doesExceedThreshold( + { thresholdType: AlertThresholdType.BELOW_OR_EQUAL, threshold: -5 }, + -3, + ), ).toBe(false); }); it('should handle decimal values correctly for BELOW_OR_EQUAL', () => { expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 10.5, 10.0), + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BELOW_OR_EQUAL, + threshold: 10.5, + }, + 10.0, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 10.5, 10.5), + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BELOW_OR_EQUAL, + threshold: 10.5, + }, + 10.5, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.BELOW_OR_EQUAL, 10.5, 11.0), + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BELOW_OR_EQUAL, + threshold: 10.5, + }, + 11.0, + ), ).toBe(false); }); // EQUAL (=) tests it('should return true when value equals EQUAL threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 10, 10)).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 10 }, + 10, + ), + ).toBe(true); }); it('should return false when value is above EQUAL threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 10, 11)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 10 }, + 11, + ), + ).toBe(false); }); it('should return false when value is below EQUAL threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 10, 9)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 10 }, + 9, + ), + ).toBe(false); }); it('should handle zero values correctly for EQUAL', () => { - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 0, 0)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 0, 1)).toBe(false); - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 0, -1)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 0 }, + 0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 0 }, + 1, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 0 }, + -1, + ), + ).toBe(false); }); it('should handle negative values correctly for EQUAL', () => { - expect(doesExceedThreshold(AlertThresholdType.EQUAL, -5, -5)).toBe(true); - expect(doesExceedThreshold(AlertThresholdType.EQUAL, -5, -3)).toBe(false); - expect(doesExceedThreshold(AlertThresholdType.EQUAL, -5, -7)).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: -5 }, + -5, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: -5 }, + -3, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: -5 }, + -7, + ), + ).toBe(false); }); it('should handle decimal values correctly for EQUAL', () => { - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 10.5, 10.5)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 10.5, 10.0)).toBe( - false, - ); - expect(doesExceedThreshold(AlertThresholdType.EQUAL, 10.5, 11.0)).toBe( - false, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 10.5 }, + 10.5, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 10.5 }, + 10.0, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.EQUAL, threshold: 10.5 }, + 11.0, + ), + ).toBe(false); }); // NOT_EQUAL (≠) tests it('should return true when value does not equal NOT_EQUAL threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 10, 11)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 10, 9)).toBe( - true, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 10 }, + 11, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 10 }, + 9, + ), + ).toBe(true); }); it('should return false when value equals NOT_EQUAL threshold', () => { - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 10, 10)).toBe( - false, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 10 }, + 10, + ), + ).toBe(false); }); it('should handle zero values correctly for NOT_EQUAL', () => { - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 0, 1)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 0, -1)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 0, 0)).toBe( - false, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 0 }, + 1, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 0 }, + -1, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 0 }, + 0, + ), + ).toBe(false); }); it('should handle negative values correctly for NOT_EQUAL', () => { - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, -5, -3)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, -5, -7)).toBe( - true, - ); - expect(doesExceedThreshold(AlertThresholdType.NOT_EQUAL, -5, -5)).toBe( - false, - ); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: -5 }, + -3, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: -5 }, + -7, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: -5 }, + -5, + ), + ).toBe(false); }); it('should handle decimal values correctly for NOT_EQUAL', () => { expect( - doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 10.5, 11.0), + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 10.5 }, + 11.0, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 10.5, 10.0), + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 10.5 }, + 10.0, + ), ).toBe(true); expect( - doesExceedThreshold(AlertThresholdType.NOT_EQUAL, 10.5, 10.5), + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_EQUAL, threshold: 10.5 }, + 10.5, + ), ).toBe(false); }); + + // BETWEEN tests + it('should return true when value is within BETWEEN range', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 7, + ), + ).toBe(true); + }); + + it('should return true when value equals BETWEEN lower bound (inclusive)', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 5, + ), + ).toBe(true); + }); + + it('should return true when value equals BETWEEN upper bound (inclusive)', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 10, + ), + ).toBe(true); + }); + + it('should return false when value is below BETWEEN range', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 4, + ), + ).toBe(false); + }); + + it('should return false when value is above BETWEEN range', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 11, + ), + ).toBe(false); + }); + + it('should handle zero values correctly for BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: -1, + thresholdMax: 1, + }, + 0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 0, + thresholdMax: 0, + }, + 0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 1, + thresholdMax: 5, + }, + 0, + ), + ).toBe(false); + }); + + it('should handle negative values correctly for BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -7, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -10, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -5, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -11, + ), + ).toBe(false); + }); + + it('should handle decimal values correctly for BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 10.0, + thresholdMax: 11.0, + }, + 10.5, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 10.0, + thresholdMax: 11.0, + }, + 9.9, + ), + ).toBe(false); + }); + + it('should return true when threshold equals thresholdMax equals value for BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.BETWEEN, + threshold: 5, + thresholdMax: 5, + }, + 5, + ), + ).toBe(true); + }); + + it('should throw when thresholdMax is undefined for BETWEEN', () => { + expect(() => + doesExceedThreshold( + { thresholdType: AlertThresholdType.BETWEEN, threshold: 5 }, + 7, + ), + ).toThrow(/thresholdMax is required/); + }); + + // NOT_BETWEEN tests + it('should return true when value is below NOT_BETWEEN range', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 3, + ), + ).toBe(true); + }); + + it('should return true when value is above NOT_BETWEEN range', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 12, + ), + ).toBe(true); + }); + + it('should return false when value is within NOT_BETWEEN range', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 7, + ), + ).toBe(false); + }); + + it('should return false when value equals NOT_BETWEEN lower bound (inclusive)', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 5, + ), + ).toBe(false); + }); + + it('should return false when value equals NOT_BETWEEN upper bound (inclusive)', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 5, + thresholdMax: 10, + }, + 10, + ), + ).toBe(false); + }); + + it('should handle zero values correctly for NOT_BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: -1, + thresholdMax: 1, + }, + 0, + ), + ).toBe(false); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 1, + thresholdMax: 5, + }, + 0, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: -5, + thresholdMax: -1, + }, + 0, + ), + ).toBe(true); + }); + + it('should handle negative values correctly for NOT_BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -11, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -4, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: -10, + thresholdMax: -5, + }, + -7, + ), + ).toBe(false); + }); + + it('should handle decimal values correctly for NOT_BETWEEN', () => { + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 10.0, + thresholdMax: 11.0, + }, + 9.9, + ), + ).toBe(true); + expect( + doesExceedThreshold( + { + thresholdType: AlertThresholdType.NOT_BETWEEN, + threshold: 10.0, + thresholdMax: 11.0, + }, + 10.5, + ), + ).toBe(false); + }); + + it('should throw when thresholdMax is undefined for NOT_BETWEEN', () => { + expect(() => + doesExceedThreshold( + { thresholdType: AlertThresholdType.NOT_BETWEEN, threshold: 5 }, + 7, + ), + ).toThrow(/thresholdMax is required/); + }); }); describe('getScheduledWindowStart', () => { @@ -1844,6 +2483,70 @@ describe('checkAlerts', () => { ); }); + it.each([AlertThresholdType.BETWEEN, AlertThresholdType.NOT_BETWEEN])( + 'should not fire or record history when thresholdMax is missing for %s', + async thresholdType => { + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + const now = new Date('2023-11-16T22:12:00.000Z'); + const eventMs = new Date('2023-11-16T22:05:00.000Z'); + + await bulkInsertLogs([ + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong!', + }, + ]); + + const details = await createAlertDetails( + team, + source, + { + source: AlertSource.SAVED_SEARCH, + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + interval: '5m', + thresholdType, + threshold: 1, + // thresholdMax intentionally omitted to simulate an invalid alert + savedSearchId: savedSearch.id, + }, + { + taskType: AlertTaskType.SAVED_SEARCH, + savedSearch, + }, + ); + + await processAlertAtTime( + now, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + // Alert should remain in its default OK state and no history/webhooks should be emitted + expect((await Alert.findById(details.alert.id))!.state).toBe('OK'); + expect( + await AlertHistory.countDocuments({ alert: details.alert.id }), + ).toBe(0); + expect(slack.postMessageToWebhook).not.toHaveBeenCalled(); + }, + ); + it('TILE alert (events) - generic webhook', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, diff --git a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.test.ts index 07d8f496..d6bd453f 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.test.ts @@ -63,6 +63,7 @@ const makeSearchView = ( overrides: Partial & { 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 & { 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(); diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index a49f374c..90d06257 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -138,8 +138,11 @@ export async function computeAliasWithClauses( } export const doesExceedThreshold = ( - thresholdType: AlertThresholdType, - threshold: number, + { + threshold, + thresholdType, + thresholdMax, + }: Pick, 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( { diff --git a/packages/api/src/tasks/checkAlerts/template.ts b/packages/api/src/tasks/checkAlerts/template.ts index 9587ba22..d6ecc839 100644 --- a/packages/api/src/tasks/checkAlerts/template.ts +++ b/packages/api/src/tasks/checkAlerts/template.ts @@ -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}`; } diff --git a/packages/api/src/utils/externalApi.ts b/packages/api/src/utils/externalApi.ts index 525c9b05..80f6144c 100644 --- a/packages/api/src/utils/externalApi.ts +++ b/packages/api/src/utils/externalApi.ts @@ -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, diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 34d283a9..bd9c1813 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -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 diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 73b075a4..304d160c 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -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}{' '} {alert.threshold} + {isRangeThresholdType(alert.thresholdType) && ( + <> + {' '} + and {alert.thresholdMax ?? '-'} + + )} · ); diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index d4203b8c..a2fbc372 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -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 = ({ )} /> + {isRangeThresholdType(thresholdType as AlertThresholdType) && ( + <> + + and + + ( + + )} + /> + + )} lines appear within @@ -324,6 +357,7 @@ const AlertForm = ({ interval={interval} groupBy={groupByValue} threshold={threshold} + thresholdMax={thresholdMax} thresholdType={thresholdType} /> )} diff --git a/packages/app/src/components/AlertPreviewChart.tsx b/packages/app/src/components/AlertPreviewChart.tsx index f220965a..fd310cc4 100644 --- a/packages/app/src/components/AlertPreviewChart.tsx +++ b/packages/app/src/components/AlertPreviewChart.tsx @@ -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, diff --git a/packages/app/src/components/Alerts.tsx b/packages/app/src/components/Alerts.tsx index 77ba95ba..5b2168ee 100644 --- a/packages/app/src/components/Alerts.tsx +++ b/packages/app/src/components/Alerts.tsx @@ -77,7 +77,7 @@ const WebhookChannelForm = ({ } - render={({ field }) => ( + render={({ field, fieldState }) => ( (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 (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() { diff --git a/packages/app/tests/e2e/components/SearchPageAlertModalComponent.ts b/packages/app/tests/e2e/components/SearchPageAlertModalComponent.ts index b3e09f0b..39954de5 100644 --- a/packages/app/tests/e2e/components/SearchPageAlertModalComponent.ts +++ b/packages/app/tests/e2e/components/SearchPageAlertModalComponent.ts @@ -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 (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 (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 diff --git a/packages/app/tests/e2e/features/alerts.spec.ts b/packages/app/tests/e2e/features/alerts.spec.ts index a2ccd21c..ba454495 100644 --- a/packages/app/tests/e2e/features/alerts.spec.ts +++ b/packages/app/tests/e2e/features/alerts.spec.ts @@ -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 }); + }); + }, + ); }); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index c0fd1b8e..a9f58730 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -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(),