mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat(alerts): add scheduleStartAt + scheduleOffsetMinutes (#1745)
Closes #1715 ## Summary - Add scheduleStartAt and scheduleOffsetMinutes to alert schemas and API validation. - Update alert evaluation scheduling to anchor windows by scheduleStartAt when set. - Skip evaluations before scheduleStartAt. - Keep current behavior unchanged when scheduling fields are unset. - Add UI fields and API/OpenAPI/external API support for the new schedule options. - Add alert scheduler tests for anchored windows and pre-start skip behavior. ## Notes - This enables Splunk-style scheduled monitor migration where checks must run on isolated, periodic windows anchored to specific times. - scheduleStartAt is the primary anchor; scheduleOffsetMinutes remains optional for backward-compatible alignment. Co-authored-by: melsalcedo <128840984+melsalcedo@users.noreply.github.com> Co-authored-by: Tom Alexander <3245235+teeohhem@users.noreply.github.com>
This commit is contained in:
parent
32f1189a7d
commit
902b8ebdd3
22 changed files with 1499 additions and 85 deletions
7
.changeset/silent-zebras-switch.md
Normal file
7
.changeset/silent-zebras-switch.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/api": minor
|
||||
"@hyperdx/app": minor
|
||||
"@hyperdx/common-utils": minor
|
||||
---
|
||||
|
||||
feat(alerts): add anchored alert scheduling with `scheduleStartAt` and `scheduleOffsetMinutes`
|
||||
|
|
@ -169,6 +169,20 @@
|
|||
"$ref": "#/components/schemas/AlertInterval",
|
||||
"example": "1h"
|
||||
},
|
||||
"scheduleOffsetMinutes": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).",
|
||||
"nullable": true,
|
||||
"example": 2
|
||||
},
|
||||
"scheduleStartAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.",
|
||||
"nullable": true,
|
||||
"example": "2026-02-08T10:00:00.000Z"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/AlertSource",
|
||||
"example": "tile"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export type AlertInput = {
|
|||
source?: AlertSource;
|
||||
channel: AlertChannel;
|
||||
interval: AlertInterval;
|
||||
scheduleOffsetMinutes?: number;
|
||||
scheduleStartAt?: string | null;
|
||||
thresholdType: AlertThresholdType;
|
||||
threshold: number;
|
||||
|
||||
|
|
@ -105,9 +107,30 @@ export const validateAlertInput = async (
|
|||
};
|
||||
|
||||
const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
|
||||
// Preserve existing DB value when scheduleStartAt is omitted from updates
|
||||
// (undefined), while still allowing explicit clears via null.
|
||||
const hasScheduleStartAt = alert.scheduleStartAt !== undefined;
|
||||
// If scheduleStartAt is explicitly provided, offset-based alignment is ignored.
|
||||
// Force persisted offset to 0 so updates can't leave stale non-zero offsets.
|
||||
// If scheduleStartAt is explicitly cleared and offset is omitted, also reset
|
||||
// to 0 to avoid preserving stale values from older documents.
|
||||
const normalizedScheduleOffsetMinutes =
|
||||
hasScheduleStartAt && alert.scheduleStartAt != null
|
||||
? 0
|
||||
: hasScheduleStartAt && alert.scheduleOffsetMinutes == null
|
||||
? 0
|
||||
: alert.scheduleOffsetMinutes;
|
||||
|
||||
return {
|
||||
channel: alert.channel,
|
||||
interval: alert.interval,
|
||||
...(normalizedScheduleOffsetMinutes != null && {
|
||||
scheduleOffsetMinutes: normalizedScheduleOffsetMinutes,
|
||||
}),
|
||||
...(hasScheduleStartAt && {
|
||||
scheduleStartAt:
|
||||
alert.scheduleStartAt == null ? null : new Date(alert.scheduleStartAt),
|
||||
}),
|
||||
source: alert.source,
|
||||
threshold: alert.threshold,
|
||||
thresholdType: alert.thresholdType,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ALERT_INTERVAL_TO_MINUTES } from '@hyperdx/common-utils/dist/types';
|
||||
import mongoose, { Schema } from 'mongoose';
|
||||
|
||||
import type { ObjectId } from '.';
|
||||
|
|
@ -44,6 +45,8 @@ export interface IAlert {
|
|||
id: string;
|
||||
channel: AlertChannel;
|
||||
interval: AlertInterval;
|
||||
scheduleOffsetMinutes?: number;
|
||||
scheduleStartAt?: Date | null;
|
||||
source?: AlertSource;
|
||||
state: AlertState;
|
||||
team: ObjectId;
|
||||
|
|
@ -88,6 +91,29 @@ const AlertSchema = new Schema<IAlert>(
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
scheduleOffsetMinutes: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
// Maximum offset for daily windows (24h - 1 minute).
|
||||
max: 1439,
|
||||
validate: {
|
||||
validator: function (this: IAlert, value: number | undefined) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const intervalMinutes = ALERT_INTERVAL_TO_MINUTES[this.interval];
|
||||
return intervalMinutes == null || value < intervalMinutes;
|
||||
},
|
||||
message:
|
||||
'scheduleOffsetMinutes must be less than the alert interval in minutes',
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
scheduleStartAt: {
|
||||
type: Date,
|
||||
required: false,
|
||||
},
|
||||
channel: Schema.Types.Mixed, // slack, email, etc
|
||||
state: {
|
||||
type: String,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
makeTile,
|
||||
randomMongoId,
|
||||
} from '@/fixtures';
|
||||
import Alert from '@/models/alert';
|
||||
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
|
||||
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
|
||||
|
||||
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
|
||||
|
|
@ -116,6 +116,245 @@ describe('alerts router', () => {
|
|||
expect(allAlerts.body.data[0].threshold).toBe(10);
|
||||
});
|
||||
|
||||
it('preserves scheduleStartAt when omitted in updates and clears when null', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
const scheduleStartAt = '2024-01-01T00:00:00.000Z';
|
||||
const createdAlert = await agent
|
||||
.post('/alerts')
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleStartAt,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const updatePayload = {
|
||||
channel: createdAlert.body.data.channel,
|
||||
interval: createdAlert.body.data.interval,
|
||||
threshold: 10,
|
||||
thresholdType: createdAlert.body.data.thresholdType,
|
||||
source: createdAlert.body.data.source,
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
};
|
||||
|
||||
await agent
|
||||
.put(`/alerts/${createdAlert.body.data._id}`)
|
||||
.send(updatePayload)
|
||||
.expect(200);
|
||||
|
||||
const alertAfterOmittedScheduleStartAt = await Alert.findById(
|
||||
createdAlert.body.data._id,
|
||||
);
|
||||
expect(
|
||||
alertAfterOmittedScheduleStartAt?.scheduleStartAt?.toISOString(),
|
||||
).toBe(scheduleStartAt);
|
||||
|
||||
await agent
|
||||
.put(`/alerts/${createdAlert.body.data._id}`)
|
||||
.send({
|
||||
...updatePayload,
|
||||
scheduleStartAt: null,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const alertAfterNullScheduleStartAt = await Alert.findById(
|
||||
createdAlert.body.data._id,
|
||||
);
|
||||
expect(alertAfterNullScheduleStartAt?.scheduleStartAt).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves scheduleOffsetMinutes when schedule fields are omitted in updates', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
const createdAlert = await agent
|
||||
.post('/alerts')
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
interval: '15m',
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleOffsetMinutes: 2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await agent
|
||||
.put(`/alerts/${createdAlert.body.data._id}`)
|
||||
.send({
|
||||
channel: createdAlert.body.data.channel,
|
||||
interval: createdAlert.body.data.interval,
|
||||
threshold: 10,
|
||||
thresholdType: createdAlert.body.data.thresholdType,
|
||||
source: createdAlert.body.data.source,
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const updatedAlert = await Alert.findById(createdAlert.body.data._id);
|
||||
expect(updatedAlert?.scheduleOffsetMinutes).toBe(2);
|
||||
expect(updatedAlert?.scheduleStartAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resets scheduleOffsetMinutes to 0 when scheduleStartAt is set without offset', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
const createdAlert = await agent
|
||||
.post('/alerts')
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleOffsetMinutes: 2,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAlert.body.data.scheduleOffsetMinutes).toBe(2);
|
||||
|
||||
const scheduleStartAt = '2024-01-01T00:00:00.000Z';
|
||||
|
||||
await agent
|
||||
.put(`/alerts/${createdAlert.body.data._id}`)
|
||||
.send({
|
||||
channel: createdAlert.body.data.channel,
|
||||
interval: createdAlert.body.data.interval,
|
||||
threshold: createdAlert.body.data.threshold,
|
||||
thresholdType: createdAlert.body.data.thresholdType,
|
||||
source: createdAlert.body.data.source,
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
scheduleStartAt,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const updatedAlert = await Alert.findById(createdAlert.body.data._id);
|
||||
expect(updatedAlert?.scheduleOffsetMinutes).toBe(0);
|
||||
expect(updatedAlert?.scheduleStartAt?.toISOString()).toBe(scheduleStartAt);
|
||||
});
|
||||
|
||||
it('resets stale scheduleOffsetMinutes when scheduleStartAt is cleared without offset', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
const staleAlert = await Alert.create({
|
||||
team: team._id,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '15m',
|
||||
threshold: 8,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
source: AlertSource.TILE,
|
||||
dashboard: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
scheduleOffsetMinutes: 2,
|
||||
scheduleStartAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await agent
|
||||
.put(`/alerts/${staleAlert._id.toString()}`)
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
interval: '15m',
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleStartAt: null,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const updatedAlert = await Alert.findById(staleAlert._id);
|
||||
expect(updatedAlert?.scheduleOffsetMinutes).toBe(0);
|
||||
expect(updatedAlert?.scheduleStartAt).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects scheduleStartAt values more than 1 year in the future', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
const farFutureScheduleStartAt = new Date(
|
||||
Date.now() + 366 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
await agent
|
||||
.post('/alerts')
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleStartAt: farFutureScheduleStartAt,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects scheduleStartAt values older than 10 years in the past', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
const tooOldScheduleStartAt = new Date(
|
||||
Date.now() - 11 * 365 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
await agent
|
||||
.post('/alerts')
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleStartAt: tooOldScheduleStartAt,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send(MOCK_DASHBOARD)
|
||||
.expect(200);
|
||||
|
||||
await agent
|
||||
.post('/alerts')
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: dashboard.body.tiles[0].id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
scheduleOffsetMinutes: 2,
|
||||
scheduleStartAt: new Date().toISOString(),
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('preserves createdBy field during updates', async () => {
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import express from 'express';
|
|||
import _ from 'lodash';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from 'zod-express-middleware';
|
||||
import { processRequest, validateRequest } from 'zod-express-middleware';
|
||||
|
||||
import { getRecentAlertHistories } from '@/controllers/alertHistory';
|
||||
import {
|
||||
|
|
@ -68,6 +68,8 @@ router.get('/', async (req, res, next) => {
|
|||
..._.pick(alert, [
|
||||
'_id',
|
||||
'interval',
|
||||
'scheduleOffsetMinutes',
|
||||
'scheduleStartAt',
|
||||
'threshold',
|
||||
'thresholdType',
|
||||
'state',
|
||||
|
|
@ -89,7 +91,7 @@ router.get('/', async (req, res, next) => {
|
|||
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest({ body: alertSchema }),
|
||||
processRequest({ body: alertSchema }),
|
||||
async (req, res, next) => {
|
||||
const teamId = req.user?.team;
|
||||
const userId = req.user?._id;
|
||||
|
|
@ -110,7 +112,7 @@ router.post(
|
|||
|
||||
router.put(
|
||||
'/:id',
|
||||
validateRequest({
|
||||
processRequest({
|
||||
body: alertSchema,
|
||||
params: z.object({
|
||||
id: objectIdSchema,
|
||||
|
|
@ -124,6 +126,7 @@ router.put(
|
|||
}
|
||||
const { id } = req.params;
|
||||
const alertInput = req.body;
|
||||
await validateAlertInput(teamId, alertInput);
|
||||
res.json({
|
||||
data: await updateAlert(id, teamId, alertInput),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -286,6 +286,53 @@ describe('External API Alerts', () => {
|
|||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should reject scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
|
||||
const dashboard = await createTestDashboard();
|
||||
|
||||
const response = await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 100,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: new ObjectId().toString(),
|
||||
},
|
||||
scheduleOffsetMinutes: 2,
|
||||
scheduleStartAt: new Date().toISOString(),
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('should reject scheduleStartAt values more than 1 year in the future', async () => {
|
||||
const dashboard = await createTestDashboard();
|
||||
|
||||
const response = await authRequest('post', ALERTS_BASE_URL)
|
||||
.send({
|
||||
dashboardId: dashboard._id.toString(),
|
||||
tileId: dashboard.tiles[0].id,
|
||||
threshold: 100,
|
||||
interval: '1h',
|
||||
source: AlertSource.TILE,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: new ObjectId().toString(),
|
||||
},
|
||||
scheduleStartAt: new Date(
|
||||
Date.now() + 366 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('should create multiple alerts for different tiles', async () => {
|
||||
// Create a dashboard with multiple tiles
|
||||
const dashboard = await createTestDashboard({ numTiles: 3 });
|
||||
|
|
@ -457,6 +504,31 @@ describe('External API Alerts', () => {
|
|||
expect(retrievedAlert.interval).toBe('1h');
|
||||
expect(retrievedAlert.message).toBe('Updated message');
|
||||
});
|
||||
|
||||
it('should reject scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
|
||||
const { alert } = await createTestAlert({
|
||||
interval: '1h',
|
||||
});
|
||||
|
||||
const originalAlert = await authRequest(
|
||||
'get',
|
||||
`${ALERTS_BASE_URL}/${alert.id}`,
|
||||
).expect(200);
|
||||
|
||||
await authRequest('put', `${ALERTS_BASE_URL}/${alert.id}`)
|
||||
.send({
|
||||
threshold: originalAlert.body.data.threshold,
|
||||
interval: originalAlert.body.data.interval,
|
||||
thresholdType: originalAlert.body.data.thresholdType,
|
||||
source: originalAlert.body.data.source,
|
||||
dashboardId: originalAlert.body.data.dashboardId,
|
||||
tileId: originalAlert.body.data.tileId,
|
||||
channel: originalAlert.body.data.channel,
|
||||
scheduleOffsetMinutes: 2,
|
||||
scheduleStartAt: new Date().toISOString(),
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deleting alerts', () => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
updateAlert,
|
||||
validateAlertInput,
|
||||
} from '@/controllers/alerts';
|
||||
import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors';
|
||||
import {
|
||||
processRequestWithEnhancedErrors as processRequest,
|
||||
validateRequestWithEnhancedErrors as validateRequest,
|
||||
} from '@/utils/enhancedErrors';
|
||||
import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi';
|
||||
import { alertSchema, objectIdSchema } from '@/utils/zod';
|
||||
|
||||
|
|
@ -106,6 +109,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
|
|||
* interval:
|
||||
* $ref: '#/components/schemas/AlertInterval'
|
||||
* example: "1h"
|
||||
* scheduleOffsetMinutes:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).
|
||||
* nullable: true
|
||||
* example: 2
|
||||
* scheduleStartAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.
|
||||
* nullable: true
|
||||
* example: "2026-02-08T10:00:00.000Z"
|
||||
* source:
|
||||
* $ref: '#/components/schemas/AlertSource'
|
||||
* example: "tile"
|
||||
|
|
@ -395,7 +410,7 @@ router.get('/', async (req, res, next) => {
|
|||
*/
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest({
|
||||
processRequest({
|
||||
body: alertSchema,
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
|
|
@ -405,7 +420,7 @@ router.post(
|
|||
return res.sendStatus(403);
|
||||
}
|
||||
try {
|
||||
const alertInput = alertSchema.parse(req.body);
|
||||
const alertInput = req.body;
|
||||
await validateAlertInput(teamId, alertInput);
|
||||
|
||||
const createdAlert = await createAlert(teamId, alertInput, userId);
|
||||
|
|
@ -484,7 +499,7 @@ router.post(
|
|||
*/
|
||||
router.put(
|
||||
'/:id',
|
||||
validateRequest({
|
||||
processRequest({
|
||||
body: alertSchema,
|
||||
params: z.object({
|
||||
id: objectIdSchema,
|
||||
|
|
@ -499,7 +514,7 @@ router.put(
|
|||
}
|
||||
const { id } = req.params;
|
||||
|
||||
const alertInput = alertSchema.parse(req.body);
|
||||
const alertInput = req.body;
|
||||
await validateAlertInput(teamId, alertInput);
|
||||
|
||||
const alert = await updateAlert(id, teamId, alertInput);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
alertHasGroupBy,
|
||||
doesExceedThreshold,
|
||||
getPreviousAlertHistories,
|
||||
getScheduledWindowStart,
|
||||
processAlert,
|
||||
} from '@/tasks/checkAlerts';
|
||||
import {
|
||||
|
|
@ -123,6 +124,45 @@ describe('checkAlerts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getScheduledWindowStart', () => {
|
||||
it('should align to the default interval boundary when offset is 0', () => {
|
||||
const now = new Date('2024-01-01T12:13:45.000Z');
|
||||
const windowStart = getScheduledWindowStart(now, 5, 0);
|
||||
|
||||
expect(windowStart).toEqual(new Date('2024-01-01T12:10:00.000Z'));
|
||||
});
|
||||
|
||||
it('should align to an offset boundary when schedule offset is provided', () => {
|
||||
const now = new Date('2024-01-01T12:13:45.000Z');
|
||||
const windowStart = getScheduledWindowStart(now, 5, 2);
|
||||
|
||||
expect(windowStart).toEqual(new Date('2024-01-01T12:12:00.000Z'));
|
||||
});
|
||||
|
||||
it('should keep previous offset window until the next offset boundary', () => {
|
||||
const now = new Date('2024-01-01T12:11:59.000Z');
|
||||
const windowStart = getScheduledWindowStart(now, 5, 2);
|
||||
|
||||
expect(windowStart).toEqual(new Date('2024-01-01T12:07:00.000Z'));
|
||||
});
|
||||
|
||||
it('should align windows using scheduleStartAt as an absolute anchor', () => {
|
||||
const now = new Date('2024-01-01T12:13:45.000Z');
|
||||
const scheduleStartAt = new Date('2024-01-01T12:02:30.000Z');
|
||||
const windowStart = getScheduledWindowStart(now, 5, 0, scheduleStartAt);
|
||||
|
||||
expect(windowStart).toEqual(new Date('2024-01-01T12:12:30.000Z'));
|
||||
});
|
||||
|
||||
it('should prioritize scheduleStartAt over offset alignment', () => {
|
||||
const now = new Date('2024-01-01T12:13:45.000Z');
|
||||
const scheduleStartAt = new Date('2024-01-01T12:02:30.000Z');
|
||||
const windowStart = getScheduledWindowStart(now, 5, 2, scheduleStartAt);
|
||||
|
||||
expect(windowStart).toEqual(new Date('2024-01-01T12:12:30.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertHasGroupBy', () => {
|
||||
const makeDetails = (
|
||||
overrides: Partial<{
|
||||
|
|
@ -1152,6 +1192,116 @@ describe('checkAlerts', () => {
|
|||
);
|
||||
};
|
||||
|
||||
it('should skip processing before scheduleStartAt', 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: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
scheduleStartAt: '2023-11-16T22:15:00.000Z',
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.SAVED_SEARCH,
|
||||
savedSearch,
|
||||
},
|
||||
);
|
||||
|
||||
const querySpy = jest.spyOn(clickhouseClient, 'queryChartConfig');
|
||||
|
||||
await processAlertAtTime(
|
||||
new Date('2023-11-16T22:12:00.000Z'),
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
|
||||
expect(querySpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await AlertHistory.countDocuments({ alert: details.alert.id }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip processing until the first anchored window fully elapses', 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: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
scheduleStartAt: '2023-11-16T22:13:30.000Z',
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.SAVED_SEARCH,
|
||||
savedSearch,
|
||||
},
|
||||
);
|
||||
|
||||
const querySpy = jest.spyOn(clickhouseClient, 'queryChartConfig');
|
||||
|
||||
await processAlertAtTime(
|
||||
new Date('2023-11-16T22:13:45.000Z'),
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
expect(querySpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await AlertHistory.countDocuments({ alert: details.alert.id }),
|
||||
).toBe(0);
|
||||
|
||||
await processAlertAtTime(
|
||||
new Date('2023-11-16T22:18:31.000Z'),
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
expect(querySpy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await AlertHistory.countDocuments({ alert: details.alert.id }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('SAVED_SEARCH alert - slack webhook', async () => {
|
||||
const {
|
||||
team,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,90 @@ export const doesExceedThreshold = (
|
|||
return false;
|
||||
};
|
||||
|
||||
const normalizeScheduleOffsetMinutes = ({
|
||||
alertId,
|
||||
scheduleOffsetMinutes,
|
||||
windowSizeInMins,
|
||||
}: {
|
||||
alertId: string;
|
||||
scheduleOffsetMinutes: number | undefined;
|
||||
windowSizeInMins: number;
|
||||
}) => {
|
||||
if (scheduleOffsetMinutes == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(scheduleOffsetMinutes)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = Math.max(0, Math.floor(scheduleOffsetMinutes));
|
||||
if (normalized < windowSizeInMins) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const scheduleOffsetInMins = normalized % windowSizeInMins;
|
||||
logger.warn(
|
||||
{
|
||||
alertId,
|
||||
scheduleOffsetMinutes,
|
||||
normalizedScheduleOffsetMinutes: scheduleOffsetInMins,
|
||||
windowSizeInMins,
|
||||
},
|
||||
'scheduleOffsetMinutes is greater than or equal to the interval and was normalized',
|
||||
);
|
||||
return scheduleOffsetInMins;
|
||||
};
|
||||
|
||||
const normalizeScheduleStartAt = ({
|
||||
alertId,
|
||||
scheduleStartAt,
|
||||
}: {
|
||||
alertId: string;
|
||||
scheduleStartAt: IAlert['scheduleStartAt'];
|
||||
}) => {
|
||||
if (scheduleStartAt == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fns.isValid(scheduleStartAt)) {
|
||||
return scheduleStartAt;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
alertId,
|
||||
scheduleStartAt,
|
||||
},
|
||||
'Invalid scheduleStartAt value detected, ignoring start time schedule',
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getScheduledWindowStart = (
|
||||
now: Date,
|
||||
windowSizeInMins: number,
|
||||
scheduleOffsetMinutes = 0,
|
||||
scheduleStartAt?: Date,
|
||||
) => {
|
||||
if (scheduleStartAt != null) {
|
||||
const windowSizeMs = windowSizeInMins * 60 * 1000;
|
||||
const elapsedMs = Math.max(0, now.getTime() - scheduleStartAt.getTime());
|
||||
const windowCountSinceStart = Math.floor(elapsedMs / windowSizeMs);
|
||||
return new Date(
|
||||
scheduleStartAt.getTime() + windowCountSinceStart * windowSizeMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (scheduleOffsetMinutes <= 0) {
|
||||
return roundDownToXMinutes(windowSizeInMins)(now);
|
||||
}
|
||||
|
||||
const shiftedNow = fns.subMinutes(now, scheduleOffsetMinutes);
|
||||
const roundedShiftedNow = roundDownToXMinutes(windowSizeInMins)(shiftedNow);
|
||||
return fns.addMinutes(roundedShiftedNow, scheduleOffsetMinutes);
|
||||
};
|
||||
|
||||
const fireChannelEvent = async ({
|
||||
alert,
|
||||
alertProvider,
|
||||
|
|
@ -185,6 +269,12 @@ const fireChannelEvent = async ({
|
|||
dashboardId: dashboard?.id,
|
||||
groupBy: alert.groupBy,
|
||||
interval: alert.interval,
|
||||
...(alert.scheduleOffsetMinutes != null && {
|
||||
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
|
||||
}),
|
||||
...(alert.scheduleStartAt != null && {
|
||||
scheduleStartAt: alert.scheduleStartAt.toISOString(),
|
||||
}),
|
||||
message: alert.message,
|
||||
name: alert.name,
|
||||
savedSearchId: savedSearch?.id,
|
||||
|
|
@ -287,6 +377,7 @@ const getAlertEvaluationDateRange = (
|
|||
hasGroupBy: boolean,
|
||||
nowInMinsRoundDown: Date,
|
||||
windowSizeInMins: number,
|
||||
scheduleStartAt?: Date,
|
||||
) => {
|
||||
// Calculate date range for the query
|
||||
// Find the latest createdAt among all histories for this alert
|
||||
|
|
@ -308,10 +399,16 @@ const getAlertEvaluationDateRange = (
|
|||
previousCreatedAt = previous?.createdAt;
|
||||
}
|
||||
|
||||
const rawStartTime = previousCreatedAt
|
||||
? previousCreatedAt.getTime()
|
||||
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime();
|
||||
const clampedStartTime =
|
||||
scheduleStartAt == null
|
||||
? rawStartTime
|
||||
: Math.max(rawStartTime, scheduleStartAt.getTime());
|
||||
|
||||
return calcAlertDateRange(
|
||||
previousCreatedAt
|
||||
? previousCreatedAt.getTime()
|
||||
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime(),
|
||||
clampedStartTime,
|
||||
nowInMinsRoundDown.getTime(),
|
||||
windowSizeInMins,
|
||||
);
|
||||
|
|
@ -454,7 +551,43 @@ export const processAlert = async (
|
|||
const { alert, source, previousMap } = details;
|
||||
try {
|
||||
const windowSizeInMins = ms(alert.interval) / 60000;
|
||||
const nowInMinsRoundDown = roundDownToXMinutes(windowSizeInMins)(now);
|
||||
const scheduleStartAt = normalizeScheduleStartAt({
|
||||
alertId: alert.id,
|
||||
scheduleStartAt: alert.scheduleStartAt,
|
||||
});
|
||||
if (scheduleStartAt != null && now < scheduleStartAt) {
|
||||
logger.info(
|
||||
{
|
||||
alertId: alert.id,
|
||||
now,
|
||||
scheduleStartAt,
|
||||
},
|
||||
'Skipped alert check because scheduleStartAt is in the future',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleOffsetMinutes = normalizeScheduleOffsetMinutes({
|
||||
alertId: alert.id,
|
||||
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
|
||||
windowSizeInMins,
|
||||
});
|
||||
if (scheduleStartAt != null && scheduleOffsetMinutes > 0) {
|
||||
logger.info(
|
||||
{
|
||||
alertId: alert.id,
|
||||
scheduleStartAt,
|
||||
scheduleOffsetMinutes,
|
||||
},
|
||||
'scheduleStartAt is set; scheduleOffsetMinutes is ignored for window alignment',
|
||||
);
|
||||
}
|
||||
const nowInMinsRoundDown = getScheduledWindowStart(
|
||||
now,
|
||||
windowSizeInMins,
|
||||
scheduleOffsetMinutes,
|
||||
scheduleStartAt,
|
||||
);
|
||||
const hasGroupBy = alertHasGroupBy(details);
|
||||
|
||||
// Check if we should skip this alert check based on last evaluation time
|
||||
|
|
@ -466,6 +599,8 @@ export const processAlert = async (
|
|||
now,
|
||||
alertId: alert.id,
|
||||
hasGroupBy,
|
||||
scheduleOffsetMinutes,
|
||||
scheduleStartAt,
|
||||
},
|
||||
`Skipped to check alert since the time diff is still less than 1 window size`,
|
||||
);
|
||||
|
|
@ -477,7 +612,20 @@ export const processAlert = async (
|
|||
hasGroupBy,
|
||||
nowInMinsRoundDown,
|
||||
windowSizeInMins,
|
||||
scheduleStartAt,
|
||||
);
|
||||
if (dateRange[0].getTime() >= dateRange[1].getTime()) {
|
||||
logger.info(
|
||||
{
|
||||
alertId: alert.id,
|
||||
dateRange,
|
||||
nowInMinsRoundDown,
|
||||
scheduleStartAt,
|
||||
},
|
||||
'Skipped alert check because the anchored window has not fully elapsed yet',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chartConfig = getChartConfigFromAlert(
|
||||
details,
|
||||
|
|
|
|||
48
packages/api/src/utils/__tests__/externalApi.test.ts
Normal file
48
packages/api/src/utils/__tests__/externalApi.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Types } from 'mongoose';
|
||||
|
||||
import {
|
||||
type AlertDocument,
|
||||
AlertSource,
|
||||
AlertState,
|
||||
AlertThresholdType,
|
||||
} from '@/models/alert';
|
||||
import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi';
|
||||
|
||||
const createAlertDocument = (
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
): AlertDocument =>
|
||||
({
|
||||
_id: new Types.ObjectId(),
|
||||
team: new Types.ObjectId(),
|
||||
threshold: 5,
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
state: AlertState.OK,
|
||||
channel: { type: null },
|
||||
...overrides,
|
||||
}) as unknown as AlertDocument;
|
||||
|
||||
describe('utils/externalApi', () => {
|
||||
describe('translateAlertDocumentToExternalAlert', () => {
|
||||
it('returns scheduleStartAt as null when explicitly cleared', () => {
|
||||
const alert = createAlertDocument({
|
||||
scheduleStartAt: null,
|
||||
});
|
||||
|
||||
const translated = translateAlertDocumentToExternalAlert(alert);
|
||||
|
||||
expect(translated.scheduleStartAt).toBeNull();
|
||||
});
|
||||
|
||||
it('returns scheduleStartAt as undefined when the value is missing', () => {
|
||||
const alert = createAlertDocument({
|
||||
scheduleStartAt: undefined,
|
||||
});
|
||||
|
||||
const translated = translateAlertDocumentToExternalAlert(alert);
|
||||
|
||||
expect(translated.scheduleStartAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -61,3 +61,55 @@ export function validateRequestWithEnhancedErrors(schemas: {
|
|||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation middleware that validates and assigns parsed request data.
|
||||
* This preserves Zod transforms/refinements and strips unknown fields while
|
||||
* keeping the same concatenated external API error format.
|
||||
*/
|
||||
export function processRequestWithEnhancedErrors(schemas: {
|
||||
body?: z.ZodSchema;
|
||||
params?: z.ZodSchema;
|
||||
query?: z.ZodSchema;
|
||||
}) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (schemas.body) {
|
||||
const result = schemas.body.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
errors.push(`Body validation failed: ${formatZodError(result.error)}`);
|
||||
} else {
|
||||
req.body = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (schemas.params) {
|
||||
const result = schemas.params.safeParse(req.params);
|
||||
if (!result.success) {
|
||||
errors.push(
|
||||
`Params validation failed: ${formatZodError(result.error)}`,
|
||||
);
|
||||
} else {
|
||||
req.params = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (schemas.query) {
|
||||
const result = schemas.query.safeParse(req.query);
|
||||
if (!result.success) {
|
||||
errors.push(`Query validation failed: ${formatZodError(result.error)}`);
|
||||
} else {
|
||||
req.query = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
message: errors.join('; '),
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ export type ExternalAlert = {
|
|||
message?: string | null;
|
||||
threshold: number;
|
||||
interval: AlertInterval;
|
||||
scheduleOffsetMinutes?: number;
|
||||
scheduleStartAt?: string | null;
|
||||
thresholdType: AlertThresholdType;
|
||||
source?: string;
|
||||
state: AlertState;
|
||||
|
|
@ -263,6 +265,24 @@ function hasUpdatedAt(
|
|||
return 'updatedAt' in alert && alert.updatedAt instanceof Date;
|
||||
}
|
||||
|
||||
function transformScheduleStartAt(
|
||||
scheduleStartAt: unknown,
|
||||
): ExternalAlert['scheduleStartAt'] {
|
||||
if (scheduleStartAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scheduleStartAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (scheduleStartAt instanceof Date) {
|
||||
return scheduleStartAt.toISOString();
|
||||
}
|
||||
|
||||
return typeof scheduleStartAt === 'string' ? scheduleStartAt : undefined;
|
||||
}
|
||||
|
||||
function transformSilencedToExternalSilenced(
|
||||
silenced: AlertDocumentObject['silenced'],
|
||||
): ExternalAlert['silenced'] {
|
||||
|
|
@ -290,6 +310,10 @@ export function translateAlertDocumentToExternalAlert(
|
|||
message: alertObj.message,
|
||||
threshold: alertObj.threshold,
|
||||
interval: alertObj.interval,
|
||||
...(alertObj.scheduleOffsetMinutes != null && {
|
||||
scheduleOffsetMinutes: alertObj.scheduleOffsetMinutes,
|
||||
}),
|
||||
scheduleStartAt: transformScheduleStartAt(alertObj.scheduleStartAt),
|
||||
thresholdType: alertObj.thresholdType,
|
||||
source: alertObj.source,
|
||||
state: alertObj.state,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import {
|
|||
DashboardFilterSchema,
|
||||
MetricsDataType,
|
||||
NumberFormatSchema,
|
||||
scheduleStartAtSchema,
|
||||
SearchConditionLanguageSchema as whereLanguageSchema,
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
WebhookService,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Types } from 'mongoose';
|
||||
|
|
@ -408,13 +410,16 @@ export const alertSchema = z
|
|||
.object({
|
||||
channel: zChannel,
|
||||
interval: z.enum(['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d']),
|
||||
scheduleOffsetMinutes: z.number().int().min(0).max(1439).optional(),
|
||||
scheduleStartAt: scheduleStartAtSchema,
|
||||
threshold: z.number().min(0),
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
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));
|
||||
.and(zSavedSearchAlert.or(zTileAlert))
|
||||
.superRefine(validateAlertScheduleOffsetMinutes);
|
||||
|
||||
// ==============================
|
||||
// Webhooks
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import {
|
|||
AlertIntervalSchema,
|
||||
AlertSource,
|
||||
AlertThresholdType,
|
||||
scheduleStartAtSchema,
|
||||
SearchCondition,
|
||||
SearchConditionLanguage,
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
zAlertChannel,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import { Alert as MantineAlert, TextInput } from '@mantine/core';
|
||||
|
|
@ -29,7 +31,6 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBrandSlack,
|
||||
IconChartLine,
|
||||
IconInfoCircleFilled,
|
||||
IconPlus,
|
||||
|
|
@ -44,10 +45,13 @@ import {
|
|||
ALERT_CHANNEL_OPTIONS,
|
||||
ALERT_INTERVAL_OPTIONS,
|
||||
ALERT_THRESHOLD_TYPE_OPTIONS,
|
||||
intervalToMinutes,
|
||||
normalizeNoOpAlertScheduleFields,
|
||||
} from '@/utils/alerts';
|
||||
|
||||
import { AlertPreviewChart } from './components/AlertPreviewChart';
|
||||
import { AlertChannelForm } from './components/Alerts';
|
||||
import { AlertScheduleFields } from './components/AlertScheduleFields';
|
||||
import { getStoredLanguage } from './components/SearchInput/SearchWhereInput';
|
||||
import { SQLInlineEditorControlled } from './components/SearchInput/SQLInlineEditor';
|
||||
import { getWebhookChannelIcon } from './utils/webhookIcons';
|
||||
|
|
@ -59,10 +63,13 @@ const SavedSearchAlertFormSchema = z
|
|||
.object({
|
||||
interval: AlertIntervalSchema,
|
||||
threshold: z.number().int().min(1),
|
||||
scheduleOffsetMinutes: z.number().int().min(0).default(0),
|
||||
scheduleStartAt: scheduleStartAtSchema,
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
channel: zAlertChannel,
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.superRefine(validateAlertScheduleOffsetMinutes);
|
||||
|
||||
const AlertForm = ({
|
||||
sourceId,
|
||||
|
|
@ -91,17 +98,30 @@ const AlertForm = ({
|
|||
}) => {
|
||||
const { data: source } = useSource({ id: sourceId });
|
||||
|
||||
const { control, handleSubmit } = useForm<Alert>({
|
||||
defaultValues: defaultValues || {
|
||||
interval: '5m',
|
||||
threshold: 1,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: '',
|
||||
},
|
||||
},
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { dirtyFields },
|
||||
} = useForm<Alert>({
|
||||
defaultValues: defaultValues
|
||||
? {
|
||||
...defaultValues,
|
||||
scheduleOffsetMinutes: defaultValues.scheduleOffsetMinutes ?? 0,
|
||||
scheduleStartAt: defaultValues.scheduleStartAt ?? null,
|
||||
}
|
||||
: {
|
||||
interval: '5m',
|
||||
threshold: 1,
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: '',
|
||||
},
|
||||
},
|
||||
resolver: zodResolver(SavedSearchAlertFormSchema),
|
||||
});
|
||||
|
||||
|
|
@ -109,11 +129,31 @@ const AlertForm = ({
|
|||
const thresholdType = useWatch({ control, name: 'thresholdType' });
|
||||
const channelType = useWatch({ control, name: 'channel.type' });
|
||||
const interval = useWatch({ control, name: 'interval' });
|
||||
const scheduleOffsetMinutes = useWatch({
|
||||
control,
|
||||
name: 'scheduleOffsetMinutes',
|
||||
});
|
||||
const groupByValue = useWatch({ control, name: 'groupBy' });
|
||||
const threshold = useWatch({ control, name: 'threshold' });
|
||||
const maxScheduleOffsetMinutes = Math.max(
|
||||
intervalToMinutes(interval ?? '5m') - 1,
|
||||
0,
|
||||
);
|
||||
const intervalLabel = ALERT_INTERVAL_OPTIONS[interval ?? '5m'];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form
|
||||
onSubmit={handleSubmit(data =>
|
||||
onSubmit(
|
||||
normalizeNoOpAlertScheduleFields(data, defaultValues, {
|
||||
preserveExplicitScheduleOffsetMinutes:
|
||||
dirtyFields.scheduleOffsetMinutes === true,
|
||||
preserveExplicitScheduleStartAt:
|
||||
dirtyFields.scheduleStartAt === true,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Paper px="md" py="sm" radius="xs">
|
||||
<Text size="xxs" opacity={0.5}>
|
||||
|
|
@ -155,6 +195,15 @@ const AlertForm = ({
|
|||
control={control}
|
||||
/>
|
||||
</Group>
|
||||
<AlertScheduleFields
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
scheduleOffsetName="scheduleOffsetMinutes"
|
||||
scheduleStartAtName="scheduleStartAt"
|
||||
scheduleOffsetMinutes={scheduleOffsetMinutes}
|
||||
maxScheduleOffsetMinutes={maxScheduleOffsetMinutes}
|
||||
offsetWindowLabel={`from each ${intervalLabel} window`}
|
||||
/>
|
||||
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
|
||||
grouped by
|
||||
</Text>
|
||||
|
|
|
|||
191
packages/app/src/components/AlertScheduleFields.tsx
Normal file
191
packages/app/src/components/AlertScheduleFields.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
PathValue,
|
||||
UseFormSetValue,
|
||||
useWatch,
|
||||
} from 'react-hook-form';
|
||||
import { NumberInput } from 'react-hook-form-mantine';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker } from '@mantine/dates';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { parseScheduleStartAtValue } from '@/utils/alerts';
|
||||
|
||||
const DATE_TIME_INPUT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
type AlertScheduleFieldsProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
setValue: UseFormSetValue<T>;
|
||||
scheduleOffsetName: FieldPath<T>;
|
||||
scheduleStartAtName: FieldPath<T>;
|
||||
scheduleOffsetMinutes: number | null | undefined;
|
||||
maxScheduleOffsetMinutes: number;
|
||||
offsetWindowLabel: string;
|
||||
};
|
||||
|
||||
export function AlertScheduleFields<T extends FieldValues>({
|
||||
control,
|
||||
setValue,
|
||||
scheduleOffsetName,
|
||||
scheduleStartAtName,
|
||||
scheduleOffsetMinutes,
|
||||
maxScheduleOffsetMinutes,
|
||||
offsetWindowLabel,
|
||||
}: AlertScheduleFieldsProps<T>) {
|
||||
const showScheduleOffsetInput = maxScheduleOffsetMinutes > 0;
|
||||
const scheduleStartAtValue = useWatch({
|
||||
control,
|
||||
name: scheduleStartAtName,
|
||||
}) as string | null | undefined;
|
||||
const hasScheduleStartAtAnchor = scheduleStartAtValue != null;
|
||||
const hasAdvancedScheduleValues =
|
||||
(scheduleOffsetMinutes ?? 0) > 0 || hasScheduleStartAtAnchor;
|
||||
const [opened, setOpened] = useState(hasAdvancedScheduleValues);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedOffset = scheduleOffsetMinutes ?? 0;
|
||||
if (!showScheduleOffsetInput && normalizedOffset !== 0) {
|
||||
setValue(scheduleOffsetName, 0 as PathValue<T, FieldPath<T>>, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hasScheduleStartAtAnchor && normalizedOffset > 0) {
|
||||
setValue(scheduleOffsetName, 0 as PathValue<T, FieldPath<T>>, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
hasScheduleStartAtAnchor,
|
||||
scheduleOffsetMinutes,
|
||||
scheduleOffsetName,
|
||||
setValue,
|
||||
showScheduleOffsetInput,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpened(current => !current)}
|
||||
mt="xs"
|
||||
data-testid="alert-advanced-settings-toggle"
|
||||
>
|
||||
<Group gap={4}>
|
||||
{opened ? (
|
||||
<IconChevronDown size={14} opacity={0.5} />
|
||||
) : (
|
||||
<IconChevronRight size={14} opacity={0.5} />
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
Advanced Settings
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse in={opened}>
|
||||
<Box data-testid="alert-advanced-settings-panel">
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Optional schedule controls for aligning alert windows.
|
||||
</Text>
|
||||
{showScheduleOffsetInput && (
|
||||
<>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Group gap={4}>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
Start offset (min)
|
||||
</Text>
|
||||
<Tooltip
|
||||
label="Delays the start of each evaluation window by this many minutes. Useful when data is ingested with a lag."
|
||||
multiline
|
||||
w={260}
|
||||
withArrow
|
||||
zIndex={10050}
|
||||
>
|
||||
<Box style={{ lineHeight: 1, cursor: 'help' }}>
|
||||
<IconInfoCircle size={14} opacity={0.4} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={maxScheduleOffsetMinutes}
|
||||
step={1}
|
||||
size="xs"
|
||||
w={100}
|
||||
control={control}
|
||||
name={scheduleOffsetName}
|
||||
disabled={hasScheduleStartAtAnchor}
|
||||
/>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
{offsetWindowLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
{hasScheduleStartAtAnchor && (
|
||||
<Text size="xs" opacity={0.6} mt={4}>
|
||||
Start offset is ignored while an anchor start time is set.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Group gap="xs" mt="xs" align="start">
|
||||
<Group gap={4} mt={6}>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
Anchor start time
|
||||
</Text>
|
||||
<Tooltip
|
||||
label="Pins alert windows to a fixed starting point instead of the default rolling schedule. Windows repeat at the configured interval from this time."
|
||||
multiline
|
||||
w={260}
|
||||
withArrow
|
||||
zIndex={10050}
|
||||
>
|
||||
<Box style={{ lineHeight: 1, cursor: 'help' }}>
|
||||
<IconInfoCircle size={14} opacity={0.4} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Controller
|
||||
control={control}
|
||||
name={scheduleStartAtName}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<DateTimePicker
|
||||
size="xs"
|
||||
valueFormat={DATE_TIME_INPUT_FORMAT}
|
||||
w={260}
|
||||
placeholder={DATE_TIME_INPUT_FORMAT}
|
||||
clearable
|
||||
dropdownType="popover"
|
||||
popoverProps={{ withinPortal: true, zIndex: 10050 }}
|
||||
value={parseScheduleStartAtValue(
|
||||
field.value as string | null | undefined,
|
||||
)}
|
||||
onChange={value =>
|
||||
field.onChange(value?.toISOString() ?? null)
|
||||
}
|
||||
error={error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text size="xs" opacity={0.6} mt={6}>
|
||||
Displayed in local time, stored as UTC
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import {
|
|||
RawSqlSavedChartConfig,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
import { AlertWithCreatedBy } from '@/types';
|
||||
|
||||
export type SavedChartConfigWithSelectArray = Omit<
|
||||
BuilderSavedChartConfig,
|
||||
'select'
|
||||
|
|
@ -24,6 +26,9 @@ export type SavedChartConfigWithSelectArray = Omit<
|
|||
**/
|
||||
export type ChartEditorFormState = Partial<BuilderSavedChartConfig> &
|
||||
Partial<Omit<RawSqlSavedChartConfig, 'configType'>> & {
|
||||
alert?: BuilderSavedChartConfig['alert'] & {
|
||||
createdBy?: AlertWithCreatedBy['createdBy'];
|
||||
};
|
||||
series: SavedChartConfigWithSelectArray['select'];
|
||||
configType?: 'sql' | 'builder';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
SelectList,
|
||||
SourceKind,
|
||||
TSource,
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Accordion,
|
||||
|
|
@ -107,6 +108,8 @@ import {
|
|||
DEFAULT_TILE_ALERT,
|
||||
extendDateRangeToInterval,
|
||||
intervalToGranularity,
|
||||
intervalToMinutes,
|
||||
normalizeNoOpAlertScheduleFields,
|
||||
TILE_ALERT_INTERVAL_OPTIONS,
|
||||
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
|
||||
} from '@/utils/alerts';
|
||||
|
|
@ -126,6 +129,7 @@ import {
|
|||
import { ErrorBoundary } from './Error/ErrorBoundary';
|
||||
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
|
||||
import { AggFnSelectControlled } from './AggFnSelect';
|
||||
import { AlertScheduleFields } from './AlertScheduleFields';
|
||||
import ChartDisplaySettingsDrawer, {
|
||||
ChartConfigDisplaySettings,
|
||||
} from './ChartDisplaySettingsDrawer';
|
||||
|
|
@ -385,7 +389,7 @@ function ChartSeriesEditorComponent({
|
|||
<AggFnSelectControlled
|
||||
aggFnName={`${namePrefix}aggFn`}
|
||||
quantileLevelName={`${namePrefix}level`}
|
||||
defaultValue={AGG_FNS[0].value}
|
||||
defaultValue={AGG_FNS[0]?.value ?? 'avg'}
|
||||
control={control}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -533,7 +537,9 @@ const ChartSeriesEditor = ChartSeriesEditorComponent;
|
|||
const zSavedChartConfig = z
|
||||
.object({
|
||||
// TODO: Chart
|
||||
alert: ChartAlertBaseSchema.optional(),
|
||||
alert: ChartAlertBaseSchema.superRefine(
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
|
@ -580,7 +586,7 @@ export default function EditTimeChartForm({
|
|||
register,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors, isDirty },
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<ChartEditorFormState>({
|
||||
defaultValues: formValue,
|
||||
values: formValue,
|
||||
|
|
@ -612,9 +618,23 @@ export default function EditTimeChartForm({
|
|||
useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
|
||||
const markdown = useWatch({ control, name: 'markdown' });
|
||||
const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
|
||||
const alertScheduleOffsetMinutes = useWatch({
|
||||
control,
|
||||
name: 'alert.scheduleOffsetMinutes',
|
||||
});
|
||||
const granularity = useWatch({ control, name: 'granularity' });
|
||||
const maxAlertScheduleOffsetMinutes = alert?.interval
|
||||
? Math.max(intervalToMinutes(alert.interval) - 1, 0)
|
||||
: 0;
|
||||
const alertIntervalLabel = alert?.interval
|
||||
? TILE_ALERT_INTERVAL_OPTIONS[alert.interval]
|
||||
: undefined;
|
||||
const configType = useWatch({ control, name: 'configType' });
|
||||
|
||||
const chartConfigAlert = !isRawSqlSavedChartConfig(chartConfig)
|
||||
? chartConfig.alert
|
||||
: undefined;
|
||||
|
||||
const isRawSqlInput =
|
||||
configType === 'sql' && displayType === DisplayType.Table;
|
||||
|
||||
|
|
@ -747,7 +767,22 @@ export default function EditTimeChartForm({
|
|||
);
|
||||
|
||||
if (savedConfig && queriedConfig) {
|
||||
setChartConfig?.(savedConfig);
|
||||
const normalizedSavedConfig = isRawSqlSavedChartConfig(savedConfig)
|
||||
? savedConfig
|
||||
: {
|
||||
...savedConfig,
|
||||
alert: normalizeNoOpAlertScheduleFields(
|
||||
savedConfig.alert,
|
||||
chartConfigAlert,
|
||||
{
|
||||
preserveExplicitScheduleOffsetMinutes:
|
||||
dirtyFields.alert?.scheduleOffsetMinutes === true,
|
||||
preserveExplicitScheduleStartAt:
|
||||
dirtyFields.alert?.scheduleStartAt === true,
|
||||
},
|
||||
),
|
||||
};
|
||||
setChartConfig?.(normalizedSavedConfig);
|
||||
setQueriedConfigAndSource(
|
||||
queriedConfig,
|
||||
isRawSqlChart ? undefined : tableSource,
|
||||
|
|
@ -755,6 +790,9 @@ export default function EditTimeChartForm({
|
|||
}
|
||||
})();
|
||||
}, [
|
||||
chartConfigAlert,
|
||||
dirtyFields.alert?.scheduleOffsetMinutes,
|
||||
dirtyFields.alert?.scheduleStartAt,
|
||||
handleSubmit,
|
||||
setChartConfig,
|
||||
setQueriedConfigAndSource,
|
||||
|
|
@ -806,9 +844,34 @@ export default function EditTimeChartForm({
|
|||
tableSource,
|
||||
);
|
||||
|
||||
if (savedChartConfig) onSave?.(savedChartConfig);
|
||||
if (savedChartConfig) {
|
||||
const normalizedSavedConfig = isRawSqlSavedChartConfig(savedChartConfig)
|
||||
? savedChartConfig
|
||||
: {
|
||||
...savedChartConfig,
|
||||
alert: normalizeNoOpAlertScheduleFields(
|
||||
savedChartConfig.alert,
|
||||
chartConfigAlert,
|
||||
{
|
||||
preserveExplicitScheduleOffsetMinutes:
|
||||
dirtyFields.alert?.scheduleOffsetMinutes === true,
|
||||
preserveExplicitScheduleStartAt:
|
||||
dirtyFields.alert?.scheduleStartAt === true,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
onSave?.(normalizedSavedConfig);
|
||||
}
|
||||
},
|
||||
[onSave, tableSource, setError],
|
||||
[
|
||||
onSave,
|
||||
tableSource,
|
||||
setError,
|
||||
chartConfigAlert,
|
||||
dirtyFields.alert?.scheduleOffsetMinutes,
|
||||
dirtyFields.alert?.scheduleStartAt,
|
||||
],
|
||||
);
|
||||
|
||||
// Track previous values for detecting changes
|
||||
|
|
@ -1317,54 +1380,68 @@ export default function EditTimeChartForm({
|
|||
)}
|
||||
{alert && (
|
||||
<Paper my="sm">
|
||||
<Stack gap="xs">
|
||||
<Paper px="md" py="sm" radius="xs" data-testid="alert-details">
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" opacity={0.7}>
|
||||
Alert when the value
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(
|
||||
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
|
||||
)}
|
||||
size="xs"
|
||||
name={`alert.thresholdType`}
|
||||
control={control}
|
||||
/>
|
||||
<NumberInput
|
||||
min={MINIMUM_THRESHOLD_VALUE}
|
||||
size="xs"
|
||||
w={80}
|
||||
control={control}
|
||||
name={`alert.threshold`}
|
||||
/>
|
||||
over
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.interval`}
|
||||
control={control}
|
||||
/>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
window via
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.channel.type`}
|
||||
control={control}
|
||||
/>
|
||||
</Group>
|
||||
{(alert as any)?.createdBy && (
|
||||
<Text size="xs" opacity={0.6}>
|
||||
Created by{' '}
|
||||
{(alert as any).createdBy?.name ||
|
||||
(alert as any).createdBy?.email}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap="xs" data-testid="alert-details">
|
||||
<Paper px="md" py="sm" radius="xs">
|
||||
<Text size="xxs" opacity={0.5} mb={4}>
|
||||
Trigger
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" opacity={0.7}>
|
||||
Alert when the value
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(
|
||||
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
|
||||
)}
|
||||
size="xs"
|
||||
name={`alert.thresholdType`}
|
||||
control={control}
|
||||
/>
|
||||
<NumberInput
|
||||
min={MINIMUM_THRESHOLD_VALUE}
|
||||
size="xs"
|
||||
w={80}
|
||||
control={control}
|
||||
name={`alert.threshold`}
|
||||
/>
|
||||
over
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.interval`}
|
||||
control={control}
|
||||
/>
|
||||
<Text size="sm" opacity={0.7}>
|
||||
window via
|
||||
</Text>
|
||||
<NativeSelect
|
||||
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
|
||||
size="xs"
|
||||
name={`alert.channel.type`}
|
||||
control={control}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
|
||||
{alert?.createdBy && (
|
||||
<Text size="xs" opacity={0.6} mt="xs">
|
||||
Created by {alert.createdBy.name || alert.createdBy.email}
|
||||
</Text>
|
||||
)}
|
||||
<AlertScheduleFields
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
scheduleOffsetName="alert.scheduleOffsetMinutes"
|
||||
scheduleStartAtName="alert.scheduleStartAt"
|
||||
scheduleOffsetMinutes={alertScheduleOffsetMinutes}
|
||||
maxScheduleOffsetMinutes={maxAlertScheduleOffsetMinutes}
|
||||
offsetWindowLabel={
|
||||
alertIntervalLabel
|
||||
? `from each ${alertIntervalLabel} window`
|
||||
: 'from each alert window'
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
<Paper px="md" py="sm" radius="xs">
|
||||
<Text size="xxs" opacity={0.5} mb={4}>
|
||||
Send to
|
||||
</Text>
|
||||
<AlertChannelForm
|
||||
|
|
|
|||
|
|
@ -473,4 +473,22 @@ describe('DBEditTimeChartForm - Add/delete alerts for display type Number', () =
|
|||
// Verify that onSave was not called
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows alert scheduling fields inside advanced settings', async () => {
|
||||
renderComponent();
|
||||
|
||||
await userEvent.click(screen.getByTestId('alert-button'));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('alert-advanced-settings-panel'),
|
||||
).not.toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByTestId('alert-advanced-settings-toggle'));
|
||||
|
||||
expect(screen.getByTestId('alert-advanced-settings-panel')).toBeVisible();
|
||||
expect(screen.getByText('Anchor start time')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('alert-advanced-settings-toggle'),
|
||||
).toHaveTextContent('Advanced Settings');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
95
packages/app/src/utils/__tests__/alerts.test.ts
Normal file
95
packages/app/src/utils/__tests__/alerts.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { normalizeNoOpAlertScheduleFields } from '../alerts';
|
||||
|
||||
describe('normalizeNoOpAlertScheduleFields', () => {
|
||||
it('drops no-op schedule fields for pre-migration alerts', () => {
|
||||
const normalized = normalizeNoOpAlertScheduleFields(
|
||||
{
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({});
|
||||
});
|
||||
|
||||
it('treats undefined previous values as absent fields', () => {
|
||||
const normalized = normalizeNoOpAlertScheduleFields(
|
||||
{
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
},
|
||||
{
|
||||
scheduleOffsetMinutes: undefined,
|
||||
scheduleStartAt: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps no-op fields when they were already persisted', () => {
|
||||
const normalized = normalizeNoOpAlertScheduleFields(
|
||||
{
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
},
|
||||
{
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-default schedule fields', () => {
|
||||
const normalized = normalizeNoOpAlertScheduleFields(
|
||||
{
|
||||
scheduleOffsetMinutes: 3,
|
||||
scheduleStartAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
scheduleOffsetMinutes: 3,
|
||||
scheduleStartAt: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicit offset reset when requested', () => {
|
||||
const normalized = normalizeNoOpAlertScheduleFields(
|
||||
{
|
||||
scheduleOffsetMinutes: 0,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
preserveExplicitScheduleOffsetMinutes: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
scheduleOffsetMinutes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicit start-at clear when requested', () => {
|
||||
const normalized = normalizeNoOpAlertScheduleFields(
|
||||
{
|
||||
scheduleStartAt: null,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
preserveExplicitScheduleStartAt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
scheduleStartAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import _ from 'lodash';
|
|||
import { z } from 'zod';
|
||||
import { Granularity } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
ALERT_INTERVAL_TO_MINUTES,
|
||||
AlertChannelType,
|
||||
AlertInterval,
|
||||
AlertThresholdType,
|
||||
|
|
@ -28,6 +29,10 @@ export function intervalToGranularity(interval: AlertInterval) {
|
|||
return Granularity.OneDay;
|
||||
}
|
||||
|
||||
export function intervalToMinutes(interval: AlertInterval): number {
|
||||
return ALERT_INTERVAL_TO_MINUTES[interval];
|
||||
}
|
||||
|
||||
export function intervalToDateRange(interval: AlertInterval): [Date, Date] {
|
||||
const now = new Date();
|
||||
if (interval === '1m') return [sub(now, { minutes: 15 }), now];
|
||||
|
|
@ -114,6 +119,8 @@ export const DEFAULT_TILE_ALERT: z.infer<typeof ChartAlertBaseSchema> = {
|
|||
threshold: 1,
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
interval: '5m',
|
||||
scheduleOffsetMinutes: 0,
|
||||
scheduleStartAt: null,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: '',
|
||||
|
|
@ -130,3 +137,66 @@ export function isAlertSilenceExpired(silenced?: {
|
|||
}): boolean {
|
||||
return silenced ? new Date() > new Date(silenced.until) : false;
|
||||
}
|
||||
|
||||
export function parseScheduleStartAtValue(
|
||||
value: string | null | undefined,
|
||||
): Date | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(value);
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||
}
|
||||
|
||||
type AlertScheduleFields = {
|
||||
scheduleOffsetMinutes?: number;
|
||||
scheduleStartAt?: string | null;
|
||||
};
|
||||
|
||||
type NormalizeAlertScheduleOptions = {
|
||||
preserveExplicitScheduleOffsetMinutes?: boolean;
|
||||
preserveExplicitScheduleStartAt?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep alert documents backward-compatible by avoiding no-op writes for
|
||||
* scheduling fields on pre-migration alerts that never had these keys.
|
||||
*/
|
||||
export function normalizeNoOpAlertScheduleFields<
|
||||
T extends AlertScheduleFields | undefined,
|
||||
>(
|
||||
alert: T,
|
||||
previousAlert?: AlertScheduleFields | null,
|
||||
options: NormalizeAlertScheduleOptions = {},
|
||||
): T {
|
||||
if (alert == null) {
|
||||
return alert;
|
||||
}
|
||||
|
||||
const normalizedAlert = { ...alert };
|
||||
// Treat undefined as "field absent" so we don't depend on object key
|
||||
// preservation/stripping behavior from any parsing layer.
|
||||
const previousHadOffset =
|
||||
previousAlert != null && previousAlert.scheduleOffsetMinutes !== undefined;
|
||||
const previousHadStartAt =
|
||||
previousAlert != null && previousAlert.scheduleStartAt !== undefined;
|
||||
|
||||
if (
|
||||
(normalizedAlert.scheduleOffsetMinutes ?? 0) === 0 &&
|
||||
!previousHadOffset &&
|
||||
!options.preserveExplicitScheduleOffsetMinutes
|
||||
) {
|
||||
delete normalizedAlert.scheduleOffsetMinutes;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedAlert.scheduleStartAt == null &&
|
||||
!previousHadStartAt &&
|
||||
!options.preserveExplicitScheduleStartAt
|
||||
) {
|
||||
delete normalizedAlert.scheduleStartAt;
|
||||
}
|
||||
|
||||
return normalizedAlert as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,6 +301,17 @@ export const AlertIntervalSchema = z.union([
|
|||
|
||||
export type AlertInterval = z.infer<typeof AlertIntervalSchema>;
|
||||
|
||||
export const ALERT_INTERVAL_TO_MINUTES: Record<AlertInterval, number> = {
|
||||
'1m': 1,
|
||||
'5m': 5,
|
||||
'15m': 15,
|
||||
'30m': 30,
|
||||
'1h': 60,
|
||||
'6h': 360,
|
||||
'12h': 720,
|
||||
'1d': 1440,
|
||||
};
|
||||
|
||||
export const zAlertChannelType = z.literal('webhook');
|
||||
|
||||
export type AlertChannelType = z.infer<typeof zAlertChannelType>;
|
||||
|
|
@ -322,9 +333,69 @@ export const zTileAlert = z.object({
|
|||
dashboardId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const AlertBaseSchema = z.object({
|
||||
export const validateAlertScheduleOffsetMinutes = (
|
||||
alert: {
|
||||
interval: AlertInterval;
|
||||
scheduleOffsetMinutes?: number;
|
||||
scheduleStartAt?: string | Date | null;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
const scheduleOffsetMinutes = alert.scheduleOffsetMinutes ?? 0;
|
||||
const intervalMinutes = ALERT_INTERVAL_TO_MINUTES[alert.interval];
|
||||
|
||||
if (alert.scheduleStartAt != null && scheduleOffsetMinutes > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
'scheduleOffsetMinutes must be 0 when scheduleStartAt is provided',
|
||||
path: ['scheduleOffsetMinutes'],
|
||||
});
|
||||
}
|
||||
|
||||
if (scheduleOffsetMinutes >= intervalMinutes) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `scheduleOffsetMinutes must be less than ${intervalMinutes} minute${intervalMinutes === 1 ? '' : 's'}`,
|
||||
path: ['scheduleOffsetMinutes'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
export const scheduleStartAtSchema = z
|
||||
.union([z.string().datetime(), z.null()])
|
||||
.optional()
|
||||
.refine(
|
||||
value =>
|
||||
value == null ||
|
||||
new Date(value).getTime() <= Date.now() + MAX_SCHEDULE_START_AT_FUTURE_MS,
|
||||
{
|
||||
message: 'scheduleStartAt must be within 1 year from now',
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
value =>
|
||||
value == null ||
|
||||
new Date(value).getTime() >= Date.now() - MAX_SCHEDULE_START_AT_PAST_MS,
|
||||
{
|
||||
message: 'scheduleStartAt must be within 10 years in the past',
|
||||
},
|
||||
);
|
||||
|
||||
export const AlertBaseObjectSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
interval: AlertIntervalSchema,
|
||||
scheduleOffsetMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SCHEDULE_OFFSET_MINUTES)
|
||||
.optional(),
|
||||
scheduleStartAt: scheduleStartAtSchema,
|
||||
threshold: z.number().int().min(1),
|
||||
thresholdType: z.nativeEnum(AlertThresholdType),
|
||||
channel: zAlertChannel,
|
||||
|
|
@ -340,13 +411,25 @@ export const AlertBaseSchema = z.object({
|
|||
.optional(),
|
||||
});
|
||||
|
||||
export const ChartAlertBaseSchema = AlertBaseSchema.extend({
|
||||
// Keep AlertBaseSchema as a ZodObject for backwards compatibility with
|
||||
// external consumers that call object helpers like .extend()/.pick()/.omit().
|
||||
export const AlertBaseSchema = AlertBaseObjectSchema;
|
||||
|
||||
const AlertBaseValidatedSchema = AlertBaseObjectSchema.superRefine(
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
);
|
||||
|
||||
export const ChartAlertBaseSchema = AlertBaseObjectSchema.extend({
|
||||
threshold: z.number().positive(),
|
||||
});
|
||||
|
||||
const ChartAlertBaseValidatedSchema = ChartAlertBaseSchema.superRefine(
|
||||
validateAlertScheduleOffsetMinutes,
|
||||
);
|
||||
|
||||
export const AlertSchema = z.union([
|
||||
z.intersection(AlertBaseSchema, zSavedSearchAlert),
|
||||
z.intersection(ChartAlertBaseSchema, zTileAlert),
|
||||
z.intersection(AlertBaseValidatedSchema, zSavedSearchAlert),
|
||||
z.intersection(ChartAlertBaseValidatedSchema, zTileAlert),
|
||||
]);
|
||||
|
||||
export type Alert = z.infer<typeof AlertSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue