From 58fbcad0fb24da5e23d08c92a727da6ce404d30e Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 10:22:32 -0400 Subject: [PATCH 01/26] feat: add initial types for window lookback --- packages/api/src/controllers/alerts.ts | 3 +++ packages/api/src/models/alert.ts | 8 ++++++++ packages/api/src/models/alertHistory.ts | 5 +++++ packages/api/src/routers/api/alerts.ts | 1 + packages/api/src/utils/zod.ts | 1 + packages/common-utils/src/types.ts | 2 ++ 6 files changed, 20 insertions(+) diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 095d317d3b..fecf33799b 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -155,6 +155,9 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial => { // Chart alerts dashboard: alert.dashboardId as unknown as ObjectId, tileId: alert.tileId, + + // Multi-window alerting + windowsLookback: alert.windowsLookback, }; }; diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index bea7653d7b..c5dd259971 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -84,6 +84,9 @@ export interface IAlert { until: Date; }; + // Multi-window alerting: fire only after N violations in M consecutive windows + windowsLookback?: number; + // Errors recorded during the most recent execution executionErrors?: IAlertError[]; createdAt: Date; @@ -190,6 +193,11 @@ const AlertSchema = new Schema( type: String, required: false, }, + windowsLookback: { + type: Number, + required: false, + min: 1, + }, silenced: { required: false, type: { diff --git a/packages/api/src/models/alertHistory.ts b/packages/api/src/models/alertHistory.ts index 93cd4ca254..5cf8630f81 100644 --- a/packages/api/src/models/alertHistory.ts +++ b/packages/api/src/models/alertHistory.ts @@ -12,6 +12,7 @@ export interface IAlertHistory { state: AlertState; lastValues: { startTime: Date; count: number }[]; group?: string; // For group-by alerts, stores the group identifier + fired?: boolean; } const AlertHistorySchema = new Schema({ @@ -45,6 +46,10 @@ const AlertHistorySchema = new Schema({ type: String, required: false, }, + fired: { + type: Boolean, + required: false, + }, }); AlertHistorySchema.index( diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index e3fdde4e42..e3f30e186a 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -84,6 +84,7 @@ const formatAlertResponse = ( 'createdAt', 'updatedAt', 'executionErrors', + 'windowsLookback', ]), }; }; diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index d271c5ef4c..81f61f5700 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -620,6 +620,7 @@ export const alertSchema = z name: z.string().min(1).max(512).nullish(), message: z.string().min(1).max(4096).nullish(), note: alertNoteSchema, + windowsLookback: z.number().int().min(1).optional(), }) .and(zSavedSearchAlert.or(zTileAlert)) .superRefine(validateAlertScheduleOffsetMinutes) diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index e9675539d2..c0eaafe41c 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -598,6 +598,7 @@ export const AlertBaseObjectSchema = z.object({ until: z.string(), }) .optional(), + windowsLookback: z.number().int().min(1).optional(), }); // Keep AlertBaseSchema as a ZodObject for backwards compatibility with @@ -2036,6 +2037,7 @@ export const AlertsPageItemSchema = z.object({ }) .optional(), executionErrors: z.array(AlertErrorSchema).optional(), + windowsLookback: z.number().int().min(1).optional(), }); export type AlertsPageItem = z.infer; From 108e442df806570de4a127907ba12b65b42ce290 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 10:22:48 -0400 Subject: [PATCH 02/26] feat: initial backend wiring using alert history --- packages/api/src/tasks/checkAlerts/index.ts | 77 +++++++++++++++------ 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 2209bf7139..49385a97ac 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -998,6 +998,31 @@ export const processAlert = async ( } }; + // Fire an alert when a codnition is met in M consecutive time windows + const shouldFireBasedOnConsecutiveWindows = async ( + groupKey: string, + ): Promise => { + const numWindowsToLookBack = alert.windowsLookback ?? 1; + + if (numWindowsToLookBack <= 1) { + return true; + } + + const groupFilter = groupKey ? { group: groupKey } : { group: null }; + const alertHistory = await AlertHistory.find({ + alert: new mongoose.Types.ObjectId(alert.id), + ...groupFilter, + createdAt: { $lt: nowInMinsRoundDown }, + }) + .sort({ createdAt: -1 }) + .limit(numWindowsToLookBack - 1); + + return ( + alertHistory.length === numWindowsToLookBack - 1 && + alertHistory.every(h => h.state === AlertState.ALERT) + ); + }; + const sendNotificationIfResolved = async ( previousHistory: AggregatedAlertHistory | undefined, currentHistory: IAlertHistory, @@ -1005,6 +1030,7 @@ export const processAlert = async ( ) => { if ( previousHistory?.state === AlertState.ALERT && + previousHistory?.fired === true && currentHistory.state === AlertState.OK ) { const lastValue = @@ -1042,12 +1068,15 @@ export const processAlert = async ( if (doesExceedThreshold(alert, value)) { history.state = AlertState.ALERT; history.counts += 1; - await trySendNotification({ - state: AlertState.ALERT, - group: '', - totalCount: value, - startTime: alertTimestamp, - }); + if (await shouldFireBasedOnConsecutiveWindows('')) { + history.fired = true; + await trySendNotification({ + state: AlertState.ALERT, + group: '', + totalCount: value, + startTime: alertTimestamp, + }); + } } // Auto-resolve @@ -1107,12 +1136,15 @@ export const processAlert = async ( history.lastValues.push({ count: 0, startTime: bucketStart }); history.state = AlertState.ALERT; history.counts += 1; - await trySendNotification({ - state: AlertState.ALERT, - group: '', - totalCount: 0, - startTime: bucketStart, - }); + if (await shouldFireBasedOnConsecutiveWindows('')) { + history.fired = true; + await trySendNotification({ + state: AlertState.ALERT, + group: '', + totalCount: 0, + startTime: bucketStart, + }); + } } else if (!hasGroupBy || !hasAlertsInPreviousMap) { // For grouped alerts, if there are alerts in the previous map, // we will handle creating a history as part of auto-resolve later @@ -1141,15 +1173,17 @@ export const processAlert = async ( if (doesExceedThreshold(alert, value)) { history.state = AlertState.ALERT; - await trySendNotification({ - state: AlertState.ALERT, - group: groupKey, - totalCount: value, - startTime: bucketStart, - attributes, - }); - history.counts += 1; + if (await shouldFireBasedOnConsecutiveWindows(groupKey)) { + history.fired = true; + await trySendNotification({ + state: AlertState.ALERT, + group: groupKey, + totalCount: value, + startTime: bucketStart, + attributes, + }); + } } else { // TODO: if the alert was previously alerting (different bucket), should we set state to OK (plus auto-resolve)? } @@ -1242,6 +1276,7 @@ export interface AggregatedAlertHistory { createdAt: Date; state: AlertState; group?: string; + fired?: boolean; } /** @@ -1296,6 +1331,7 @@ export const getPreviousAlertHistories = async ( }, createdAt: { $first: '$createdAt' }, state: { $first: '$state' }, + fired: { $first: '$fired' }, }, }, { @@ -1304,6 +1340,7 @@ export const getPreviousAlertHistories = async ( createdAt: 1, state: 1, group: '$_id.group', + fired: 1, }, }, ]); From d54dbc830605e9731858a9e7504f48d14b97de80 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 10:23:01 -0400 Subject: [PATCH 03/26] feat: update alert configuration modal(s) --- packages/app/src/DBSearchPageAlertModal.tsx | 27 ++++++++++++++++ .../DBEditTimeChartForm/TileAlertEditor.tsx | 31 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index 9f2cd43098..a46fd23e40 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -77,6 +77,7 @@ const SavedSearchAlertFormSchema = z scheduleStartAt: scheduleStartAtSchema, thresholdType: z.nativeEnum(AlertThresholdType), channel: zAlertChannel, + windowsLookback: z.number().int().min(1).optional(), }) .passthrough() .superRefine(validateAlertScheduleOffsetMinutes) @@ -150,6 +151,7 @@ const AlertForm = ({ const groupByValue = useWatch({ control, name: 'groupBy' }); const threshold = useWatch({ control, name: 'threshold' }); const thresholdMax = useWatch({ control, name: 'thresholdMax' }); + const windowsLookback = useWatch({ control, name: 'windowsLookback' }); const maxScheduleOffsetMinutes = Math.max( intervalToMinutes(interval ?? '5m') - 1, 0, @@ -241,6 +243,31 @@ const AlertForm = ({ /> )} /> + + for + + ( + { + const num = typeof v === 'number' ? v : 1; + field.onChange(num > 1 ? num : undefined); + }} + min={1} + size="xs" + w={70} + /> + )} + /> + + {(windowsLookback ?? 1) === 1 + ? 'window' + : 'consecutive windows'} + via diff --git a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx index 187aaaaa69..73fac9093c 100644 --- a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx @@ -72,6 +72,10 @@ export function TileAlertEditor({ control, name: 'alert.scheduleOffsetMinutes', }); + const alertWindowsLookback = useWatch({ + control, + name: 'alert.windowsLookback', + }); const maxAlertScheduleOffsetMinutes = alert?.interval ? Math.max(intervalToMinutes(alert.interval) - 1, 0) : 0; @@ -206,7 +210,32 @@ export function TileAlertEditor({ )} /> - window via + for + + ( + { + const num = typeof v === 'number' ? v : 1; + field.onChange(num > 1 ? num : undefined); + }} + min={1} + size="xs" + w={70} + /> + )} + /> + + {(alertWindowsLookback ?? 1) === 1 + ? 'window' + : 'consecutive windows'} + + + via Date: Tue, 16 Jun 2026 10:34:52 -0400 Subject: [PATCH 04/26] feat: write a couple of tests --- .../checkAlerts/__tests__/checkAlerts.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 69d796fcb3..26414315ca 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -8421,6 +8421,135 @@ describe('checkAlerts', () => { ); expect((await Alert.findById(details.alert.id))!.state).toBe('OK'); }); + + describe('multi-window alerting (windowsLookback)', () => { + it('fires on the first violation when windowsLookback=1', async () => { + const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = + await setupSavedSearchAlertTest(); + + const details = await createAlertDetails( + team, + source, + { + source: AlertSource.SAVED_SEARCH, + channel: { type: 'webhook', webhookId: webhook._id.toString() }, + interval: '5m', + thresholdType: AlertThresholdType.ABOVE, + threshold: 0, + savedSearchId: savedSearch.id, + windowsLookback: 1, + }, + { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, + ); + + await bulkInsertLogs([ + { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:05:00Z'), SeverityText: 'error', Body: 'err' }, + ]); + + await processAlertAtTime(new Date('2024-01-01T00:12:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + + const histories = await AlertHistory.find({ alert: details.alert.id }); + expect(histories).toHaveLength(1); + expect(histories[0].state).toBe('ALERT'); + expect(histories[0].fired).toBe(true); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); + }); + + it('suppresses notification until all M windows have violated', async () => { + const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = + await setupSavedSearchAlertTest(); + + jest.spyOn(slack, 'postMessageToWebhook').mockResolvedValue(null as any); + + const details = await createAlertDetails( + team, + source, + { + source: AlertSource.SAVED_SEARCH, + channel: { type: 'webhook', webhookId: webhook._id.toString() }, + interval: '5m', + thresholdType: AlertThresholdType.ABOVE, + threshold: 0, + savedSearchId: savedSearch.id, + windowsLookback: 3, + }, + { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, + ); + + await bulkInsertLogs([ + { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:05:00Z'), SeverityText: 'error', Body: 'err' }, + { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:10:00Z'), SeverityText: 'error', Body: 'err' }, + { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:15:00Z'), SeverityText: 'error', Body: 'err' }, + ]); + + // don't fire on windows 1 or 2 since we need 3 consecutive violations to fire + await processAlertAtTime(new Date('2024-01-01T00:12:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); + let histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + expect(histories).toHaveLength(1); + expect(histories[0].state).toBe('ALERT'); + expect(histories[0].fired).toBeFalsy(); // shouldn't fire yet + + await processAlertAtTime(new Date('2024-01-01T00:17:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); + histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + expect(histories).toHaveLength(2); + expect(histories[1].state).toBe('ALERT'); + expect(histories[1].fired).toBeFalsy(); // shouldn't fire yet + + await processAlertAtTime(new Date('2024-01-01T00:22:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); + histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + expect(histories).toHaveLength(3); + expect(histories[2].state).toBe('ALERT'); + expect(histories[2].fired).toBe(true); // fires here b/c it's the third consecutive violation + }); + + it('does not send a resolution notification when no alert had previously fired', async () => { + const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = + await setupSavedSearchAlertTest(); + + jest.spyOn(slack, 'postMessageToWebhook').mockResolvedValue(null as any); + + const details = await createAlertDetails( + team, + source, + { + source: AlertSource.SAVED_SEARCH, + channel: { type: 'webhook', webhookId: webhook._id.toString() }, + interval: '5m', + thresholdType: AlertThresholdType.ABOVE, + threshold: 1, // threshold=1 so zero-fill (no data) produces OK, not ALERT + savedSearchId: savedSearch.id, + windowsLookback: 3, + }, + { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, + ); + + await bulkInsertLogs([ + { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:05:00Z'), SeverityText: 'error', Body: 'err' }, + { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:10:00Z'), SeverityText: 'error', Body: 'err' }, + ]); + + await processAlertAtTime(new Date('2024-01-01T00:12:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); + + await processAlertAtTime(new Date('2024-01-01T00:17:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); + + // window 3 is ok (no violation) + await processAlertAtTime(new Date('2024-01-01T00:22:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); // webhook should never fire in this case + + const histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + expect(histories).toHaveLength(3); + expect(histories[0].state).toBe('ALERT'); + expect(histories[0].fired).toBeFalsy(); + expect(histories[1].state).toBe('ALERT'); + expect(histories[1].fired).toBeFalsy(); + expect(histories[2].state).toBe('OK'); + }); + }); }); describe('processAlert with materialized views', () => { From 4914f9866059b7bbe7a20ac2d78f63fb40a4cbb9 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 11:03:15 -0400 Subject: [PATCH 05/26] fix: typo --- packages/api/src/tasks/checkAlerts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 49385a97ac..21ea9737e4 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -998,7 +998,7 @@ export const processAlert = async ( } }; - // Fire an alert when a codnition is met in M consecutive time windows + // Fire an alert when a condition is met in M consecutive time windows const shouldFireBasedOnConsecutiveWindows = async ( groupKey: string, ): Promise => { From fa2ea77ebcaebccdf1af9938619dd776348f8f54 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 11:03:56 -0400 Subject: [PATCH 06/26] fix: backwards compat --- packages/api/src/tasks/checkAlerts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 21ea9737e4..4329e19983 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1030,7 +1030,7 @@ export const processAlert = async ( ) => { if ( previousHistory?.state === AlertState.ALERT && - previousHistory?.fired === true && + previousHistory?.fired !== false && currentHistory.state === AlertState.OK ) { const lastValue = From 25a403d8460b0883c0690b89159a527b56382b01 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 13:54:25 -0400 Subject: [PATCH 07/26] fix: write `fired=false` back to mongo --- packages/api/src/tasks/checkAlerts/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 4329e19983..54808fb0e2 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1076,6 +1076,8 @@ export const processAlert = async ( totalCount: value, startTime: alertTimestamp, }); + } else { + history.fired = false; } } @@ -1144,6 +1146,8 @@ export const processAlert = async ( totalCount: 0, startTime: bucketStart, }); + } else { + history.fired = false; } } else if (!hasGroupBy || !hasAlertsInPreviousMap) { // For grouped alerts, if there are alerts in the previous map, @@ -1183,6 +1187,8 @@ export const processAlert = async ( startTime: bucketStart, attributes, }); + } else { + history.fired = false; } } else { // TODO: if the alert was previously alerting (different bucket), should we set state to OK (plus auto-resolve)? From 07079ae3caf7872b6f91e710744723074c7d2fae Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 14:00:49 -0400 Subject: [PATCH 08/26] fix: naming --- packages/api/src/controllers/alerts.ts | 2 +- packages/api/src/models/alert.ts | 4 ++-- packages/api/src/routers/api/alerts.ts | 2 +- .../tasks/checkAlerts/__tests__/checkAlerts.test.ts | 10 +++++----- packages/api/src/tasks/checkAlerts/index.ts | 2 +- packages/api/src/utils/zod.ts | 2 +- packages/app/src/DBSearchPageAlertModal.tsx | 8 ++++---- .../components/DBEditTimeChartForm/TileAlertEditor.tsx | 8 ++++---- packages/common-utils/src/types.ts | 4 ++-- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index fecf33799b..add659284e 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -157,7 +157,7 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial => { tileId: alert.tileId, // Multi-window alerting - windowsLookback: alert.windowsLookback, + numConsecutiveWindows: alert.numConsecutiveWindows, }; }; diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index c5dd259971..49a2c3ef6b 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -85,7 +85,7 @@ export interface IAlert { }; // Multi-window alerting: fire only after N violations in M consecutive windows - windowsLookback?: number; + numConsecutiveWindows?: number; // Errors recorded during the most recent execution executionErrors?: IAlertError[]; @@ -193,7 +193,7 @@ const AlertSchema = new Schema( type: String, required: false, }, - windowsLookback: { + numConsecutiveWindows: { type: Number, required: false, min: 1, diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index e3f30e186a..9da8c63b31 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -84,7 +84,7 @@ const formatAlertResponse = ( 'createdAt', 'updatedAt', 'executionErrors', - 'windowsLookback', + 'numConsecutiveWindows', ]), }; }; diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 26414315ca..b712a517ae 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -8422,8 +8422,8 @@ describe('checkAlerts', () => { expect((await Alert.findById(details.alert.id))!.state).toBe('OK'); }); - describe('multi-window alerting (windowsLookback)', () => { - it('fires on the first violation when windowsLookback=1', async () => { + describe('multi-window alerting (numConsecutiveWindows)', () => { + it('fires on the first violation when numConsecutiveWindows=1', async () => { const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = await setupSavedSearchAlertTest(); @@ -8437,7 +8437,7 @@ describe('checkAlerts', () => { thresholdType: AlertThresholdType.ABOVE, threshold: 0, savedSearchId: savedSearch.id, - windowsLookback: 1, + numConsecutiveWindows: 1, }, { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, ); @@ -8471,7 +8471,7 @@ describe('checkAlerts', () => { thresholdType: AlertThresholdType.ABOVE, threshold: 0, savedSearchId: savedSearch.id, - windowsLookback: 3, + numConsecutiveWindows: 3, }, { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, ); @@ -8521,7 +8521,7 @@ describe('checkAlerts', () => { thresholdType: AlertThresholdType.ABOVE, threshold: 1, // threshold=1 so zero-fill (no data) produces OK, not ALERT savedSearchId: savedSearch.id, - windowsLookback: 3, + numConsecutiveWindows: 3, }, { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, ); diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 54808fb0e2..ce3bcff186 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1002,7 +1002,7 @@ export const processAlert = async ( const shouldFireBasedOnConsecutiveWindows = async ( groupKey: string, ): Promise => { - const numWindowsToLookBack = alert.windowsLookback ?? 1; + const numWindowsToLookBack = alert.numConsecutiveWindows ?? 1; if (numWindowsToLookBack <= 1) { return true; diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 81f61f5700..a7d817c1cb 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -620,7 +620,7 @@ export const alertSchema = z name: z.string().min(1).max(512).nullish(), message: z.string().min(1).max(4096).nullish(), note: alertNoteSchema, - windowsLookback: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).optional(), }) .and(zSavedSearchAlert.or(zTileAlert)) .superRefine(validateAlertScheduleOffsetMinutes) diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index a46fd23e40..a154fd3915 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -77,7 +77,7 @@ const SavedSearchAlertFormSchema = z scheduleStartAt: scheduleStartAtSchema, thresholdType: z.nativeEnum(AlertThresholdType), channel: zAlertChannel, - windowsLookback: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).optional(), }) .passthrough() .superRefine(validateAlertScheduleOffsetMinutes) @@ -151,7 +151,7 @@ const AlertForm = ({ const groupByValue = useWatch({ control, name: 'groupBy' }); const threshold = useWatch({ control, name: 'threshold' }); const thresholdMax = useWatch({ control, name: 'thresholdMax' }); - const windowsLookback = useWatch({ control, name: 'windowsLookback' }); + const numConsecutiveWindows = useWatch({ control, name: 'numConsecutiveWindows' }); const maxScheduleOffsetMinutes = Math.max( intervalToMinutes(interval ?? '5m') - 1, 0, @@ -248,7 +248,7 @@ const AlertForm = ({ ( - {(windowsLookback ?? 1) === 1 + {(numConsecutiveWindows ?? 1) === 1 ? 'window' : 'consecutive windows'} diff --git a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx index 73fac9093c..c0b5d0cf07 100644 --- a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx @@ -72,9 +72,9 @@ export function TileAlertEditor({ control, name: 'alert.scheduleOffsetMinutes', }); - const alertWindowsLookback = useWatch({ + const alertnumConsecutiveWindows = useWatch({ control, - name: 'alert.windowsLookback', + name: 'alert.numConsecutiveWindows', }); const maxAlertScheduleOffsetMinutes = alert?.interval ? Math.max(intervalToMinutes(alert.interval) - 1, 0) @@ -214,7 +214,7 @@ export function TileAlertEditor({ ( - {(alertWindowsLookback ?? 1) === 1 + {(alertnumConsecutiveWindows ?? 1) === 1 ? 'window' : 'consecutive windows'} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index c0eaafe41c..811fc1b48f 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -598,7 +598,7 @@ export const AlertBaseObjectSchema = z.object({ until: z.string(), }) .optional(), - windowsLookback: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).optional(), }); // Keep AlertBaseSchema as a ZodObject for backwards compatibility with @@ -2037,7 +2037,7 @@ export const AlertsPageItemSchema = z.object({ }) .optional(), executionErrors: z.array(AlertErrorSchema).optional(), - windowsLookback: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).optional(), }); export type AlertsPageItem = z.infer; From e78fd961ac31fc30a79a2a6ef3429ccaf420cf9e Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 16 Jun 2026 14:02:47 -0400 Subject: [PATCH 09/26] refactor: group key can be optional --- packages/api/src/tasks/checkAlerts/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index ce3bcff186..6203f4ea91 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1000,7 +1000,7 @@ export const processAlert = async ( // Fire an alert when a condition is met in M consecutive time windows const shouldFireBasedOnConsecutiveWindows = async ( - groupKey: string, + groupKey?: string, ): Promise => { const numWindowsToLookBack = alert.numConsecutiveWindows ?? 1; @@ -1068,7 +1068,7 @@ export const processAlert = async ( if (doesExceedThreshold(alert, value)) { history.state = AlertState.ALERT; history.counts += 1; - if (await shouldFireBasedOnConsecutiveWindows('')) { + if (await shouldFireBasedOnConsecutiveWindows()) { history.fired = true; await trySendNotification({ state: AlertState.ALERT, @@ -1138,7 +1138,7 @@ export const processAlert = async ( history.lastValues.push({ count: 0, startTime: bucketStart }); history.state = AlertState.ALERT; history.counts += 1; - if (await shouldFireBasedOnConsecutiveWindows('')) { + if (await shouldFireBasedOnConsecutiveWindows()) { history.fired = true; await trySendNotification({ state: AlertState.ALERT, From a55f73e5668ecef7b9e8a40937af2c95743fa240 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Fri, 19 Jun 2026 08:16:50 -0400 Subject: [PATCH 10/26] chore: lint --- .../checkAlerts/__tests__/checkAlerts.test.ts | 162 +++++++++++++++--- packages/app/src/DBSearchPageAlertModal.tsx | 5 +- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 87c08fca97..176fb0561c 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -8424,8 +8424,15 @@ describe('checkAlerts', () => { describe('multi-window alerting (numConsecutiveWindows)', () => { it('fires on the first violation when numConsecutiveWindows=1', async () => { - const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = - await setupSavedSearchAlertTest(); + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); const details = await createAlertDetails( team, @@ -8443,10 +8450,22 @@ describe('checkAlerts', () => { ); await bulkInsertLogs([ - { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:05:00Z'), SeverityText: 'error', Body: 'err' }, + { + ServiceName: 'api', + Timestamp: new Date('2024-01-01T00:05:00Z'), + SeverityText: 'error', + Body: 'err', + }, ]); - await processAlertAtTime(new Date('2024-01-01T00:12:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:12:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); const histories = await AlertHistory.find({ alert: details.alert.id }); expect(histories).toHaveLength(1); @@ -8456,10 +8475,19 @@ describe('checkAlerts', () => { }); it('suppresses notification until all M windows have violated', async () => { - const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = - await setupSavedSearchAlertTest(); + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); - jest.spyOn(slack, 'postMessageToWebhook').mockResolvedValue(null as any); + jest + .spyOn(slack, 'postMessageToWebhook') + .mockResolvedValue(null as any); const details = await createAlertDetails( team, @@ -8477,39 +8505,90 @@ describe('checkAlerts', () => { ); await bulkInsertLogs([ - { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:05:00Z'), SeverityText: 'error', Body: 'err' }, - { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:10:00Z'), SeverityText: 'error', Body: 'err' }, - { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:15:00Z'), SeverityText: 'error', Body: 'err' }, + { + ServiceName: 'api', + Timestamp: new Date('2024-01-01T00:05:00Z'), + SeverityText: 'error', + Body: 'err', + }, + { + ServiceName: 'api', + Timestamp: new Date('2024-01-01T00:10:00Z'), + SeverityText: 'error', + Body: 'err', + }, + { + ServiceName: 'api', + Timestamp: new Date('2024-01-01T00:15:00Z'), + SeverityText: 'error', + Body: 'err', + }, ]); // don't fire on windows 1 or 2 since we need 3 consecutive violations to fire - await processAlertAtTime(new Date('2024-01-01T00:12:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:12:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); - let histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + let histories = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1 }); expect(histories).toHaveLength(1); expect(histories[0].state).toBe('ALERT'); expect(histories[0].fired).toBeFalsy(); // shouldn't fire yet - await processAlertAtTime(new Date('2024-01-01T00:17:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:17:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); - histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + histories = await AlertHistory.find({ alert: details.alert.id }).sort({ + createdAt: 1, + }); expect(histories).toHaveLength(2); expect(histories[1].state).toBe('ALERT'); expect(histories[1].fired).toBeFalsy(); // shouldn't fire yet - await processAlertAtTime(new Date('2024-01-01T00:22:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:22:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); - histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + histories = await AlertHistory.find({ alert: details.alert.id }).sort({ + createdAt: 1, + }); expect(histories).toHaveLength(3); expect(histories[2].state).toBe('ALERT'); expect(histories[2].fired).toBe(true); // fires here b/c it's the third consecutive violation }); it('does not send a resolution notification when no alert had previously fired', async () => { - const { team, webhook, connection, source, savedSearch, teamWebhooksById, clickhouseClient } = - await setupSavedSearchAlertTest(); + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); - jest.spyOn(slack, 'postMessageToWebhook').mockResolvedValue(null as any); + jest + .spyOn(slack, 'postMessageToWebhook') + .mockResolvedValue(null as any); const details = await createAlertDetails( team, @@ -8527,21 +8606,54 @@ describe('checkAlerts', () => { ); await bulkInsertLogs([ - { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:05:00Z'), SeverityText: 'error', Body: 'err' }, - { ServiceName: 'api', Timestamp: new Date('2024-01-01T00:10:00Z'), SeverityText: 'error', Body: 'err' }, + { + ServiceName: 'api', + Timestamp: new Date('2024-01-01T00:05:00Z'), + SeverityText: 'error', + Body: 'err', + }, + { + ServiceName: 'api', + Timestamp: new Date('2024-01-01T00:10:00Z'), + SeverityText: 'error', + Body: 'err', + }, ]); - await processAlertAtTime(new Date('2024-01-01T00:12:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:12:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); - await processAlertAtTime(new Date('2024-01-01T00:17:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:17:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); // window 3 is ok (no violation) - await processAlertAtTime(new Date('2024-01-01T00:22:00Z'), details, clickhouseClient, connection, alertProvider, teamWebhooksById); + await processAlertAtTime( + new Date('2024-01-01T00:22:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); // webhook should never fire in this case - const histories = await AlertHistory.find({ alert: details.alert.id }).sort({ createdAt: 1 }); + const histories = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1 }); expect(histories).toHaveLength(3); expect(histories[0].state).toBe('ALERT'); expect(histories[0].fired).toBeFalsy(); diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index a154fd3915..f90bcafc4b 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -151,7 +151,10 @@ const AlertForm = ({ const groupByValue = useWatch({ control, name: 'groupBy' }); const threshold = useWatch({ control, name: 'threshold' }); const thresholdMax = useWatch({ control, name: 'thresholdMax' }); - const numConsecutiveWindows = useWatch({ control, name: 'numConsecutiveWindows' }); + const numConsecutiveWindows = useWatch({ + control, + name: 'numConsecutiveWindows', + }); const maxScheduleOffsetMinutes = Math.max( intervalToMinutes(interval ?? '5m') - 1, 0, From 436207e94761c204f7cab5888944d310f926cad1 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Mon, 22 Jun 2026 14:26:40 -0400 Subject: [PATCH 11/26] feat: initial pending state work --- packages/api/src/models/alert.ts | 1 + .../checkAlerts/__tests__/checkAlerts.test.ts | 8 +++--- packages/api/src/tasks/checkAlerts/index.ts | 25 ++++++++++++++----- .../tasks/checkAlerts/providers/default.ts | 4 ++- packages/app/src/AlertsPage.tsx | 19 ++++++++++++++ packages/app/src/DBDashboardPage.tsx | 2 +- .../app/src/components/AlertStatusIcon.tsx | 13 +++++++++- packages/common-utils/src/types.ts | 1 + 8 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index 49a2c3ef6b..318a876330 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -14,6 +14,7 @@ export enum AlertState { DISABLED = 'DISABLED', INSUFFICIENT_DATA = 'INSUFFICIENT_DATA', OK = 'OK', + PENDING = 'PENDING', } export interface IAlertError { diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 176fb0561c..213d8ded1b 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -8539,7 +8539,7 @@ describe('checkAlerts', () => { alert: details.alert.id, }).sort({ createdAt: 1 }); expect(histories).toHaveLength(1); - expect(histories[0].state).toBe('ALERT'); + expect(histories[0].state).toBe('PENDING'); expect(histories[0].fired).toBeFalsy(); // shouldn't fire yet await processAlertAtTime( @@ -8555,7 +8555,7 @@ describe('checkAlerts', () => { createdAt: 1, }); expect(histories).toHaveLength(2); - expect(histories[1].state).toBe('ALERT'); + expect(histories[1].state).toBe('PENDING'); expect(histories[1].fired).toBeFalsy(); // shouldn't fire yet await processAlertAtTime( @@ -8655,9 +8655,9 @@ describe('checkAlerts', () => { alert: details.alert.id, }).sort({ createdAt: 1 }); expect(histories).toHaveLength(3); - expect(histories[0].state).toBe('ALERT'); + expect(histories[0].state).toBe('PENDING'); expect(histories[0].fired).toBeFalsy(); - expect(histories[1].state).toBe('ALERT'); + expect(histories[1].state).toBe('PENDING'); expect(histories[1].fired).toBeFalsy(); expect(histories[2].state).toBe('OK'); }); diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 6203f4ea91..18c5ce1357 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1009,17 +1009,23 @@ export const processAlert = async ( } const groupFilter = groupKey ? { group: groupKey } : { group: null }; + const earliestAllowedTime = new Date( + nowInMinsRoundDown.getTime() - + (numWindowsToLookBack - 1) * windowSizeInMins * 60_000, + ); const alertHistory = await AlertHistory.find({ alert: new mongoose.Types.ObjectId(alert.id), ...groupFilter, - createdAt: { $lt: nowInMinsRoundDown }, + createdAt: { $gte: earliestAllowedTime, $lt: nowInMinsRoundDown }, }) .sort({ createdAt: -1 }) .limit(numWindowsToLookBack - 1); return ( alertHistory.length === numWindowsToLookBack - 1 && - alertHistory.every(h => h.state === AlertState.ALERT) + alertHistory.every( + h => h.state === AlertState.ALERT || h.state === AlertState.PENDING, + ) ); }; @@ -1066,9 +1072,9 @@ export const processAlert = async ( history.lastValues.push({ count: value, startTime: alertTimestamp }); if (doesExceedThreshold(alert, value)) { - history.state = AlertState.ALERT; history.counts += 1; if (await shouldFireBasedOnConsecutiveWindows()) { + history.state = AlertState.ALERT; history.fired = true; await trySendNotification({ state: AlertState.ALERT, @@ -1077,6 +1083,7 @@ export const processAlert = async ( startTime: alertTimestamp, }); } else { + history.state = AlertState.PENDING; history.fired = false; } } @@ -1131,14 +1138,18 @@ export const processAlert = async ( const hasAlertsInPreviousMap = previousMap .values() - .some(history => history.state === AlertState.ALERT); + .some( + history => + history.state === AlertState.ALERT || + history.state === AlertState.PENDING, + ); if (zeroValueIsAlert) { const history = getOrCreateHistory(''); history.lastValues.push({ count: 0, startTime: bucketStart }); - history.state = AlertState.ALERT; history.counts += 1; if (await shouldFireBasedOnConsecutiveWindows()) { + history.state = AlertState.ALERT; history.fired = true; await trySendNotification({ state: AlertState.ALERT, @@ -1147,6 +1158,7 @@ export const processAlert = async ( startTime: bucketStart, }); } else { + history.state = AlertState.PENDING; history.fired = false; } } else if (!hasGroupBy || !hasAlertsInPreviousMap) { @@ -1176,9 +1188,9 @@ export const processAlert = async ( const history = getOrCreateHistory(groupKey); if (doesExceedThreshold(alert, value)) { - history.state = AlertState.ALERT; history.counts += 1; if (await shouldFireBasedOnConsecutiveWindows(groupKey)) { + history.state = AlertState.ALERT; history.fired = true; await trySendNotification({ state: AlertState.ALERT, @@ -1188,6 +1200,7 @@ export const processAlert = async ( attributes, }); } else { + history.state = AlertState.PENDING; history.fired = false; } } else { diff --git a/packages/api/src/tasks/checkAlerts/providers/default.ts b/packages/api/src/tasks/checkAlerts/providers/default.ts index 64a0e0c8aa..7d8a77a2c2 100644 --- a/packages/api/src/tasks/checkAlerts/providers/default.ts +++ b/packages/api/src/tasks/checkAlerts/providers/default.ts @@ -380,7 +380,9 @@ export default class DefaultAlertProvider implements AlertProvider { const finalState = historiesToCheck.some(h => h.state === AlertState.ALERT) ? AlertState.ALERT - : AlertState.OK; + : historiesToCheck.some(h => h.state === AlertState.PENDING) + ? AlertState.PENDING + : AlertState.OK; // Update alert state + errors based on this execution await Alert.updateOne( diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index e4dfefd809..69abb5c1b0 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -30,6 +30,7 @@ import { IconChevronDown, IconChevronRight, IconHelpCircle, + IconHourglass, IconInfoCircleFilled, IconNote, IconSearch, @@ -213,6 +214,11 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { Alert )} + {alert.state === AlertState.PENDING && ( + + Pending + + )} {alert.state === AlertState.OK && Ok} {alert.state === AlertState.DISABLED && ( @@ -269,6 +275,9 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT); + const pendingAlerts = alerts.filter( + alert => alert.state === AlertState.PENDING, + ); const okData = alerts.filter(alert => alert.state === AlertState.OK); return ( @@ -283,6 +292,16 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { ))} )} + {pendingAlerts.length > 0 && ( +
+ + Pending + + {pendingAlerts.map(alert => ( + + ))} +
+ )}
OK diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index dbae6dcc13..8b5d563fe3 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -589,7 +589,7 @@ const Tile = forwardRef( if (alert.state === AlertState.OK) { return 'green'; } - if (alert.silenced?.at) { + if (alert.silenced?.at || alert.state === AlertState.PENDING) { return 'yellow'; } return 'red'; diff --git a/packages/app/src/components/AlertStatusIcon.tsx b/packages/app/src/components/AlertStatusIcon.tsx index 310311155a..f9638cbd50 100644 --- a/packages/app/src/components/AlertStatusIcon.tsx +++ b/packages/app/src/components/AlertStatusIcon.tsx @@ -9,12 +9,17 @@ export function AlertStatusIcon({ }) { if (!Array.isArray(alerts) || alerts.length === 0) return null; const alertingCount = alerts.filter(a => a.state === AlertState.ALERT).length; + const pendingCount = alerts.filter( + a => a.state === AlertState.PENDING, + ).length; return ( 0 ? `${alertingCount} alert${alertingCount > 1 ? 's' : ''} triggered` - : 'Alerts configured' + : pendingCount > 0 + ? `${pendingCount} alert${pendingCount > 1 ? 's' : ''} pending` + : 'Alerts configured' } > {alertingCount > 0 ? ( @@ -23,6 +28,12 @@ export function AlertStatusIcon({ color="var(--mantine-color-red-filled)" data-testid="alert-status-icon-triggered" /> + ) : pendingCount > 0 ? ( + ) : ( )} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 811fc1b48f..d5752e72aa 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -425,6 +425,7 @@ export enum AlertState { DISABLED = 'DISABLED', INSUFFICIENT_DATA = 'INSUFFICIENT_DATA', OK = 'OK', + PENDING = 'PENDING', } export enum AlertErrorType { From cc0dc5bf67c4cce173b5ecd612d734213cdc99aa Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Mon, 22 Jun 2026 14:36:29 -0400 Subject: [PATCH 12/26] chore: changeset --- .changeset/rare-spoons-change.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/rare-spoons-change.md diff --git a/.changeset/rare-spoons-change.md b/.changeset/rare-spoons-change.md new file mode 100644 index 0000000000..e68e316b5a --- /dev/null +++ b/.changeset/rare-spoons-change.md @@ -0,0 +1,9 @@ +--- +"@hyperdx/common-utils": minor +"@hyperdx/api": minor +"@hyperdx/app": minor +--- + +Adding consecutive-window configuration to alerts, so that you can specify a condition like "only fire this alert after some condition is met for N consecutive windows." This helps prevent flaky alerts (and pages), and cut down on alert noise in many cases. + +Also adds a `PENDING` alert state for alarms that _will_ fire if current trends continue. From 8e2dc737e8ab1db5cfcf7297e16fba96bf584a60 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Mon, 22 Jun 2026 14:38:38 -0400 Subject: [PATCH 13/26] chore: add comment + more windows --- packages/api/src/tasks/checkAlerts/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 18c5ce1357..9df153c23c 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1009,9 +1009,14 @@ export const processAlert = async ( } const groupFilter = groupKey ? { group: groupKey } : { group: null }; + + // filters the alert history to only include the last M _eligible_ windows (plus a couple of) + // windows of buffer, in order to ensure that we don't fire in the case where e.g. + // there's an offending log line, then the service is down for a while, then it comes back and there's + // another offending log line and they look consecutive, but are not. const earliestAllowedTime = new Date( nowInMinsRoundDown.getTime() - - (numWindowsToLookBack - 1) * windowSizeInMins * 60_000, + (numWindowsToLookBack + 1) * windowSizeInMins * 60_000, ); const alertHistory = await AlertHistory.find({ alert: new mongoose.Types.ObjectId(alert.id), From a114014e2e6bcc23a15b4c7423101d907e5e78cc Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 14:07:45 -0400 Subject: [PATCH 14/26] fix: make the alert badge orange --- packages/app/src/AlertsPage.tsx | 2 +- packages/app/src/DBDashboardPage.tsx | 5 ++++- packages/app/src/components/AlertStatusIcon.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 69abb5c1b0..395e6f6c0b 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -215,7 +215,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { )} {alert.state === AlertState.PENDING && ( - + Pending )} diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 8b5d563fe3..325b155921 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -589,9 +589,12 @@ const Tile = forwardRef( if (alert.state === AlertState.OK) { return 'green'; } - if (alert.silenced?.at || alert.state === AlertState.PENDING) { + if (alert.silenced?.at) { return 'yellow'; } + if (alert.state === AlertState.PENDING) { + return 'orange'; + } return 'red'; }, [alert]); diff --git a/packages/app/src/components/AlertStatusIcon.tsx b/packages/app/src/components/AlertStatusIcon.tsx index f9638cbd50..74b3bd5f31 100644 --- a/packages/app/src/components/AlertStatusIcon.tsx +++ b/packages/app/src/components/AlertStatusIcon.tsx @@ -31,7 +31,7 @@ export function AlertStatusIcon({ ) : pendingCount > 0 ? ( ) : ( From 3394ecd52003c5cc9c189815ab5947966ffa19ca Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 14:14:52 -0400 Subject: [PATCH 15/26] fix: show state as pending in grouped history if there's a pending alert --- packages/api/src/controllers/alertHistory.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/api/src/controllers/alertHistory.ts b/packages/api/src/controllers/alertHistory.ts index 93ed9c1468..86f5701d5b 100644 --- a/packages/api/src/controllers/alertHistory.ts +++ b/packages/api/src/controllers/alertHistory.ts @@ -18,14 +18,24 @@ type GroupedAlertHistory = { lastValues: IAlertHistory['lastValues'][]; }; +function groupStateToOverallState(states: string[]): AlertState { + if (states.includes(AlertState.ALERT)) { + return AlertState.ALERT; + } + + if (states.includes(AlertState.PENDING)) { + return AlertState.PENDING; + } + + return AlertState.OK; +} + function mapGroupedHistories( groupedHistories: GroupedAlertHistory[], ): Omit[] { return groupedHistories.map(group => ({ createdAt: group._id, - state: group.states.includes(AlertState.ALERT) - ? AlertState.ALERT - : AlertState.OK, + state: groupStateToOverallState(group.states), counts: group.counts, lastValues: group.lastValues .flat() From 1db64277d926a744724baace5352cb4a908a9e38 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 14:18:59 -0400 Subject: [PATCH 16/26] fix: show pending state in alert history as orange --- .../app/src/components/alerts/AlertHistoryCards.tsx | 13 ++++++++++++- packages/app/styles/AlertsPage.module.scss | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/alerts/AlertHistoryCards.tsx b/packages/app/src/components/alerts/AlertHistoryCards.tsx index 26d8ad1025..1095300fa4 100644 --- a/packages/app/src/components/alerts/AlertHistoryCards.tsx +++ b/packages/app/src/components/alerts/AlertHistoryCards.tsx @@ -26,6 +26,17 @@ import styles from '../../../styles/AlertsPage.module.scss'; const HISTORY_ITEMS = 18; +function stateToBgColorClass(state: AlertState) { + switch (state) { + case AlertState.OK: + return styles.ok; + case AlertState.PENDING: + return styles.pending; + default: + return styles.alarm; + } +} + function AlertHistoryCard({ history, alertUrl, @@ -58,7 +69,7 @@ function AlertHistoryCard({
diff --git a/packages/app/styles/AlertsPage.module.scss b/packages/app/styles/AlertsPage.module.scss index cf95722e94..6261985499 100644 --- a/packages/app/styles/AlertsPage.module.scss +++ b/packages/app/styles/AlertsPage.module.scss @@ -39,6 +39,10 @@ background-color: var(--color-bg-danger); } + &.pending { + background-color: var(--color-bg-warning); + } + &.clickable { cursor: pointer; } From 7c6eb3f410c52435f95d3f6b0b11e01a50406c3b Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 14:23:20 -0400 Subject: [PATCH 17/26] fix: handle pending states properly in missing group loop --- packages/api/src/tasks/checkAlerts/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 9df153c23c..52d0dcf844 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1215,16 +1215,17 @@ export const processAlert = async ( } } - // Handle missing groups: If current check found no data, check if any previously alerting groups need to be resolved - // For group-by alerts, check if any previously alerting groups are missing from current data + // Handle missing groups: If current check found no data, check if any previously alerting/pending groups need to be resolved + // For group-by alerts, check if any previously alerting or pending groups are missing from current data if (hasGroupBy && previousMap && previousMap.size > 0) { for (const [previousKey, previousHistory] of previousMap.entries()) { const groupKey = extractGroupKeyFromMapKey(previousKey, alert.id); - // If this group was previously ALERT but is missing from current data and would be resolved by a 0 value, + // If this group was previously ALERT or PENDING but is missing from current data and would be resolved by a 0 value, // create an OK history for the group if ( - previousHistory.state === AlertState.ALERT && + (previousHistory.state === AlertState.ALERT || + previousHistory.state === AlertState.PENDING) && !histories.has(groupKey) && !doesExceedThreshold(alert, 0) ) { @@ -1233,7 +1234,7 @@ export const processAlert = async ( alertId: alert.id, group: groupKey, }, - `Group "${groupKey}" is missing from current data but was previously alerting - creating OK history`, + `Group "${groupKey}" is missing from current data but was previously ${previousHistory.state} - creating OK history`, ); const history = getOrCreateHistory(groupKey); history.lastValues.push({ count: 0, startTime: expectedBuckets[0] }); From 5d0c9cec813e2c8490a72777785c162e051c3db4 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 15:07:52 -0400 Subject: [PATCH 18/26] fix: greptile comment --- packages/api/src/tasks/checkAlerts/index.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 52d0dcf844..c212e58fb5 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1040,7 +1040,8 @@ export const processAlert = async ( groupKey: string, ) => { if ( - previousHistory?.state === AlertState.ALERT && + (previousHistory?.state === AlertState.ALERT || + previousHistory?.state === AlertState.PENDING) && previousHistory?.fired !== false && currentHistory.state === AlertState.OK ) { @@ -1076,6 +1077,7 @@ export const processAlert = async ( : 0; history.lastValues.push({ count: value, startTime: alertTimestamp }); + const previous = previousMap.get(computeHistoryMapKey(alert.id, '')); if (doesExceedThreshold(alert, value)) { history.counts += 1; if (await shouldFireBasedOnConsecutiveWindows()) { @@ -1089,12 +1091,12 @@ export const processAlert = async ( }); } else { history.state = AlertState.PENDING; - history.fired = false; + // Carry forward fired=true if a notification was previously sent and not yet resolved. + history.fired = previous?.fired !== false; } } // Auto-resolve - const previous = previousMap.get(computeHistoryMapKey(alert.id, '')); await sendNotificationIfResolved(previous, history, ''); const historyRecords = Array.from(histories.values()); @@ -1164,7 +1166,10 @@ export const processAlert = async ( }); } else { history.state = AlertState.PENDING; - history.fired = false; + // Carry forward fired=true if a notification was previously sent and not yet resolved. + history.fired = + previousMap.get(computeHistoryMapKey(alert.id, ''))?.fired !== + false; } } else if (!hasGroupBy || !hasAlertsInPreviousMap) { // For grouped alerts, if there are alerts in the previous map, @@ -1206,7 +1211,11 @@ export const processAlert = async ( }); } else { history.state = AlertState.PENDING; - history.fired = false; + // Carry forward fired=true if a notification was previously sent and not yet resolved. + history.fired = + previousMap.get( + computeHistoryMapKey(alert.id, groupKey), + )?.fired !== false; } } else { // TODO: if the alert was previously alerting (different bucket), should we set state to OK (plus auto-resolve)? From 82f9c1188929a476b5b55e0ecba56640fedb4e6f Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 15:15:11 -0400 Subject: [PATCH 19/26] fix: truthiness --- packages/api/src/tasks/checkAlerts/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index c212e58fb5..320c5fbade 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1092,7 +1092,7 @@ export const processAlert = async ( } else { history.state = AlertState.PENDING; // Carry forward fired=true if a notification was previously sent and not yet resolved. - history.fired = previous?.fired !== false; + history.fired = previous?.fired === true; } } @@ -1168,8 +1168,8 @@ export const processAlert = async ( history.state = AlertState.PENDING; // Carry forward fired=true if a notification was previously sent and not yet resolved. history.fired = - previousMap.get(computeHistoryMapKey(alert.id, ''))?.fired !== - false; + previousMap.get(computeHistoryMapKey(alert.id, ''))?.fired === + true; } } else if (!hasGroupBy || !hasAlertsInPreviousMap) { // For grouped alerts, if there are alerts in the previous map, @@ -1215,7 +1215,7 @@ export const processAlert = async ( history.fired = previousMap.get( computeHistoryMapKey(alert.id, groupKey), - )?.fired !== false; + )?.fired === true; } } else { // TODO: if the alert was previously alerting (different bucket), should we set state to OK (plus auto-resolve)? From bee6ae0e19d62139a366f3f0e24f49954cbe916b Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 15:23:22 -0400 Subject: [PATCH 20/26] fix: undefined -> null --- packages/api/src/controllers/alerts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index add659284e..238b069946 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -157,7 +157,7 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial => { tileId: alert.tileId, // Multi-window alerting - numConsecutiveWindows: alert.numConsecutiveWindows, + numConsecutiveWindows: alert.numConsecutiveWindows ?? null, }; }; From bb3e5faeb5418a5935c91f7de9e0066091859b11 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Tue, 23 Jun 2026 15:34:22 -0400 Subject: [PATCH 21/26] fix: use exact time match --- packages/api/src/tasks/checkAlerts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 320c5fbade..17f939f0fd 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1016,7 +1016,7 @@ export const processAlert = async ( // another offending log line and they look consecutive, but are not. const earliestAllowedTime = new Date( nowInMinsRoundDown.getTime() - - (numWindowsToLookBack + 1) * windowSizeInMins * 60_000, + (numWindowsToLookBack - 1) * windowSizeInMins * 60_000, ); const alertHistory = await AlertHistory.find({ alert: new mongoose.Types.ObjectId(alert.id), From 1ed62b0569a953da85ca11f61f529484b7f22e3c Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Wed, 24 Jun 2026 10:58:45 -0400 Subject: [PATCH 22/26] chore: lint --- packages/api/src/models/alert.ts | 2 +- packages/api/src/tasks/checkAlerts/index.ts | 5 ++--- packages/common-utils/src/types.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index 318a876330..e607629f78 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -86,7 +86,7 @@ export interface IAlert { }; // Multi-window alerting: fire only after N violations in M consecutive windows - numConsecutiveWindows?: number; + numConsecutiveWindows?: number | null; // Errors recorded during the most recent execution executionErrors?: IAlertError[]; diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 17f939f0fd..a633e04acb 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -1213,9 +1213,8 @@ export const processAlert = async ( history.state = AlertState.PENDING; // Carry forward fired=true if a notification was previously sent and not yet resolved. history.fired = - previousMap.get( - computeHistoryMapKey(alert.id, groupKey), - )?.fired === true; + previousMap.get(computeHistoryMapKey(alert.id, groupKey)) + ?.fired === true; } } else { // TODO: if the alert was previously alerting (different bucket), should we set state to OK (plus auto-resolve)? diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 2d37e832bf..907f89c695 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2064,7 +2064,7 @@ export const AlertsPageItemSchema = z.object({ }) .optional(), executionErrors: z.array(AlertErrorSchema).optional(), - numConsecutiveWindows: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).nullish(), }); export type AlertsPageItem = z.infer; From 79d737cc0c1c57bcba19e2abb5a698e05dcdff7c Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Wed, 24 Jun 2026 11:06:53 -0400 Subject: [PATCH 23/26] fix: one more nullish zod field --- packages/api/src/utils/zod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index a7d817c1cb..ac6a95624e 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -620,7 +620,7 @@ export const alertSchema = z name: z.string().min(1).max(512).nullish(), message: z.string().min(1).max(4096).nullish(), note: alertNoteSchema, - numConsecutiveWindows: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).nullish(), }) .and(zSavedSearchAlert.or(zTileAlert)) .superRefine(validateAlertScheduleOffsetMinutes) From 2b5dfc3cca16d280ce8d754e7e818ab57bf3e289 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Wed, 24 Jun 2026 11:19:42 -0400 Subject: [PATCH 24/26] fix: one more use of `nullish` --- packages/common-utils/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 907f89c695..725bbaf9a3 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -599,7 +599,7 @@ export const AlertBaseObjectSchema = z.object({ until: z.string(), }) .optional(), - numConsecutiveWindows: z.number().int().min(1).optional(), + numConsecutiveWindows: z.number().int().min(1).nullish(), }); // Keep AlertBaseSchema as a ZodObject for backwards compatibility with From fad2aaedc50cb16ba9a3b9dfd5a4b7d3c4fcdfb4 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Thu, 25 Jun 2026 16:36:42 +0200 Subject: [PATCH 25/26] one db query per alert, shared queue --- .../checkAlerts/__tests__/checkAlerts.test.ts | 227 +++++++++++++++++- packages/api/src/tasks/checkAlerts/index.ts | 160 +++++++++--- .../tasks/checkAlerts/providers/default.ts | 28 ++- .../src/tasks/checkAlerts/providers/index.ts | 3 + .../components/alerts/AlertHistoryCards.tsx | 12 +- 5 files changed, 386 insertions(+), 44 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 13d3152f25..374ff1cbc2 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -25,7 +25,7 @@ import { RAW_SQL_ALERT_TEMPLATE, RAW_SQL_NUMBER_ALERT_TEMPLATE, } from '@/fixtures'; -import Alert, { AlertSource } from '@/models/alert'; +import Alert, { AlertSource, IAlert } from '@/models/alert'; import AlertHistory from '@/models/alertHistory'; import Connection, { IConnection } from '@/models/connection'; import Dashboard, { IDashboard } from '@/models/dashboard'; @@ -37,6 +37,7 @@ import * as checkAlert from '@/tasks/checkAlerts'; import { alertHasGroupBy, doesExceedThreshold, + getConsecutiveWindowHistories, getPreviousAlertHistories, getScheduledWindowStart, parseAlertData, @@ -2114,15 +2115,16 @@ describe('checkAlerts', () => { alertProvider: AlertProvider, teamWebhooksById: Map, ) => { - const previousMap = await getPreviousAlertHistories( - [details.alert.id], - now, - ); + const [previousMap, recentHistoryMap] = await Promise.all([ + getPreviousAlertHistories([details.alert.id], now), + getConsecutiveWindowHistories([details.alert], now), + ]); await processAlert( now, { ...details, previousMap, + recentHistoryMap, }, clickhouseClient, connection.id, @@ -8773,6 +8775,110 @@ describe('checkAlerts', () => { expect(histories[1].fired).toBeFalsy(); expect(histories[2].state).toBe('OK'); }); + + it('tracks consecutive windows independently per group', async () => { + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + jest + .spyOn(slack, 'postMessageToWebhook') + .mockResolvedValue(null as any); + + const details = await createAlertDetails( + team, + source, + { + source: AlertSource.SAVED_SEARCH, + channel: { type: 'webhook', webhookId: webhook._id.toString() }, + interval: '5m', + thresholdType: AlertThresholdType.ABOVE, + threshold: 0, + savedSearchId: savedSearch.id, + groupBy: 'ServiceName', + numConsecutiveWindows: 2, + }, + { taskType: AlertTaskType.SAVED_SEARCH, savedSearch }, + ); + + // Window 1: only service-a violates. + // Window 2: both service-a (2nd consecutive) and service-b (1st) violate. + await bulkInsertLogs([ + { + ServiceName: 'service-a', + Timestamp: new Date('2024-01-01T00:05:00Z'), + SeverityText: 'error', + Body: 'err', + }, + { + ServiceName: 'service-a', + Timestamp: new Date('2024-01-01T00:10:00Z'), + SeverityText: 'error', + Body: 'err', + }, + { + ServiceName: 'service-b', + Timestamp: new Date('2024-01-01T00:10:00Z'), + SeverityText: 'error', + Body: 'err', + }, + ]); + + // Window 1: service-a has its first violation -> PENDING, no notification. + await processAlertAtTime( + new Date('2024-01-01T00:12:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(0); + + // Window 2: service-a hits 2 consecutive violations -> ALERT (fires); + // service-b is on its first violation -> PENDING (does not fire). + await processAlertAtTime( + new Date('2024-01-01T00:17:00Z'), + details, + clickhouseClient, + connection, + alertProvider, + teamWebhooksById, + ); + + // Exactly one notification: service-a's transition to ALERT. + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); + + const histories = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1 }); + + const serviceAHistories = histories + .filter(h => h.group === 'ServiceName:service-a') + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + const serviceBHistories = histories.filter( + h => h.group === 'ServiceName:service-b', + ); + + // service-a: first window PENDING, second window ALERT (fired). + expect(serviceAHistories).toHaveLength(2); + expect(serviceAHistories[0].state).toBe('PENDING'); + expect(serviceAHistories[0].fired).toBeFalsy(); + expect(serviceAHistories[1].state).toBe('ALERT'); + expect(serviceAHistories[1].fired).toBe(true); + + // service-b: only one violation so far, so it must still be PENDING even + // though service-a fired in the same run. + expect(serviceBHistories).toHaveLength(1); + expect(serviceBHistories[0].state).toBe('PENDING'); + expect(serviceBHistories[0].fired).toBeFalsy(); + }); }); }); @@ -9331,4 +9437,115 @@ describe('checkAlerts', () => { ); }); }); + + describe('getConsecutiveWindowHistories', () => { + const server = getServer(); + + beforeAll(async () => { + await server.start(); + }); + + afterEach(async () => { + await server.clearDBs(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await server.stop(); + }); + + // getConsecutiveWindowHistories only reads scheduling/config fields off the + // alert (id, interval, numConsecutiveWindows, schedule*) and never loads the + // alert from the DB, so a lightweight stub is sufficient. + const makeAlert = ( + id: mongoose.Types.ObjectId, + numConsecutiveWindows?: number, + interval = '5m', + ): IAlert => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ({ + id: id.toString(), + interval, + numConsecutiveWindows, + }) as unknown as IAlert; + + const saveHistory = ( + alertId: mongoose.Types.ObjectId, + createdAt: Date, + opts: { group?: string; state?: AlertState } = {}, + ) => + new AlertHistory({ + alert: alertId, + createdAt, + state: opts.state ?? AlertState.ALERT, + group: opts.group, + }).save(); + + it('skips alerts with numConsecutiveWindows <= 1 (no query, empty map)', async () => { + const alertId = new mongoose.Types.ObjectId(); + await saveHistory(alertId, new Date('2025-01-01T00:10:00Z')); + + const aggregateSpy = jest.spyOn(AlertHistory, 'aggregate'); + + const result = await getConsecutiveWindowHistories( + [makeAlert(alertId, 1), makeAlert(new mongoose.Types.ObjectId())], + new Date('2025-01-01T00:17:00Z'), + ); + + expect(aggregateSpy).not.toHaveBeenCalled(); + expect(result.size).toBe(0); + }); + + it('buckets recent histories per group, newest-first, within the lookback window', async () => { + const alertId = new mongoose.Types.ObjectId(); + // now=00:17 -> windowStart=00:15; lookback = (3-1)*5m -> [00:05, 00:15) + await saveHistory(alertId, new Date('2025-01-01T00:00:00Z'), { + group: 'ServiceName:a', + }); // excluded: before earliestAllowedTime + await saveHistory(alertId, new Date('2025-01-01T00:05:00Z'), { + group: 'ServiceName:a', + }); + await saveHistory(alertId, new Date('2025-01-01T00:10:00Z'), { + group: 'ServiceName:a', + }); + await saveHistory(alertId, new Date('2025-01-01T00:15:00Z'), { + group: 'ServiceName:a', + }); // excluded: == windowStart (the current window being evaluated) + await saveHistory(alertId, new Date('2025-01-01T00:10:00Z'), { + group: 'ServiceName:b', + }); + + const result = await getConsecutiveWindowHistories( + [makeAlert(alertId, 3)], + new Date('2025-01-01T00:17:00Z'), + ); + + const aKey = `${alertId.toString()}||ServiceName:a`; + const bKey = `${alertId.toString()}||ServiceName:b`; + + expect(result.get(aKey)!.map(h => h.createdAt)).toEqual([ + new Date('2025-01-01T00:10:00Z'), + new Date('2025-01-01T00:05:00Z'), + ]); + expect(result.get(bKey)!.map(h => h.createdAt)).toEqual([ + new Date('2025-01-01T00:10:00Z'), + ]); + }); + + it('keys ungrouped histories by the bare alert id', async () => { + const alertId = new mongoose.Types.ObjectId(); + // now=00:17 -> windowStart=00:15; lookback = (2-1)*5m -> [00:10, 00:15) + await saveHistory(alertId, new Date('2025-01-01T00:10:00Z')); + + const result = await getConsecutiveWindowHistories( + [makeAlert(alertId, 2)], + new Date('2025-01-01T00:17:00Z'), + ); + + expect(result.size).toBe(1); + expect(result.get(alertId.toString())!.map(h => h.state)).toEqual([ + AlertState.ALERT, + ]); + }); + }); }); diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 3f68f370bd..fd6c027e81 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -319,6 +319,31 @@ export const getScheduledWindowStart = ( return fns.addMinutes(roundedShiftedNow, scheduleOffsetMinutes); }; +/** + * Compute the scheduled window start ("now rounded down to the window") for an + * alert at the given time. This mirrors the computation inside processAlert so + * that history fetched up-front (see getConsecutiveWindowHistories) lines up + * exactly with the window processAlert evaluates. + */ +export const getAlertWindowStart = (alert: IAlert, now: Date): Date => { + const windowSizeInMins = ms(alert.interval) / 60000; + const scheduleStartAt = normalizeScheduleStartAt({ + alertId: alert.id, + scheduleStartAt: alert.scheduleStartAt, + }); + const scheduleOffsetMinutes = normalizeScheduleOffsetMinutes({ + alertId: alert.id, + scheduleOffsetMinutes: alert.scheduleOffsetMinutes, + windowSizeInMins, + }); + return getScheduledWindowStart( + now, + windowSizeInMins, + scheduleOffsetMinutes, + scheduleStartAt, + ); +}; + const fireChannelEvent = async ({ alert, alertProvider, @@ -733,7 +758,7 @@ export const processAlert = async ( alertProvider: AlertProvider, teamWebhooksById: Map, ) => { - const { alert, previousMap } = details; + const { alert, previousMap, recentHistoryMap } = details; const source = 'source' in details ? details.source : undefined; // Errors collected during this execution. Webhook errors accumulate here; query // and validation errors are recorded via recordAlertErrors before returning. @@ -1026,37 +1051,24 @@ export const processAlert = async ( } }; - // Fire an alert when a condition is met in M consecutive time windows - const shouldFireBasedOnConsecutiveWindows = async ( - groupKey?: string, - ): Promise => { - const numWindowsToLookBack = alert.numConsecutiveWindows ?? 1; + const numWindowsToLookBack = alert.numConsecutiveWindows ?? 1; + const shouldFireBasedOnConsecutiveWindows = ( + groupKey?: string, + ): boolean => { if (numWindowsToLookBack <= 1) { return true; } - const groupFilter = groupKey ? { group: groupKey } : { group: null }; - - // filters the alert history to only include the last M _eligible_ windows (plus a couple of) - // windows of buffer, in order to ensure that we don't fire in the case where e.g. - // there's an offending log line, then the service is down for a while, then it comes back and there's - // another offending log line and they look consecutive, but are not. - const earliestAllowedTime = new Date( - nowInMinsRoundDown.getTime() - - (numWindowsToLookBack - 1) * windowSizeInMins * 60_000, - ); - const alertHistory = await AlertHistory.find({ - alert: new mongoose.Types.ObjectId(alert.id), - ...groupFilter, - createdAt: { $gte: earliestAllowedTime, $lt: nowInMinsRoundDown }, - }) - .sort({ createdAt: -1 }) - .limit(numWindowsToLookBack - 1); + // recentHistoryMap entries are pre-filtered to the lookback window and + // sorted newest-first, so take the most recent M-1 for this group. + const key = computeHistoryMapKey(alert.id, groupKey || ''); + const groupHistories = recentHistoryMap?.get(key) ?? []; + const relevant = groupHistories.slice(0, numWindowsToLookBack - 1); return ( - alertHistory.length === numWindowsToLookBack - 1 && - alertHistory.every( + relevant.length === numWindowsToLookBack - 1 && + relevant.every( h => h.state === AlertState.ALERT || h.state === AlertState.PENDING, ) ); @@ -1108,7 +1120,7 @@ export const processAlert = async ( const previous = previousMap.get(computeHistoryMapKey(alert.id, '')); if (doesExceedThreshold(alert, value)) { history.counts += 1; - if (await shouldFireBasedOnConsecutiveWindows()) { + if (shouldFireBasedOnConsecutiveWindows()) { history.state = AlertState.ALERT; history.fired = true; await trySendNotification({ @@ -1184,7 +1196,7 @@ export const processAlert = async ( const history = getOrCreateHistory(''); history.lastValues.push({ count: 0, startTime: bucketStart }); history.counts += 1; - if (await shouldFireBasedOnConsecutiveWindows()) { + if (shouldFireBasedOnConsecutiveWindows()) { history.state = AlertState.ALERT; history.fired = true; await trySendNotification({ @@ -1228,7 +1240,7 @@ export const processAlert = async ( if (doesExceedThreshold(alert, value)) { history.counts += 1; - if (await shouldFireBasedOnConsecutiveWindows(groupKey)) { + if (shouldFireBasedOnConsecutiveWindows(groupKey)) { history.state = AlertState.ALERT; history.fired = true; await trySendNotification({ @@ -1360,12 +1372,14 @@ export interface AggregatedAlertHistory { export const getPreviousAlertHistories = async ( alertIds: string[], now: Date, + sharedQueue?: PQueue, ) => { const lookbackDate = new Date(now.getTime() - ms('7d')); - // Use a concurrency-limited queue to avoid overwhelming the connection pool - // when there are many alerts (e.g., 200+ alert IDs). - const queue = new PQueue({ concurrency: ALERT_HISTORY_QUERY_CONCURRENCY }); + // Concurrency-limited per-alert queries to avoid overwhelming the connection + // pool when there are many alerts (e.g., 200+ alert IDs). + const queue = + sharedQueue ?? new PQueue({ concurrency: ALERT_HISTORY_QUERY_CONCURRENCY }); const results = await Promise.all( alertIds.map(alertId => @@ -1426,6 +1440,90 @@ export const getPreviousAlertHistories = async ( ); }; +/** + * For alerts that use multi-window lookback (numConsecutiveWindows > 1), + * batch-fetch the per-group history needed to decide whether the alert condition + * has been met in M consecutive windows. + * + * Alerts with numConsecutiveWindows <= 1 are skipped entirely (no query is run). + * + * For each multi-window alert we fetch the AlertHistory records whose createdAt + * falls in [windowStart - (M-1)*window, windowStart), sorted newest-first, then + * bucket them by group. processAlert takes the most recent M-1 per group and + * requires every one of them to be ALERT/PENDING to fire. The window start is + * computed with getAlertWindowStart so it matches the window processAlert + * evaluates for the same `now`. + */ +export const getConsecutiveWindowHistories = async ( + alerts: IAlert[], + now: Date, + sharedQueue?: PQueue, +): Promise> => { + const map = new Map(); + + const multiWindowAlerts = alerts.filter( + alert => (alert.numConsecutiveWindows ?? 1) > 1, + ); + if (multiWindowAlerts.length === 0) { + return map; + } + + // Concurrency-limited per-alert queries (same approach as getPreviousAlertHistories) + const queue = + sharedQueue ?? new PQueue({ concurrency: ALERT_HISTORY_QUERY_CONCURRENCY }); + + const results = await Promise.all( + multiWindowAlerts.map(alert => + queue.add(async () => { + const numWindowsToLookBack = alert.numConsecutiveWindows ?? 1; + const windowSizeInMins = ms(alert.interval) / 60000; + const windowStart = getAlertWindowStart(alert, now); + const earliestAllowedTime = new Date( + windowStart.getTime() - + (numWindowsToLookBack - 1) * windowSizeInMins * 60_000, + ); + const id = new mongoose.Types.ObjectId(alert.id); + const histories = await AlertHistory.aggregate([ + { + $match: { + alert: id, + createdAt: { $gte: earliestAllowedTime, $lt: windowStart }, + }, + }, + { $sort: { alert: 1, group: 1, createdAt: -1 } }, + { + $project: { + _id: '$alert', + createdAt: 1, + state: 1, + group: 1, + fired: 1, + }, + }, + ]); + return { alertId: alert.id, histories }; + }), + ), + ); + + for (const result of results) { + if (!result) { + continue; + } + for (const history of result.histories) { + const key = computeHistoryMapKey(result.alertId, history.group || ''); + const bucket = map.get(key); + if (bucket) { + bucket.push(history); + } else { + map.set(key, [history]); + } + } + } + + return map; +}; + export default class CheckAlertTask implements HdxTask { private provider!: AlertProvider; private task_queue: PQueue; diff --git a/packages/api/src/tasks/checkAlerts/providers/default.ts b/packages/api/src/tasks/checkAlerts/providers/default.ts index ff3adab1f5..079cdcc89d 100644 --- a/packages/api/src/tasks/checkAlerts/providers/default.ts +++ b/packages/api/src/tasks/checkAlerts/providers/default.ts @@ -1,3 +1,4 @@ +import PQueue from '@esm2cjs/p-queue'; import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; import { displayTypeSupportsRawSqlAlerts } from '@hyperdx/common-utils/dist/core/utils'; import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; @@ -7,6 +8,7 @@ import ms from 'ms'; import { URLSearchParams } from 'url'; import * as config from '@/config'; +import { ALERT_HISTORY_QUERY_CONCURRENCY } from '@/controllers/alertHistory'; import { LOCAL_APP_TEAM } from '@/controllers/team'; import { connectDB, mongooseConnection, ObjectId } from '@/models'; import Alert, { @@ -23,6 +25,7 @@ import { type ISource, Source } from '@/models/source'; import Webhook, { IWebhook } from '@/models/webhook'; import { AggregatedAlertHistory, + getConsecutiveWindowHistories, getPreviousAlertHistories, } from '@/tasks/checkAlerts'; import { @@ -216,6 +219,7 @@ async function loadAlert( alert: IAlert, groupedTasks: Map, previousAlerts: Map, + recentHistoryMap: Map, now: Date, ) { if (!alert.source) { @@ -258,7 +262,11 @@ async function loadAlert( if (!v) { throw new Error(`provider did not set key ${conn.id} before appending`); } - v.alerts.push({ ...details, previousMap: previousAlerts }); + v.alerts.push({ + ...details, + previousMap: previousAlerts, + recentHistoryMap, + }); } export default class DefaultAlertProvider implements AlertProvider { @@ -276,11 +284,25 @@ export default class DefaultAlertProvider implements AlertProvider { const now = new Date(); const alertIds = alerts.map(({ id }) => id); - const previousAlerts = await getPreviousAlertHistories(alertIds, now); + // Share a single queue across both history fetches so their combined + // in-flight per-alert queries stay within one global cap. + const historyQueryQueue = new PQueue({ + concurrency: ALERT_HISTORY_QUERY_CONCURRENCY, + }); + const [previousAlerts, recentHistoryMap] = await Promise.all([ + getPreviousAlertHistories(alertIds, now, historyQueryQueue), + getConsecutiveWindowHistories(alerts, now, historyQueryQueue), + ]); for (const alert of alerts) { try { - await loadAlert(alert, groupedTasks, previousAlerts, now); + await loadAlert( + alert, + groupedTasks, + previousAlerts, + recentHistoryMap, + now, + ); } catch (e) { logger.error({ message: `failed to load alert: ${e}`, diff --git a/packages/api/src/tasks/checkAlerts/providers/index.ts b/packages/api/src/tasks/checkAlerts/providers/index.ts index 116be8c588..aeed3e5407 100644 --- a/packages/api/src/tasks/checkAlerts/providers/index.ts +++ b/packages/api/src/tasks/checkAlerts/providers/index.ts @@ -32,6 +32,9 @@ export type PopulatedAlertChannel = { type: 'webhook' } & { channel: IWebhook }; export type AlertDetails = { alert: IAlert; previousMap: Map; // Map of alertId||group -> history for group-by alerts + // For multi-window alerts (numConsecutiveWindows > 1): the recent per-group + // history (alertId||group -> histories, newest-first). + recentHistoryMap?: Map; } & ( | { taskType: AlertTaskType.SAVED_SEARCH; diff --git a/packages/app/src/components/alerts/AlertHistoryCards.tsx b/packages/app/src/components/alerts/AlertHistoryCards.tsx index eb8e1c767b..a20537308f 100644 --- a/packages/app/src/components/alerts/AlertHistoryCards.tsx +++ b/packages/app/src/components/alerts/AlertHistoryCards.tsx @@ -75,12 +75,14 @@ function AlertHistoryCard({ /> ); + const count = history.counts ?? 0; + const pending = history.state === AlertState.PENDING ? 'pending' : ''; + const alert = `alert${count === 0 || count > 1 ? 's' : ''}`; + const time = formatRelative(start, today); + const label = `${count} ${pending} ${alert} ${time}`; + return ( - + {href ? ( {content} From 41b9820cc80a62f43406d8110899e884cf90d4f5 Mon Sep 17 00:00:00 2001 From: mrkaye97 Date: Fri, 26 Jun 2026 12:52:57 -0400 Subject: [PATCH 26/26] fix: move to advanced settings --- packages/app/src/DBSearchPageAlertModal.tsx | 27 +--------- .../src/components/AlertScheduleFields.tsx | 50 ++++++++++++++++++- .../DBEditTimeChartForm/TileAlertEditor.tsx | 27 +--------- 3 files changed, 53 insertions(+), 51 deletions(-) diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index f90bcafc4b..3af7e81fe3 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -246,31 +246,6 @@ const AlertForm = ({ /> )} /> - - for - - ( - { - const num = typeof v === 'number' ? v : 1; - field.onChange(num > 1 ? num : undefined); - }} - min={1} - size="xs" - w={70} - /> - )} - /> - - {(numConsecutiveWindows ?? 1) === 1 - ? 'window' - : 'consecutive windows'} - via @@ -294,6 +269,8 @@ const AlertForm = ({ scheduleOffsetMinutes={scheduleOffsetMinutes} maxScheduleOffsetMinutes={maxScheduleOffsetMinutes} offsetWindowLabel={`from each ${intervalLabel} window`} + numConsecutiveWindowsName="numConsecutiveWindows" + numConsecutiveWindows={numConsecutiveWindows ?? undefined} /> grouped by diff --git a/packages/app/src/components/AlertScheduleFields.tsx b/packages/app/src/components/AlertScheduleFields.tsx index ccdba37367..774baca58a 100644 --- a/packages/app/src/components/AlertScheduleFields.tsx +++ b/packages/app/src/components/AlertScheduleFields.tsx @@ -36,6 +36,8 @@ type AlertScheduleFieldsProps = { scheduleOffsetMinutes: number | null | undefined; maxScheduleOffsetMinutes: number; offsetWindowLabel: string; + numConsecutiveWindowsName?: FieldPath; + numConsecutiveWindows?: number; }; export function AlertScheduleFields({ @@ -46,6 +48,8 @@ export function AlertScheduleFields({ scheduleOffsetMinutes, maxScheduleOffsetMinutes, offsetWindowLabel, + numConsecutiveWindowsName, + numConsecutiveWindows, }: AlertScheduleFieldsProps) { const showScheduleOffsetInput = maxScheduleOffsetMinutes > 0; const scheduleStartAtValue = useWatch({ @@ -54,7 +58,9 @@ export function AlertScheduleFields({ }) as string | null | undefined; const hasScheduleStartAtAnchor = scheduleStartAtValue != null; const hasAdvancedScheduleValues = - (scheduleOffsetMinutes ?? 0) > 0 || hasScheduleStartAtAnchor; + (scheduleOffsetMinutes ?? 0) > 0 || + hasScheduleStartAtAnchor || + (numConsecutiveWindows ?? 1) > 1; const [opened, setOpened] = useState(hasAdvancedScheduleValues); useEffect(() => { @@ -101,6 +107,48 @@ export function AlertScheduleFields({ Optional schedule controls for aligning alert windows. + {numConsecutiveWindowsName && ( + + + + Consecutive windows + + + + + + + + ( + { + const num = typeof v === 'number' ? v : 1; + field.onChange(num > 1 ? num : undefined); + }} + min={1} + size="xs" + w={70} + /> + )} + /> + + {(numConsecutiveWindows ?? 1) === 1 + ? 'window' + : 'consecutive windows'} + + + )} {showScheduleOffsetInput && ( <> diff --git a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx index c0b5d0cf07..8bfb0180fa 100644 --- a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx @@ -209,31 +209,6 @@ export function TileAlertEditor({ /> )} /> - - for - - ( - { - const num = typeof v === 'number' ? v : 1; - field.onChange(num > 1 ? num : undefined); - }} - min={1} - size="xs" - w={70} - /> - )} - /> - - {(alertnumConsecutiveWindows ?? 1) === 1 - ? 'window' - : 'consecutive windows'} - via @@ -266,6 +241,8 @@ export function TileAlertEditor({ ? `from each ${alertIntervalLabel} window` : 'from each alert window' } + numConsecutiveWindowsName="alert.numConsecutiveWindows" + numConsecutiveWindows={alertnumConsecutiveWindows ?? undefined} /> Send to