From 7953c0281b5a27bbf5541311537d5c215cd17cce Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Fri, 17 Apr 2026 07:08:23 -0400 Subject: [PATCH] feat: Add between and not-between alert thresholds (#2130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds BETWEEN and NOT BETWEEN alert threshold types. ### Screenshots or video Screenshot 2026-04-16 at 2 44 10 PM Screenshot 2026-04-16 at 2 44 17 PM ### 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: --- .changeset/healthy-eyes-kiss.md | 9 + packages/api/openapi.json | 12 +- packages/api/src/controllers/alerts.ts | 46 +- packages/api/src/models/alert.ts | 6 + packages/api/src/routers/api/alerts.ts | 1 + .../external-api/__tests__/alerts.test.ts | 191 ++++ .../api/src/routers/external-api/v2/alerts.ts | 9 +- .../renderAlertTemplate.test.ts.snap | 78 ++ .../checkAlerts/__tests__/checkAlerts.test.ts | 927 +++++++++++++++--- .../__tests__/renderAlertTemplate.test.ts | 73 +- packages/api/src/tasks/checkAlerts/index.ts | 30 +- .../api/src/tasks/checkAlerts/template.ts | 25 +- packages/api/src/utils/externalApi.ts | 2 + packages/api/src/utils/zod.ts | 5 +- packages/app/src/AlertsPage.tsx | 18 +- packages/app/src/DBSearchPageAlertModal.tsx | 36 +- .../app/src/components/AlertPreviewChart.tsx | 8 +- packages/app/src/components/Alerts.tsx | 40 +- packages/app/src/components/AppNav/AppNav.tsx | 10 +- .../ChartEditor/__tests__/utils.test.ts | 143 +++ .../app/src/components/ChartEditor/utils.ts | 18 + .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 1 + .../DBEditTimeChartForm/TileAlertEditor.tsx | 35 +- .../Dashboards/DashboardsListPage.tsx | 6 +- packages/app/src/utils/alerts.ts | 4 + .../e2e/components/ChartEditorComponent.ts | 58 +- .../SearchPageAlertModalComponent.ts | 58 ++ .../app/tests/e2e/features/alerts.spec.ts | 131 +++ packages/common-utils/src/types.ts | 38 +- 29 files changed, 1812 insertions(+), 206 deletions(-) create mode 100644 .changeset/healthy-eyes-kiss.md 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(),