diff --git a/.changeset/quiet-alert-groups.md b/.changeset/quiet-alert-groups.md new file mode 100644 index 0000000000..dddb5c189d --- /dev/null +++ b/.changeset/quiet-alert-groups.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/api': patch +'@hyperdx/app': patch +--- + +Add per-group alert acknowledgments and group resume overrides so grouped alerts can mute or resume notifications for one group without changing other groups or the aggregate alert state. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 4c076f92fa..32f8c8a661 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -175,6 +175,26 @@ } } }, + "AlertSilencedGroup": { + "allOf": [ + { + "$ref": "#/components/schemas/AlertSilenced" + }, + { + "type": "object", + "required": [ + "group" + ], + "properties": { + "group": { + "type": "string", + "description": "Exact group key being silenced.", + "example": "service:api" + } + } + } + ] + }, "AlertChannelWebhook": { "type": "object", "required": [ @@ -331,6 +351,14 @@ "description": "Silencing metadata.", "nullable": true }, + "silencedGroups": { + "type": "array", + "nullable": true, + "description": "Stored per-group silences, if any.", + "items": { + "$ref": "#/components/schemas/AlertSilencedGroup" + } + }, "executionErrors": { "type": "array", "nullable": true, diff --git a/packages/api/src/controllers/alertHistory.ts b/packages/api/src/controllers/alertHistory.ts index 68caff4749..7f06aaeec0 100644 --- a/packages/api/src/controllers/alertHistory.ts +++ b/packages/api/src/controllers/alertHistory.ts @@ -19,6 +19,12 @@ type GroupedAlertHistory = { lastValues: IAlertHistory['lastValues'][]; }; +type AlertGroupHistorySummary = { + group: string; + state: AlertState; + history: Omit[]; +}; + function groupStateToOverallState(states: string[]): AlertState { if (states.includes(AlertState.ALERT)) { return AlertState.ALERT; @@ -44,6 +50,17 @@ function mapGroupedHistories( })); } +function mapHistoryEntry( + history: Pick, +): Omit { + return { + createdAt: history.createdAt, + counts: history.counts, + lastValues: history.lastValues, + state: history.state, + }; +} + /** * Gets the most recent alert histories for a given alert ID, * limiting to the given number of entries. @@ -131,6 +148,82 @@ export async function getRecentAlertHistoriesBatch( ); } +export async function getRecentAlertGroupSummaries({ + alertId, + interval, + limit, +}: { + alertId: ObjectId; + interval: AlertInterval; + limit: number; +}): Promise { + const lookbackMs = limit * ALERT_INTERVAL_TO_MINUTES[interval] * 60 * 1000; + + const histories = await AlertHistory.find({ + alert: new ObjectId(alertId), + createdAt: { $gte: new Date(Date.now() - lookbackMs) }, + group: { $type: 'string' }, + }) + .sort({ group: 1, createdAt: -1 }) + .lean(); + + const groupedHistories = new Map(); + + for (const history of histories) { + const group = history.group; + + if (group == null) { + continue; + } + + const existing = groupedHistories.get(group); + + if (existing == null) { + groupedHistories.set(group, { + group, + state: history.state, + history: [mapHistoryEntry(history)], + }); + continue; + } + + if (existing.history.length < limit) { + existing.history.push(mapHistoryEntry(history)); + } + } + + return Array.from(groupedHistories.values()).sort( + (a, b) => + b.history[0]!.createdAt.getTime() - a.history[0]!.createdAt.getTime(), + ); +} + +export async function getRecentAlertGroupSummariesBatch( + alerts: { alertId: ObjectId; interval: AlertInterval }[], + limit: number, +): Promise> { + const queue = new PQueue({ concurrency: ALERT_HISTORY_QUERY_CONCURRENCY }); + + const entries = await Promise.all( + alerts.map(({ alertId, interval }) => + queue.add(async () => { + const summaries = await getRecentAlertGroupSummaries({ + alertId, + interval, + limit, + }); + return [alertId.toString(), summaries] as const; + }), + ), + ); + + return new Map( + entries.filter( + (e): e is [string, AlertGroupHistorySummary[]] => e !== undefined, + ), + ); +} + /** * Returns alert firing/recovery transitions (ALERT-boundary crossings) within * [startTime, endTime] for one alert, for drawing chart annotations. One window diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index ff430657c9..d0f27d8244 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -298,7 +298,24 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => { silenced?: IAlert['silenced'] & { by: IUser; }; - }>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']); + silencedGroups?: Array< + Omit[number], 'by'> & { + by?: IUser; + } + >; + unsilencedGroups?: Array< + Omit[number], 'by'> & { + by?: IUser; + } + >; + }>([ + 'savedSearch', + 'dashboard', + 'createdBy', + 'silenced.by', + 'silencedGroups.by', + 'unsilencedGroups.by', + ]); }; export const getAlertEnhanced = async ( @@ -312,7 +329,24 @@ export const getAlertEnhanced = async ( silenced?: IAlert['silenced'] & { by: IUser; }; - }>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']); + silencedGroups?: Array< + Omit[number], 'by'> & { + by?: IUser; + } + >; + unsilencedGroups?: Array< + Omit[number], 'by'> & { + by?: IUser; + } + >; + }>([ + 'savedSearch', + 'dashboard', + 'createdBy', + 'silenced.by', + 'silencedGroups.by', + 'unsilencedGroups.by', + ]); }; export const deleteAlert = async (id: string, teamId: ObjectId) => { diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index f84bc30020..0df453a1a1 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -23,6 +23,23 @@ export interface IAlertError { message: string; } +interface IAlertSilence { + by?: ObjectId; + at: Date; + until: Date; +} + +export interface IAlertSilencedGroup extends IAlertSilence { + group: string; +} + +export interface IAlertUnsilencedGroup { + group: string; + by?: ObjectId; + at: Date; + parentSilencedAt: Date; +} + // follow 'ms' pkg formats export type AlertInterval = | '1m' @@ -79,11 +96,9 @@ export interface IAlert { tileId?: string; // Silenced - silenced?: { - by?: ObjectId; - at: Date; - until: Date; - }; + silenced?: IAlertSilence; + silencedGroups?: IAlertSilencedGroup[]; + unsilencedGroups?: IAlertUnsilencedGroup[]; // Multi-window alerting: fire only after N violations in M consecutive windows numConsecutiveWindows?: number | null; @@ -218,6 +233,58 @@ const AlertSchema = new Schema( required: false, }, }, + silencedGroups: { + type: [ + { + _id: false, + group: { + type: String, + required: true, + }, + by: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + at: { + type: Date, + required: true, + }, + until: { + type: Date, + required: true, + }, + }, + ], + required: false, + default: undefined, + }, + unsilencedGroups: { + type: [ + { + _id: false, + group: { + type: String, + required: true, + }, + by: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + at: { + type: Date, + required: true, + }, + parentSilencedAt: { + type: Date, + required: true, + }, + }, + ], + required: false, + default: undefined, + }, executionErrors: { type: [ { diff --git a/packages/api/src/routers/api/__tests__/alerts.int.test.ts b/packages/api/src/routers/api/__tests__/alerts.int.test.ts index fc6c71c65b..596a56b9d6 100644 --- a/packages/api/src/routers/api/__tests__/alerts.int.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.int.test.ts @@ -538,6 +538,198 @@ describe('alerts router', () => { ); }); + it('can silence one group', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + const mutedUntil = new Date(Date.now() + 3600000).toISOString(); + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ group: 'service:web', mutedUntil }) + .expect(200); + + const alertFromDb = await Alert.findById(alert.body.data._id); + expect(alertFromDb?.silencedGroups).toHaveLength(1); + expect(alertFromDb?.silencedGroups?.[0]?.group).toBe('service:web'); + expect(alertFromDb?.silencedGroups?.[0]?.by).toEqual(user._id); + expect(new Date(alertFromDb!.silencedGroups![0].until).toISOString()).toBe( + mutedUntil, + ); + }); + + it('can unsilence one group', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ + group: 'service:web', + mutedUntil: new Date(Date.now() + 3600000).toISOString(), + }) + .expect(200); + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ + group: 'service:worker', + mutedUntil: new Date(Date.now() + 3600000).toISOString(), + }) + .expect(200); + + await agent + .delete(`/alerts/${alert.body.data._id}/group-silenced`) + .query({ group: 'service:web' }) + .expect(200); + + const alertFromDb = await Alert.findById(alert.body.data._id); + expect(alertFromDb?.silencedGroups).toHaveLength(1); + expect(alertFromDb?.silencedGroups?.[0]?.group).toBe('service:worker'); + }); + + it('can resume one group while the alert remains silenced', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/silenced`) + .send({ mutedUntil: new Date(Date.now() + 3600000).toISOString() }) + .expect(200); + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ + group: 'service:web', + mutedUntil: new Date(Date.now() + 1800000).toISOString(), + }) + .expect(200); + await agent + .post(`/alerts/${alert.body.data._id}/group-unsilenced`) + .send({ group: 'service:web' }) + .expect(200); + + let alertFromDb = await Alert.findById(alert.body.data._id); + expect(alertFromDb?.silenced).toBeDefined(); + expect(alertFromDb?.silencedGroups).toBeUndefined(); + expect(alertFromDb?.unsilencedGroups).toHaveLength(1); + expect(alertFromDb?.unsilencedGroups?.[0]).toMatchObject({ + group: 'service:web', + by: user._id, + parentSilencedAt: alertFromDb?.silenced?.at, + }); + + await agent + .delete(`/alerts/${alert.body.data._id}/group-unsilenced`) + .query({ group: 'service:web' }) + .expect(200); + + alertFromDb = await Alert.findById(alert.body.data._id); + expect(alertFromDb?.unsilencedGroups).toBeUndefined(); + }); + + it('rejects resuming a group without an active alert silence', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/group-unsilenced`) + .send({ group: 'service:web' }) + .expect(400); + }); + + it('prevents resuming a group on an alert that does not exist', async () => { + await agent + .post(`/alerts/${randomMongoId()}/group-unsilenced`) + .send({ group: 'service:web' }) + .expect(404); + }); + + it('prevents clearing a group resume on an alert that does not exist', async () => { + await agent + .delete(`/alerts/${randomMongoId()}/group-unsilenced`) + .query({ group: 'service:web' }) + .expect(404); + }); + + it('clears group resume overrides when the alert is acknowledged again', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/silenced`) + .send({ mutedUntil: new Date(Date.now() + 3600000).toISOString() }) + .expect(200); + await agent + .post(`/alerts/${alert.body.data._id}/group-unsilenced`) + .send({ group: 'service:web' }) + .expect(200); + await agent + .post(`/alerts/${alert.body.data._id}/silenced`) + .send({ mutedUntil: new Date(Date.now() + 7200000).toISOString() }) + .expect(200); + + const alertFromDb = await Alert.findById(alert.body.data._id); + expect(alertFromDb?.unsilencedGroups).toBeUndefined(); + }); + it('can unsilence an alert', async () => { const dashboard = await agent .post('/dashboards') @@ -607,6 +799,184 @@ describe('alerts router', () => { expect(silencedAlert.silenced.until).toBeDefined(); }); + it('returns silencedGroups info in GET /alerts', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + const mutedUntil = new Date(Date.now() + 3600000).toISOString(); + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ group: 'service:web', mutedUntil }) + .expect(200); + + const alerts = await agent.get('/alerts').expect(200); + expect(alerts.body.data.length).toBe(1); + const silencedAlert = alerts.body.data[0]; + expect(silencedAlert.silencedGroups).toEqual([ + expect.objectContaining({ + group: 'service:web', + by: user.email, + until: mutedUntil, + }), + ]); + expect(silencedAlert.silencedGroups[0].at).toBeDefined(); + }); + + it('returns configured groupBy in GET /alerts', async () => { + const tile = makeTile(); + const groupedTile = { + ...tile, + config: { + ...tile.config, + groupBy: 'ServiceName', + }, + }; + const dashboard = await agent + .post('/dashboards') + .send({ + ...MOCK_DASHBOARD, + tiles: [groupedTile], + }) + .expect(200); + + await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + const alerts = await agent.get('/alerts').expect(200); + expect(alerts.body.data).toEqual([ + expect.objectContaining({ + groupBy: 'ServiceName', + }), + ]); + }); + + it('returns group summaries in GET /alerts', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + const now = new Date(Date.now() - 60000); + const earlier = new Date(Date.now() - 120000); + const mutedUntil = new Date(Date.now() + 3600000).toISOString(); + + await AlertHistory.create({ + alert: alert.body.data._id, + createdAt: now, + state: AlertState.ALERT, + counts: 5, + group: 'service:app', + lastValues: [{ startTime: now, count: 5 }], + }); + await AlertHistory.create({ + alert: alert.body.data._id, + createdAt: earlier, + state: AlertState.OK, + counts: 0, + group: 'service:app', + lastValues: [{ startTime: earlier, count: 0 }], + }); + await AlertHistory.create({ + alert: alert.body.data._id, + createdAt: now, + state: AlertState.OK, + counts: 1, + group: 'service:api', + lastValues: [{ startTime: now, count: 1 }], + }); + + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ group: 'service:app', mutedUntil }) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/silenced`) + .send({ mutedUntil }) + .expect(200); + await agent + .post(`/alerts/${alert.body.data._id}/group-unsilenced`) + .send({ group: 'service:api' }) + .expect(200); + + const alerts = await agent.get('/alerts').expect(200); + const groupedAlert = alerts.body.data.find( + (item: { _id: string }) => item._id === alert.body.data._id, + ); + + expect(groupedAlert.groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + group: 'service:app', + state: AlertState.ALERT, + silenced: expect.objectContaining({ + by: user.email, + until: mutedUntil, + }), + history: [ + expect.objectContaining({ + state: AlertState.ALERT, + counts: 5, + }), + expect.objectContaining({ + state: AlertState.OK, + counts: 0, + }), + ], + }), + expect.objectContaining({ + group: 'service:api', + state: AlertState.OK, + unsilenced: expect.objectContaining({ + by: user.email, + at: expect.any(String), + parentSilencedAt: expect.any(String), + }), + history: [ + expect.objectContaining({ + state: AlertState.OK, + counts: 1, + }), + ], + }), + ]), + ); + expect(groupedAlert.groups[0].history[0].createdAt).toBeDefined(); + expect(groupedAlert.groups[0].history[0].lastValues[0].startTime).toBe( + now.toISOString(), + ); + }); + it('prevents silencing an alert that does not exist', async () => { const fakeId = randomMongoId(); const mutedUntil = new Date(Date.now() + 3600000).toISOString(); @@ -617,12 +987,79 @@ describe('alerts router', () => { .expect(404); // Should fail because alert doesn't exist }); + it('rejects group silencing with an invalid group', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ + group: ' ', + mutedUntil: new Date(Date.now() + 3600000).toISOString(), + }) + .expect(400); + }); + + it('rejects group silencing with a past mutedUntil', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + await agent + .post(`/alerts/${alert.body.data._id}/group-silenced`) + .send({ + group: 'service:web', + mutedUntil: new Date(Date.now() - 60000).toISOString(), + }) + .expect(400); + }); + + it('prevents group silencing an alert that does not exist', async () => { + await agent + .post(`/alerts/${randomMongoId()}/group-silenced`) + .send({ + group: 'service:web', + mutedUntil: new Date(Date.now() + 3600000).toISOString(), + }) + .expect(404); + }); + it('prevents unsilencing an alert that does not exist', async () => { const fakeId = randomMongoId(); await agent.delete(`/alerts/${fakeId}/silenced`).expect(404); // Should fail }); + it('prevents group unsilencing an alert that does not exist', async () => { + await agent + .delete(`/alerts/${randomMongoId()}/group-silenced`) + .query({ group: 'service:web' }) + .expect(404); + }); + it('allows creating an alert on a raw SQL line tile', async () => { const rawSqlTile = makeRawSqlAlertTile(); const dashboard = await agent @@ -824,6 +1261,7 @@ describe('alerts router', () => { expect(res.body.data._id).toBe(alert.body.data._id); expect(res.body.data.history).toEqual([]); + expect(res.body.data.groups).toEqual([]); expect(res.body.data.threshold).toBe(alert.body.data.threshold); expect(res.body.data.interval).toBe(alert.body.data.interval); expect(res.body.data.dashboard).toBeDefined(); @@ -875,6 +1313,80 @@ describe('alerts router', () => { expect(res.body.data.history[1].state).toBe('OK'); expect(res.body.data.history[1].counts).toBe(0); }); + + it('returns grouped summaries with silence metadata', async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + const now = new Date(Date.now() - 60000); + const earlier = new Date(Date.now() - 120000); + + await AlertHistory.create({ + alert: alert.body.data._id, + createdAt: now, + state: AlertState.ALERT, + counts: 5, + group: 'service:app', + lastValues: [{ startTime: now, count: 5 }], + }); + await AlertHistory.create({ + alert: alert.body.data._id, + createdAt: earlier, + state: AlertState.OK, + counts: 0, + group: 'service:app', + lastValues: [{ startTime: earlier, count: 0 }], + }); + + const alertDoc = await Alert.findById(alert.body.data._id); + expect(alertDoc).toBeDefined(); + alertDoc!.silencedGroups = [ + { + group: 'service:app', + by: user._id, + at: earlier, + until: new Date(Date.now() - 30000), + }, + ]; + await alertDoc!.save(); + + const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200); + + expect(res.body.data.groups).toEqual([ + expect.objectContaining({ + group: 'service:app', + state: AlertState.ALERT, + silenced: expect.objectContaining({ + by: user.email, + at: earlier.toISOString(), + }), + history: [ + expect.objectContaining({ + state: AlertState.ALERT, + counts: 5, + }), + expect.objectContaining({ + state: AlertState.OK, + counts: 0, + }), + ], + }), + ]); + expect(res.body.data.groups[0].silenced.until).toBeDefined(); + }); }); describe('GET /alerts/:id/history', () => { diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 1ffe35bbb4..592620f62a 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -12,6 +12,8 @@ import { processRequest, validateRequest } from 'zod-express-middleware'; import { getAlertTransitionsInRange, + getRecentAlertGroupSummaries, + getRecentAlertGroupSummariesBatch, getRecentAlertHistories, getRecentAlertHistoriesBatch, } from '@/controllers/alertHistory'; @@ -31,13 +33,108 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; const router = express.Router(); type EnhancedAlert = NonNullable>>; +type AlertGroupSummary = Awaited< + ReturnType +>[number]; +type AlertResponseGroupBy = string | string[]; + +const futureMutedUntilSchema = z + .string() + .datetime() + .refine(val => new Date(val) > new Date(), { + message: 'mutedUntil must be in the future', + }); + +const groupSchema = z.string().refine(value => value.trim().length > 0, { + message: 'group must not be empty', +}); + +const getGroupByValueExpression = ( + groupBy: unknown, +): AlertResponseGroupBy | undefined => { + if (typeof groupBy === 'string') { + const trimmedGroupBy = groupBy.trim(); + return trimmedGroupBy.length > 0 ? trimmedGroupBy : undefined; + } + + if (Array.isArray(groupBy)) { + const groupByValues = groupBy + .map(value => { + if (typeof value === 'string') { + return value; + } + if ( + value != null && + typeof value === 'object' && + 'valueExpression' in value && + typeof value.valueExpression === 'string' + ) { + return value.valueExpression; + } + return undefined; + }) + .filter((value): value is string => value != null && value.length > 0); + + return groupByValues.length > 0 ? groupByValues : undefined; + } + + return undefined; +}; + +const getConfiguredAlertGroupBy = ( + alert: EnhancedAlert, +): AlertResponseGroupBy | undefined => { + const alertGroupBy = getGroupByValueExpression(alert.groupBy); + if (alertGroupBy != null) { + return alertGroupBy; + } + + const tile = alert.dashboard?.tiles.find(tile => tile.id === alert.tileId); + if (tile?.config != null && 'groupBy' in tile.config) { + return getGroupByValueExpression(tile.config.groupBy); + } + + return undefined; +}; const formatAlertResponse = ( alert: EnhancedAlert, history: Omit[], + groups?: AlertGroupSummary[], ): PreSerialized => { + const formattedGroups = groups?.map(groupSummary => { + const groupSilence = alert.silencedGroups?.find( + silencedGroup => silencedGroup.group === groupSummary.group, + ); + const groupUnsilence = alert.unsilencedGroups?.find( + unsilencedGroup => unsilencedGroup.group === groupSummary.group, + ); + + return { + group: groupSummary.group, + state: groupSummary.state, + history: groupSummary.history, + silenced: groupSilence + ? { + by: groupSilence.by?.email, + at: groupSilence.at, + until: groupSilence.until, + } + : undefined, + unsilenced: groupUnsilence + ? { + by: groupUnsilence.by?.email, + at: groupUnsilence.at, + parentSilencedAt: groupUnsilence.parentSilencedAt, + } + : undefined, + }; + }); + return { history, + groupBy: getConfiguredAlertGroupBy(alert), + groups: formattedGroups, silenced: alert.silenced ? { by: alert.silenced.by?.email, @@ -45,6 +142,12 @@ const formatAlertResponse = ( until: alert.silenced.until, } : undefined, + silencedGroups: alert.silencedGroups?.map(groupSilence => ({ + group: groupSilence.group, + by: groupSilence.by?.email, + at: groupSilence.at, + until: groupSilence.until, + })), createdBy: alert.createdBy ? pick(alert.createdBy, ['email', 'name']) : undefined, @@ -108,10 +211,18 @@ router.get('/', async (req, res: AlertsExpRes, next) => { })), 20, ); + const groupSummaryMap = await getRecentAlertGroupSummariesBatch( + alerts.map(alert => ({ + alertId: new ObjectId(alert._id), + interval: alert.interval, + })), + 20, + ); const data = alerts.map(alert => { const history = historyMap.get(alert._id.toString()) ?? []; - return formatAlertResponse(alert, history); + const groups = groupSummaryMap.get(alert._id.toString()); + return formatAlertResponse(alert, history, groups); }); sendJson(res, { data }); @@ -145,8 +256,13 @@ router.get( interval: alert.interval, limit: 20, }); + const groups = await getRecentAlertGroupSummaries({ + alertId: new ObjectId(alert._id), + interval: alert.interval, + limit: 20, + }); - const data = formatAlertResponse(alert, history); + const data = formatAlertResponse(alert, history, groups); sendJson(res, { data }); } catch (e) { @@ -256,16 +372,194 @@ router.put( }, ); +router.post( + '/:id/group-silenced', + validateRequest({ + body: z.object({ + group: groupSchema, + mutedUntil: futureMutedUntilSchema, + }), + params: z.object({ + id: objectIdSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null || req.user == null) { + return res.sendStatus(403); + } + + const alert = await getAlertById(req.params.id, teamId); + if (!alert) { + return res.status(404).json({ error: 'Alert not found' }); + } + + const silencedGroup = { + group: req.body.group, + by: req.user._id, + at: new Date(), + until: new Date(req.body.mutedUntil), + }; + + alert.silencedGroups = [ + ...(alert.silencedGroups?.filter( + groupSilence => groupSilence.group !== req.body.group, + ) ?? []), + silencedGroup, + ]; + alert.unsilencedGroups = alert.unsilencedGroups?.filter( + groupUnsilence => groupUnsilence.group !== req.body.group, + ); + if (alert.unsilencedGroups?.length === 0) { + alert.unsilencedGroups = undefined; + } + await alert.save(); + + res.sendStatus(200); + } catch (e) { + next(e); + } + }, +); + +router.post( + '/:id/group-unsilenced', + validateRequest({ + body: z.object({ + group: groupSchema, + }), + params: z.object({ + id: objectIdSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null || req.user == null) { + return res.sendStatus(403); + } + + const alert = await getAlertById(req.params.id, teamId); + if (!alert) { + return res.status(404).json({ error: 'Alert not found' }); + } + if ( + alert.silenced == null || + alert.silenced.until.getTime() <= Date.now() + ) { + return res.status(400).json({ + error: 'Alert must have an active acknowledgment', + }); + } + + const unsilencedGroup = { + group: req.body.group, + by: req.user._id, + at: new Date(), + parentSilencedAt: alert.silenced.at, + }; + + alert.silencedGroups = alert.silencedGroups?.filter( + groupSilence => groupSilence.group !== req.body.group, + ); + if (alert.silencedGroups?.length === 0) { + alert.silencedGroups = undefined; + } + alert.unsilencedGroups = [ + ...(alert.unsilencedGroups?.filter( + groupUnsilence => groupUnsilence.group !== req.body.group, + ) ?? []), + unsilencedGroup, + ]; + await alert.save(); + + res.sendStatus(200); + } catch (e) { + next(e); + } + }, +); + +router.delete( + '/:id/group-unsilenced', + validateRequest({ + params: z.object({ + id: objectIdSchema, + }), + query: z.object({ + group: groupSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const alert = await getAlertById(req.params.id, teamId); + if (!alert) { + return res.status(404).json({ error: 'Alert not found' }); + } + + alert.unsilencedGroups = alert.unsilencedGroups?.filter( + groupUnsilence => groupUnsilence.group !== req.query.group, + ); + if (alert.unsilencedGroups?.length === 0) { + alert.unsilencedGroups = undefined; + } + await alert.save(); + + res.sendStatus(200); + } catch (e) { + next(e); + } + }, +); + +router.delete( + '/:id/group-silenced', + validateRequest({ + params: z.object({ + id: objectIdSchema, + }), + query: z.object({ + group: groupSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + const alert = await getAlertById(req.params.id, teamId); + if (!alert) { + return res.status(404).json({ error: 'Alert not found' }); + } + + alert.silencedGroups = alert.silencedGroups?.filter( + groupSilence => groupSilence.group !== req.query.group, + ); + if (alert.silencedGroups?.length === 0) { + alert.silencedGroups = undefined; + } + await alert.save(); + + res.sendStatus(200); + } catch (e) { + next(e); + } + }, +); + router.post( '/:id/silenced', validateRequest({ body: z.object({ - mutedUntil: z - .string() - .datetime() - .refine(val => new Date(val) > new Date(), { - message: 'mutedUntil must be in the future', - }), + mutedUntil: futureMutedUntilSchema, }), params: z.object({ id: objectIdSchema, @@ -287,6 +581,7 @@ router.post( at: new Date(), until: new Date(req.body.mutedUntil), }; + alert.unsilencedGroups = undefined; await alert.save(); res.sendStatus(200); @@ -315,6 +610,7 @@ router.delete( return res.status(404).json({ error: 'Alert not found' }); } alert.silenced = undefined; + alert.unsilencedGroups = undefined; await alert.save(); res.sendStatus(200); diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index e53aedf4bc..57f6f649a4 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -98,6 +98,17 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * description: Silence end timestamp. * format: date-time * example: "2026-03-20T08:00:00.000Z" + * AlertSilencedGroup: + * allOf: + * - $ref: '#/components/schemas/AlertSilenced' + * - type: object + * required: + * - group + * properties: + * group: + * type: string + * description: Exact group key being silenced. + * example: "service:api" * AlertChannelWebhook: * type: object * required: @@ -220,6 +231,12 @@ import { alertSchema, objectIdSchema } from '@/utils/zod'; * $ref: '#/components/schemas/AlertSilenced' * description: Silencing metadata. * nullable: true + * silencedGroups: + * type: array + * nullable: true + * description: Stored per-group silences, if any. + * items: + * $ref: '#/components/schemas/AlertSilencedGroup' * executionErrors: * type: array * nullable: true diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts index 0e03d17cdb..651d493a5f 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts @@ -36,7 +36,9 @@ import Webhook, { IWebhook } from '@/models/webhook'; import * as checkAlert from '@/tasks/checkAlerts'; import { alertHasGroupBy, + buildAlertGroupContext, doesExceedThreshold, + getConfiguredGroupByLabels, getConsecutiveWindowHistories, getPreviousAlertHistories, getScheduledWindowStart, @@ -1183,6 +1185,70 @@ describe('checkAlerts', () => { }); }); + describe('alert group key labels', () => { + it('uses configured groupBy labels instead of rendered ClickHouse expression names', () => { + const groupByLabels = getConfiguredGroupByLabels({ + displayType: 'line', + groupBy: "ResourceAttributes['process.command_args']", + } as any); + + const { groupKey, attributes } = buildAlertGroupContext( + [ + [ + "arrayElement(ResourceAttributes, 'process.command_args')", + 'node index.js', + ], + ], + groupByLabels, + ); + + expect(groupKey).toBe( + "ResourceAttributes['process.command_args']:node index.js", + ); + expect(attributes).toEqual({ + "ResourceAttributes['process.command_args']": 'node index.js', + }); + }); + + it('preserves saved-search group labels', () => { + const groupByLabels = getConfiguredGroupByLabels({ + displayType: 'line', + groupBy: 'ServiceName', + } as any); + + const { groupKey, attributes } = buildAlertGroupContext( + [['ServiceName', 'hdx-oss-dev-api']], + groupByLabels, + ); + + expect(groupKey).toBe('ServiceName:hdx-oss-dev-api'); + expect(attributes).toEqual({ ServiceName: 'hdx-oss-dev-api' }); + }); + + it('preserves multi-column groupBy order', () => { + const groupByLabels = getConfiguredGroupByLabels({ + displayType: 'line', + groupBy: "ServiceName, ResourceAttributes['host.name']", + } as any); + + const { groupKey, attributes } = buildAlertGroupContext( + [ + ['ServiceName', 'api'], + ["arrayElement(ResourceAttributes, 'host.name')", 'host-1'], + ], + groupByLabels, + ); + + expect(groupKey).toBe( + "ServiceName:api, ResourceAttributes['host.name']:host-1", + ); + expect(attributes).toEqual({ + ServiceName: 'api', + "ResourceAttributes['host.name']": 'host-1', + }); + }); + }); + describe('Alert Templates', () => { // Create a mock metadata object with the necessary methods // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -7461,6 +7527,458 @@ describe('checkAlerts', () => { expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT'); }); + it('should suppress notifications only for silenced groups', async () => { + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + const now = new Date('2023-11-16T22:12:00.000Z'); + const eventMs = new Date('2023-11-16T22:05:00.000Z'); + + await bulkInsertLogs([ + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in app!', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in app!', + }, + ]); + + 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, + groupBy: 'ServiceName', + }, + { + taskType: AlertTaskType.SAVED_SEARCH, + savedSearch, + }, + ); + + const alertDoc = await Alert.findById(details.alert.id); + alertDoc!.silencedGroups = [ + { + group: 'ServiceName:api', + at: new Date(), + until: new Date(Date.now() + 3600000), + }, + ]; + await alertDoc!.save(); + + details.alert.silencedGroups = alertDoc!.silencedGroups; + + await processAlertAtTime( + now, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); + + const webhookCall = (slack.postMessageToWebhook as jest.Mock).mock + .calls[0]; + expect(webhookCall[0]).toBe('https://hooks.slack.com/services/123'); + + const webhookPayload = webhookCall[1]; + expect(webhookPayload).toMatchObject({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: 'section', + text: expect.objectContaining({ + type: 'mrkdwn', + text: expect.stringContaining('Group: "ServiceName:app"'), + }), + }), + ]), + }); + + const alertHistories = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1, group: 1 }); + expect(alertHistories).toHaveLength(2); + + const apiHistory = alertHistories.find( + ({ group }) => group === 'ServiceName:api', + ); + expect(apiHistory?.state).toBe('ALERT'); + expect(apiHistory?.counts).toBe(1); + + const appHistory = alertHistories.find( + ({ group }) => group === 'ServiceName:app', + ); + expect(appHistory?.state).toBe('ALERT'); + expect(appHistory?.counts).toBe(1); + + expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT'); + }); + + it('should fire grouped notifications when group silence has expired', async () => { + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + const now = new Date('2023-11-16T22:12:00.000Z'); + const eventMs = new Date('2023-11-16T22:05:00.000Z'); + + await bulkInsertLogs([ + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in app!', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in app!', + }, + ]); + + 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, + groupBy: 'ServiceName', + }, + { + taskType: AlertTaskType.SAVED_SEARCH, + savedSearch, + }, + ); + + const alertDoc = await Alert.findById(details.alert.id); + alertDoc!.silencedGroups = [ + { + group: 'ServiceName:api', + at: new Date(Date.now() - 7200000), + until: new Date(Date.now() - 3600000), + }, + ]; + await alertDoc!.save(); + + details.alert.silencedGroups = alertDoc!.silencedGroups; + + await processAlertAtTime( + now, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(2); + + const webhookCalls = (slack.postMessageToWebhook as jest.Mock).mock.calls; + + expect(webhookCalls).toEqual( + expect.arrayContaining([ + [ + 'https://hooks.slack.com/services/123', + expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: 'section', + text: expect.objectContaining({ + type: 'mrkdwn', + text: expect.stringContaining('Group: "ServiceName:api"'), + }), + }), + ]), + }), + ], + [ + 'https://hooks.slack.com/services/123', + expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: 'section', + text: expect.objectContaining({ + type: 'mrkdwn', + text: expect.stringContaining('Group: "ServiceName:app"'), + }), + }), + ]), + }), + ], + ]), + ); + + expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT'); + }); + + it('should not fire grouped notifications when alert is silenced', async () => { + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + const now = new Date('2023-11-16T22:12:00.000Z'); + const eventMs = new Date('2023-11-16T22:05:00.000Z'); + + await bulkInsertLogs([ + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Error from app', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Error from app', + }, + ]); + + 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, + groupBy: 'ServiceName', + }, + { + taskType: AlertTaskType.SAVED_SEARCH, + savedSearch, + }, + ); + + const alertDoc = await Alert.findById(details.alert.id); + alertDoc!.silenced = { + at: new Date(), + until: new Date(Date.now() + 3600000), + }; + await alertDoc!.save(); + + details.alert.silenced = alertDoc!.silenced; + + await processAlertAtTime( + now, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + expect(slack.postMessageToWebhook).not.toHaveBeenCalled(); + + const alertHistories = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1, group: 1 }); + expect(alertHistories).toHaveLength(2); + expect(alertHistories[0].state).toBe('ALERT'); + expect(alertHistories[1].state).toBe('ALERT'); + + expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT'); + }); + + it('should fire notifications for resumed groups when alert is silenced', async () => { + const { + team, + webhook, + connection, + source, + savedSearch, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + const now = new Date('2023-11-16T22:12:00.000Z'); + const eventMs = new Date('2023-11-16T22:05:00.000Z'); + + await bulkInsertLogs([ + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'api', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Oh no! Something went wrong in api!', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Error from app', + }, + { + ServiceName: 'app', + Timestamp: eventMs, + SeverityText: 'error', + Body: 'Error from app', + }, + ]); + + 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, + groupBy: 'ServiceName', + }, + { + taskType: AlertTaskType.SAVED_SEARCH, + savedSearch, + }, + ); + + const parentSilencedAt = new Date(); + const alertDoc = await Alert.findById(details.alert.id); + alertDoc!.silenced = { + at: parentSilencedAt, + until: new Date(Date.now() + 3600000), + }; + alertDoc!.unsilencedGroups = [ + { + group: 'ServiceName:app', + at: new Date(), + parentSilencedAt, + }, + ]; + await alertDoc!.save(); + + details.alert.silenced = alertDoc!.silenced; + details.alert.unsilencedGroups = alertDoc!.unsilencedGroups; + + await processAlertAtTime( + now, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); + expect(slack.postMessageToWebhook).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/123', + expect.objectContaining({ + blocks: expect.arrayContaining([ + expect.objectContaining({ + type: 'section', + text: expect.objectContaining({ + type: 'mrkdwn', + text: expect.stringContaining('Group: "ServiceName:app"'), + }), + }), + ]), + }), + ); + + const alertHistories = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1, group: 1 }); + expect(alertHistories).toHaveLength(2); + expect(alertHistories[0].state).toBe('ALERT'); + expect(alertHistories[1].state).toBe('ALERT'); + + expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT'); + }); + it('SAVED_SEARCH alert with alias in select and where should trigger', async () => { const team = await createTeam({ name: 'My Team' }); diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlertsTask.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlertsTask.test.ts index 1b537c7573..376ff6cf68 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlertsTask.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlertsTask.test.ts @@ -23,6 +23,79 @@ jest.mock('@/tasks/checkAlerts/providers', () => { }; }); +describe('getAlertNotificationSuppression', () => { + const now = new Date('2026-07-20T12:00:00.000Z'); + const parentSilencedAt = new Date('2026-07-20T11:00:00.000Z'); + const parentSilenced = { + at: parentSilencedAt, + until: new Date('2026-07-20T13:00:00.000Z'), + }; + + it('suppresses a group using the active alert acknowledgment by default', () => { + expect( + checkAlerts.getAlertNotificationSuppression( + { silenced: parentSilenced }, + 'service:app', + now.getTime(), + ), + ).toEqual({ type: 'alert', silenced: parentSilenced }); + }); + + it('allows a group resumed against the current alert acknowledgment', () => { + expect( + checkAlerts.getAlertNotificationSuppression( + { + silenced: parentSilenced, + unsilencedGroups: [ + { + group: 'service:app', + at: new Date('2026-07-20T11:30:00.000Z'), + parentSilencedAt, + }, + ], + }, + 'service:app', + now.getTime(), + ), + ).toBeUndefined(); + }); + + it('ignores a group resume from an older alert acknowledgment', () => { + expect( + checkAlerts.getAlertNotificationSuppression( + { + silenced: parentSilenced, + unsilencedGroups: [ + { + group: 'service:app', + at: new Date('2026-07-20T11:30:00.000Z'), + parentSilencedAt: new Date('2026-07-19T11:00:00.000Z'), + }, + ], + }, + 'service:app', + now.getTime(), + ), + ).toEqual({ type: 'alert', silenced: parentSilenced }); + }); + + it('still suppresses a group using an active group acknowledgment', () => { + const groupSilenced = { + group: 'service:app', + at: new Date('2026-07-20T11:30:00.000Z'), + until: new Date('2026-07-20T12:30:00.000Z'), + }; + + expect( + checkAlerts.getAlertNotificationSuppression( + { silencedGroups: [groupSilenced] }, + 'service:app', + now.getTime(), + ), + ).toEqual({ type: 'group', silenced: groupSilenced }); + }); +}); + describe('CheckAlertTask', () => { describe('execute', () => { let mockAlertProvider: jest.Mocked; diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index a5259b39de..ab41ffc087 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -22,6 +22,7 @@ import { aliasMapToWithClauses, displayTypeSupportsRawSqlAlerts, isTimeSeriesDisplayType, + splitAndTrimWithBracket, } from '@hyperdx/common-utils/dist/core/utils'; import { timeBucketByGranularity } from '@hyperdx/common-utils/dist/core/utils'; import { @@ -50,7 +51,13 @@ import { performance } from 'perf_hooks'; import { serializeError } from 'serialize-error'; import { ALERT_HISTORY_QUERY_CONCURRENCY } from '@/controllers/alertHistory'; -import { AlertState, IAlert, IAlertError } from '@/models/alert'; +import { + AlertState, + IAlert, + IAlertError, + IAlertSilencedGroup, + IAlertUnsilencedGroup, +} from '@/models/alert'; import AlertHistory, { IAlertHistory } from '@/models/alertHistory'; import { IDashboard } from '@/models/dashboard'; import { ISavedSearch } from '@/models/savedSearch'; @@ -137,6 +144,74 @@ export const alertHasGroupBy = (details: AlertDetails): boolean => { return false; }; +const getActiveSilencedGroup = ( + silencedGroups: IAlert['silencedGroups'], + group: string, + now: number, +): IAlertSilencedGroup | undefined => { + if (!group) { + return undefined; + } + + return silencedGroups?.find( + silencedGroup => + silencedGroup.group === group && silencedGroup.until.getTime() > now, + ); +}; + +const getActiveUnsilencedGroup = ( + unsilencedGroups: IAlert['unsilencedGroups'], + group: string, + parentSilenced: IAlert['silenced'], + now: number, +): IAlertUnsilencedGroup | undefined => { + if ( + !group || + parentSilenced == null || + parentSilenced.until.getTime() <= now + ) { + return undefined; + } + + return unsilencedGroups?.find( + unsilencedGroup => + unsilencedGroup.group === group && + unsilencedGroup.parentSilencedAt.getTime() === + parentSilenced.at.getTime(), + ); +}; + +export type AlertNotificationSuppression = + | { type: 'alert'; silenced: NonNullable } + | { type: 'group'; silenced: IAlertSilencedGroup }; + +export const getAlertNotificationSuppression = ( + alert: Pick, + group: string, + now = Date.now(), +): AlertNotificationSuppression | undefined => { + const unsilencedGroup = getActiveUnsilencedGroup( + alert.unsilencedGroups, + group, + alert.silenced, + now, + ); + if (unsilencedGroup) { + return undefined; + } + + if (alert.silenced && alert.silenced.until.getTime() > now) { + return { type: 'alert', silenced: alert.silenced }; + } + + const silencedGroup = getActiveSilencedGroup( + alert.silencedGroups, + group, + now, + ); + return silencedGroup ? { type: 'group', silenced: silencedGroup } : undefined; +}; + /** * Render a saved search's SELECT to discover column aliases (e.g. `toString(Body) AS body`) * and return them as WITH clauses that can be injected into alert/sample-log queries @@ -773,6 +848,39 @@ export const parseAlertData = ( return { value, extraFields }; }; +const getGroupByLabel = (groupBy: { valueExpression: string } | string) => { + return typeof groupBy === 'string' ? groupBy : groupBy.valueExpression; +}; + +export const getConfiguredGroupByLabels = ( + chartConfig: ChartConfigWithOptDateRange, +): string[] => { + if (!isBuilderChartConfig(chartConfig) || chartConfig.groupBy == null) { + return []; + } + + if (typeof chartConfig.groupBy === 'string') { + return splitAndTrimWithBracket(chartConfig.groupBy); + } + + return chartConfig.groupBy.map(getGroupByLabel); +}; + +export const buildAlertGroupContext = ( + extraFields: Array<[string, string]>, + groupByLabels: string[], +) => { + const fields = extraFields.map( + ([key, value], index) => + [groupByLabels[index] ?? key, value] as [string, string], + ); + + return { + attributes: Object.fromEntries(fields), + groupKey: fields.map(([key, value]) => `${key}:${value}`).join(', '), + }; +}; + export const processAlert = async ( now: Date, details: AlertDetails, @@ -1048,18 +1156,36 @@ export const processAlert = async ( // execution may still send a notification. Subsequent alert checks will // respect the silenced state. This trade-off maintains architectural // separation from direct database access. - if ((alert.silenced?.until?.getTime() ?? 0) > Date.now()) { + const notificationSuppression = getAlertNotificationSuppression( + alert, + group, + ); + + if (notificationSuppression?.type === 'alert') { alertEvaluationsCounter.add(1, { outcome: 'skipped_silenced' }); logger.info( { alertId: alert.id, - silenced: alert.silenced, + silenced: notificationSuppression.silenced, }, 'Skipped firing alert due to silence', ); return; } + if (notificationSuppression?.type === 'group') { + alertEvaluationsCounter.add(1, { outcome: 'skipped_silenced' }); + logger.info( + { + alertId: alert.id, + group, + silencedGroup: notificationSuppression.silenced, + }, + 'Skipped firing alert due to group silence', + ); + return; + } + alertEvaluationsCounter.add(1, { outcome: state === AlertState.ALERT ? 'fired' : 'resolved', }); @@ -1153,6 +1279,7 @@ export const processAlert = async ( logger.error({ alertId: alert.id }, 'Failed to get response metadata'); return; } + const groupByLabels = getConfiguredGroupByLabels(chartConfig); // single_value type (Raw SQL Number charts) returns a single value with no // timestamp column, and are assumed to not have groups. @@ -1290,10 +1417,9 @@ export const processAlert = async ( continue; } - const groupKey = hasGroupBy - ? extraFields.map(([k, v]) => `${k}:${v}`).join(', ') - : ''; - const attributes = hasGroupBy ? Object.fromEntries(extraFields) : {}; + const { groupKey, attributes } = hasGroupBy + ? buildAlertGroupContext(extraFields, groupByLabels) + : { groupKey: '', attributes: {} }; const exceeds = doesExceedThreshold(alert, value); diff --git a/packages/api/src/utils/__tests__/externalApi.test.ts b/packages/api/src/utils/__tests__/externalApi.test.ts index 499ec69a52..28114bfc58 100644 --- a/packages/api/src/utils/__tests__/externalApi.test.ts +++ b/packages/api/src/utils/__tests__/externalApi.test.ts @@ -44,6 +44,34 @@ describe('utils/externalApi', () => { expect(translated.scheduleStartAt).toBeUndefined(); }); + + it('returns silencedGroups with stringified ids and iso timestamps', () => { + const silencedAt = new Date('2026-07-07T08:00:00.000Z'); + const silencedUntil = new Date('2026-07-07T09:00:00.000Z'); + const by = new Types.ObjectId(); + + const alert = createAlertDocument({ + silencedGroups: [ + { + group: 'service:api', + by, + at: silencedAt, + until: silencedUntil, + }, + ], + }); + + const translated = translateAlertDocumentToExternalAlert(alert); + + expect(translated.silencedGroups).toEqual([ + { + group: 'service:api', + by: by.toString(), + at: silencedAt.toISOString(), + until: silencedUntil.toISOString(), + }, + ]); + }); }); describe('note handling', () => { diff --git a/packages/api/src/utils/externalApi.ts b/packages/api/src/utils/externalApi.ts index c25e76cbcc..f35403e12e 100644 --- a/packages/api/src/utils/externalApi.ts +++ b/packages/api/src/utils/externalApi.ts @@ -252,6 +252,12 @@ export type ExternalAlert = { at: string; until: string; }; + silencedGroups?: { + group: string; + by?: string; + at: string; + until: string; + }[]; executionErrors?: { timestamp: string; type: AlertErrorType; @@ -305,6 +311,17 @@ function transformSilencedToExternalSilenced( : undefined; } +function transformSilencedGroupsToExternalSilencedGroups( + silencedGroups: AlertDocumentObject['silencedGroups'], +): ExternalAlert['silencedGroups'] { + return silencedGroups?.map(group => ({ + group: group.group, + by: group.by?.toString(), + at: group.at.toISOString(), + until: group.until.toISOString(), + })); +} + function transformErrorsToExternalErrors( errors: AlertDocumentObject['executionErrors'], ): ExternalAlert['executionErrors'] { @@ -350,6 +367,9 @@ export function translateAlertDocumentToExternalAlert( savedSearchId: alertObj.savedSearch?.toString(), groupBy: alertObj.groupBy, silenced: transformSilencedToExternalSilenced(alertObj.silenced), + silencedGroups: transformSilencedGroupsToExternalSilencedGroups( + alertObj.silencedGroups, + ), executionErrors: transformErrorsToExternalErrors(alertObj.executionErrors), createdAt: hasCreatedAt(alertObj) ? alertObj.createdAt.toISOString() diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 1d1345c0f3..1933a8b93a 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -3,6 +3,7 @@ import Head from 'next/head'; import Link from 'next/link'; import { useQueryState } from 'nuqs'; import ReactMarkdown from 'react-markdown'; +import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils'; import { AlertSource, AlertState, @@ -38,7 +39,13 @@ import { } from '@tabler/icons-react'; import { AckAlert } from '@/components/alerts/AckAlert'; +import { + AlertGroupRows, + getVisibleAlertGroups, + isAlertGrouped, +} from '@/components/alerts/AlertGroupRows'; import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards'; +import { AlertStateBadge } from '@/components/alerts/AlertStateBadge'; import EmptyState from '@/components/EmptyState'; import { PageHeader } from '@/components/PageHeader'; @@ -72,6 +79,20 @@ function getAlertCreatorLabel(alert: AlertsPageItem): string | undefined { return alert.createdBy.name || alert.createdBy.email; } +function getAlertGroupByLabel(alert: AlertsPageItem): string | undefined { + if (Array.isArray(alert.groupBy)) { + const labels = alert.groupBy.filter(groupBy => groupBy.trim().length > 0); + return labels.length > 0 ? labels.join(', ') : undefined; + } + + if (alert.groupBy == null) { + return undefined; + } + + const labels = splitAndTrimWithBracket(alert.groupBy); + return labels.length > 0 ? labels.join(', ') : undefined; +} + function AlertNote({ note }: { note: string }) { const [opened, { toggle }] = useDisclosure(false); @@ -122,7 +143,21 @@ function AlertNote({ note }: { note: string }) { ); } -function AlertDetails({ alert }: { alert: AlertsPageItem }) { +function AlertDetails({ + alert, + groupState, +}: { + alert: AlertsPageItem; + groupState?: AlertsPageItem['state']; +}) { + const [areGroupsExpanded, setAreGroupsExpanded] = React.useState(true); + const isGrouped = isAlertGrouped(alert); + const hasVisibleGroups = + isGrouped && getVisibleAlertGroups(alert, groupState).length > 0; + const displayState = groupState ?? alert.state; + const showParentActions = groupState == null || groupState === alert.state; + const testIdSuffix = groupState ? `${alert._id}-${groupState}` : alert._id; + const alertName = React.useMemo(() => { if (alert.source === AlertSource.TILE && alert.dashboard) { const tile = alert.dashboard?.tiles.find( @@ -195,6 +230,11 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { ); }, [alert]); + const groupByLabel = React.useMemo( + () => getAlertGroupByLabel(alert), + [alert], + ); + const linkTitle = React.useMemo(() => { switch (alert.source) { case AlertSource.TILE: @@ -207,78 +247,126 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { }, [alert]); return ( -
- - {alert.state === AlertState.ALERT && ( - - Alert - - )} - {alert.state === AlertState.PENDING && ( - - Pending - - )} - {alert.state === AlertState.OK && Ok} - {alert.state === AlertState.DISABLED && ( - - Disabled - - )} - - -
- +
+ + {hasVisibleGroups ? ( + setAreGroupsExpanded(expanded => !expanded)} > - - {alertIcon} - {alertName} + + + ) : null} + + + +
+ + + {alertIcon} + {alertName} + + +
+
+ {alertType} + {notificationMethod} + {isGrouped && groupByLabel && ( + <> + · + Grouped by {groupByLabel} + + )} + {alert.createdBy && ( + <> + · + + Created by {alert.createdBy.name || alert.createdBy.email} + + + )} +
+ {getAlertTags(alert).length > 0 && ( + + {getAlertTags(alert).map(tag => ( + + {tag} + + ))} - -
-
- {alertType} - {notificationMethod} - {alert.createdBy && ( - <> - · - - Created by {alert.createdBy.name || alert.createdBy.email} - - )} -
- {getAlertTags(alert).length > 0 && ( - - {getAlertTags(alert).map(tag => ( - - {tag} - - ))} - - )} - {alert.note && } - - + {alert.note && } + + - - - - + {showParentActions ? ( + + + + + ) : null} +
+ {hasVisibleGroups ? ( + + + + ) : null}
); } - function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { - const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT); - const pendingAlerts = alerts.filter( - alert => alert.state === AlertState.PENDING, + const getAlertsForSection = React.useCallback( + (state: AlertsPageItem['state']) => + alerts.filter(alert => { + if (isAlertGrouped(alert)) { + return ( + alert.state === state || + getVisibleAlertGroups(alert, state).length > 0 + ); + } + return alert.state === state; + }), + [alerts], + ); + + const alarmAlerts = getAlertsForSection(AlertState.ALERT); + const pendingAlerts = getAlertsForSection(AlertState.PENDING); + const okData = getAlertsForSection(AlertState.OK); + + const getAlertCardKey = React.useCallback( + (alert: AlertsPageItem, state: AlertsPageItem['state']) => + isAlertGrouped(alert) ? `${alert._id}-${state}` : alert._id, + [], + ); + + const getAlertGroupState = React.useCallback( + (alert: AlertsPageItem, state: AlertsPageItem['state']) => + isAlertGrouped(alert) ? state : undefined, + [], ); - const okData = alerts.filter(alert => alert.state === AlertState.OK); return (
@@ -288,7 +376,11 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { Triggered {alarmAlerts.map(alert => ( - + ))}
)} @@ -298,7 +390,11 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { Pending {pendingAlerts.map(alert => ( - + ))} )} @@ -315,7 +411,11 @@ function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { /> )} {okData.map(alert => ( - + ))} diff --git a/packages/app/src/__tests__/AlertsPage.test.tsx b/packages/app/src/__tests__/AlertsPage.test.tsx new file mode 100644 index 0000000000..875ed35fef --- /dev/null +++ b/packages/app/src/__tests__/AlertsPage.test.tsx @@ -0,0 +1,682 @@ +import React from 'react'; +import { + AlertSource, + AlertState, + AlertThresholdType, +} from '@hyperdx/common-utils/dist/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import AlertsPage from '@/AlertsPage'; +import api from '@/api'; +import type { AlertsPageItem } from '@/types'; +import { truncateMiddle } from '@/utils'; + +const mockSilenceAlert = jest.fn(); +const mockUnsilenceAlert = jest.fn(); +const mockSilenceAlertGroup = jest.fn(); +const mockUnsilenceAlertGroup = jest.fn(); +const mockResumeAlertGroup = jest.fn(); +const mockClearAlertGroupResume = jest.fn(); +const mutationOptionsMatcher = expect.objectContaining({ + onError: expect.any(Function), + onSuccess: expect.any(Function), +}); + +jest.mock('nuqs', () => ({ + useQueryState: () => React.useState(null), +})); + +jest.mock('react-markdown', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('@/api', () => ({ + __esModule: true, + default: { + getAlertsQueryKey: () => ['alerts'], + getAlertQueryKey: (alertId: string | undefined) => ['alert', alertId], + useAlerts: jest.fn(), + useSilenceAlert: () => ({ + isPending: false, + mutate: mockSilenceAlert, + }), + useUnsilenceAlert: () => ({ + isPending: false, + mutate: mockUnsilenceAlert, + }), + useSilenceAlertGroup: () => ({ + isPending: false, + mutate: mockSilenceAlertGroup, + }), + useUnsilenceAlertGroup: () => ({ + isPending: false, + mutate: mockUnsilenceAlertGroup, + }), + useResumeAlertGroup: () => ({ + isPending: false, + mutate: mockResumeAlertGroup, + }), + useClearAlertGroupResume: () => ({ + isPending: false, + mutate: mockClearAlertGroupResume, + }), + }, +})); + +function renderAlertsPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return renderWithMantine( + + + , + ); +} + +const alertHistory = { + counts: 1, + createdAt: '2024-01-01T00:00:00.000Z', + lastValues: [{ startTime: '2024-01-01T00:00:00.000Z', count: 1 }], + state: AlertState.ALERT, +}; + +function makeAlert(overrides: Partial = {}): AlertsPageItem { + return { + _id: 'alert-1', + interval: '5m', + threshold: 10, + thresholdType: AlertThresholdType.ABOVE, + channel: { type: 'webhook' }, + state: AlertState.ALERT, + source: AlertSource.SAVED_SEARCH, + savedSearchId: 'saved-search-1', + name: null, + message: null, + note: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + history: [alertHistory], + groupBy: 'ServiceName', + savedSearch: { + _id: 'saved-search-1', + createdAt: '2024-01-01T00:00:00.000Z', + name: 'Grouped alert', + updatedAt: '2024-01-01T00:00:00.000Z', + tags: [], + }, + ...overrides, + }; +} + +describe('AlertsPage grouped alerts', () => { + beforeEach(() => { + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + groups: [ + { + group: 'ServiceName:app', + state: AlertState.ALERT, + history: [alertHistory], + }, + { + group: 'ServiceName:api', + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + silenced: { + by: 'test@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + mockSilenceAlert.mockClear(); + mockUnsilenceAlert.mockClear(); + mockSilenceAlertGroup.mockClear(); + mockUnsilenceAlertGroup.mockClear(); + mockResumeAlertGroup.mockClear(); + mockClearAlertGroupResume.mockClear(); + }); + + it('renders group rows in their matching status sections', () => { + renderAlertsPage(); + + expect( + screen.getByTestId('alert-group-row-alert-1-ALERT-0'), + ).toHaveTextContent('ServiceName:app'); + expect( + screen.getByTestId('alert-group-row-alert-1-OK-0'), + ).toHaveTextContent('ServiceName:api'); + }); + + it('renders OK group rows for an OK alert', () => { + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + groups: [ + { + group: 'ServiceName:api', + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + expect( + screen.getByTestId('alert-group-row-alert-1-OK-0'), + ).toHaveTextContent('ServiceName:api'); + }); + + it('collapses and expands group rows under a grouped alert', async () => { + const user = userEvent.setup(); + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + const toggle = screen.getByTestId('alert-group-toggle-alert-1-ALERT'); + + expect(groupRow).toBeVisible(); + + await user.click(toggle); + expect(groupRow).not.toBeVisible(); + + await user.click(toggle); + expect(groupRow).toBeVisible(); + }); + + it('shows grouped configuration without child rows before group history exists', () => { + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + groups: [], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + expect( + screen.queryByTestId('alert-group-toggle-alert-1-ALERT'), + ).not.toBeInTheDocument(); + expect(screen.getByText('Grouped by ServiceName')).toBeInTheDocument(); + expect( + screen.queryByText('Waiting for group data'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId(/alert-group-row-/)).not.toBeInTheDocument(); + }); + + it('does not show an empty collapse when a grouped alert has no groups in that status section', () => { + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + state: AlertState.ALERT, + groups: [ + { + group: 'ServiceName:api', + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + expect( + screen.queryByTestId('alert-group-toggle-alert-1-ALERT'), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId('alert-group-toggle-alert-1-OK'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('alert-group-row-alert-1-OK-0'), + ).toHaveTextContent('ServiceName:api'); + }); + + it('renders ungrouped alerts without group rows even when stale group summaries exist', () => { + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + groupBy: undefined, + groups: [ + { + group: 'ServiceName:api', + state: AlertState.ALERT, + history: [alertHistory], + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + expect( + screen.queryByTestId('alert-group-toggle-alert-1-ALERT'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('ServiceName:api')).not.toBeInTheDocument(); + expect( + screen.queryByText('Waiting for group data'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Grouped by ServiceName'), + ).not.toBeInTheDocument(); + }); + + it('acking a group calls the group-specific mutation payload', async () => { + const user = userEvent.setup(); + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + await user.click(within(groupRow).getByRole('button', { name: 'Ack' })); + await user.click(await screen.findByText('30 minutes')); + + expect(mockSilenceAlertGroup).toHaveBeenCalledWith( + expect.objectContaining({ + alertId: 'alert-1', + group: 'ServiceName:app', + mutedUntil: expect.any(String), + }), + mutationOptionsMatcher, + ); + expect(mockSilenceAlert).not.toHaveBeenCalled(); + }); + + it('normalizes legacy dashboard alert group labels for display while preserving ack payload', async () => { + const user = userEvent.setup(); + const rawGroup = + 'arrayElement(ResourceAttributes, \'process.command_args\'):["node","./src/index.ts"]'; + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + groups: [ + { + group: rawGroup, + state: AlertState.ALERT, + history: [alertHistory], + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + const displayGroup = + 'ResourceAttributes[\'process.command_args\']:["node","./src/index.ts"]'; + + expect(groupRow).toHaveTextContent(truncateMiddle(displayGroup, 35)); + expect( + within(groupRow).getByTitle(rawGroup, { exact: true }), + ).toBeInTheDocument(); + expect(groupRow).not.toHaveTextContent( + 'ResourceAttributes[\'process.command_args\']:["node","./src/index.ts"]', + ); + + await user.click(within(groupRow).getByRole('button', { name: 'Ack' })); + await user.click(await screen.findByText('30 minutes')); + + expect(mockSilenceAlertGroup).toHaveBeenCalledWith( + expect.objectContaining({ + alertId: 'alert-1', + group: rawGroup, + mutedUntil: expect.any(String), + }), + mutationOptionsMatcher, + ); + }); + + it('resuming a group calls the group-specific unsilence mutation', async () => { + const user = userEvent.setup(); + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + groups: [ + { + group: 'ServiceName:app', + state: AlertState.ALERT, + history: [alertHistory], + silenced: { + by: 'test@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + await user.click(within(groupRow).getByRole('button', { name: "Ack'd" })); + await user.click(await screen.findByText('Resume alert')); + + expect(mockUnsilenceAlertGroup).toHaveBeenCalledWith( + { + alertId: 'alert-1', + group: 'ServiceName:app', + }, + mutationOptionsMatcher, + ); + expect(mockUnsilenceAlert).not.toHaveBeenCalled(); + }); + + it('parent-level ack still calls the whole-alert mutation', async () => { + const user = userEvent.setup(); + renderAlertsPage(); + + const alertCard = screen.getByTestId('alert-card-alert-1-ALERT'); + await user.click( + within(alertCard).getAllByRole('button', { name: 'Ack' })[0], + ); + await user.click(await screen.findByText('30 minutes')); + + expect(mockSilenceAlert).toHaveBeenCalledWith( + expect.objectContaining({ + alertId: 'alert-1', + mutedUntil: expect.any(String), + }), + mutationOptionsMatcher, + ); + expect(mockSilenceAlertGroup).not.toHaveBeenCalled(); + }); + + it('shows group ack as effective for touched alerting group rows while preserving parent ack metadata', async () => { + const user = userEvent.setup(); + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + silenced: { + by: 'parent@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + groups: [ + { + group: 'ServiceName:app', + state: AlertState.ALERT, + history: [alertHistory], + silenced: { + by: 'group@example.com', + at: '2024-01-02T00:00:00.000Z', + until: '2099-01-02T00:00:00.000Z', + }, + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + await user.click(within(groupRow).getByRole('button', { name: "Ack'd" })); + + expect(await screen.findByText('group@example.com')).toBeInTheDocument(); + expect( + screen.getByText(/Parent alert acknowledgment also exists/), + ).toBeInTheDocument(); + expect(screen.getByText('parent@example.com')).toBeInTheDocument(); + }); + + it('shows inherited parent ack as the effective group state and can resume only that group', async () => { + const user = userEvent.setup(); + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + silenced: { + by: 'parent@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + groups: [ + { + group: 'ServiceName:app', + state: AlertState.ALERT, + history: [alertHistory], + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + + await user.click(within(groupRow).getByRole('button', { name: 'Muted' })); + expect(await screen.findByText('Muted by parent ack.')).toBeInTheDocument(); + await user.click(screen.getByText('Resume group')); + + expect(mockResumeAlertGroup).toHaveBeenCalledWith( + { + alertId: 'alert-1', + group: 'ServiceName:app', + }, + mutationOptionsMatcher, + ); + expect(mockUnsilenceAlert).not.toHaveBeenCalled(); + }); + + it('does not show parent ack as effective for OK group rows', () => { + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + silenced: { + by: 'parent@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + groups: [ + { + group: 'ServiceName:app', + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-OK-0'); + expect( + within(groupRow).queryByRole('button', { name: 'Muted' }), + ).not.toBeInTheDocument(); + }); + + it('shows explicit group ack for OK group rows while parent ack is active', async () => { + const user = userEvent.setup(); + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + silenced: { + by: 'parent@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + groups: [ + { + group: 'ServiceName:app', + state: AlertState.OK, + history: [{ ...alertHistory, state: AlertState.OK }], + silenced: { + by: 'group@example.com', + at: '2024-01-02T00:00:00.000Z', + until: '2099-01-02T00:00:00.000Z', + }, + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-OK-0'); + await user.click(within(groupRow).getByRole('button', { name: "Ack'd" })); + + expect(await screen.findByText('group@example.com')).toBeInTheDocument(); + expect(screen.queryByText('parent@example.com')).not.toBeInTheDocument(); + }); + + it('resuming an explicitly acked group while parent ack is active creates a group resume override', async () => { + const user = userEvent.setup(); + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + silenced: { + by: 'parent@example.com', + at: '2024-01-01T00:00:00.000Z', + until: '2099-01-01T00:00:00.000Z', + }, + groups: [ + { + group: 'ServiceName:app', + state: AlertState.ALERT, + history: [alertHistory], + silenced: { + by: 'group@example.com', + at: '2024-01-02T00:00:00.000Z', + until: '2099-01-02T00:00:00.000Z', + }, + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + await user.click(within(groupRow).getByRole('button', { name: "Ack'd" })); + await user.click(await screen.findByText('Resume group')); + + expect(mockResumeAlertGroup).toHaveBeenCalledWith( + { + alertId: 'alert-1', + group: 'ServiceName:app', + }, + mutationOptionsMatcher, + ); + expect(mockUnsilenceAlert).not.toHaveBeenCalled(); + }); + + it('can return an explicitly resumed group to the parent acknowledgment', async () => { + const user = userEvent.setup(); + const parentSilencedAt = '2024-01-01T00:00:00.000Z'; + jest.mocked(api.useAlerts).mockReturnValue({ + data: { + data: [ + makeAlert({ + silenced: { + by: 'parent@example.com', + at: parentSilencedAt, + until: '2099-01-01T00:00:00.000Z', + }, + groups: [ + { + group: 'ServiceName:app', + state: AlertState.ALERT, + history: [alertHistory], + unsilenced: { + by: 'group@example.com', + at: '2024-01-02T00:00:00.000Z', + parentSilencedAt, + }, + }, + ], + }), + ], + }, + isError: false, + isLoading: false, + } as ReturnType); + + renderAlertsPage(); + + const groupRow = screen.getByTestId('alert-group-row-alert-1-ALERT-0'); + await user.click(within(groupRow).getByRole('button', { name: 'Ack' })); + await user.click(await screen.findByText('Use parent ack')); + + expect(mockClearAlertGroupResume).toHaveBeenCalledWith( + { + alertId: 'alert-1', + group: 'ServiceName:app', + }, + mutationOptionsMatcher, + ); + }); +}); diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 21facd9c05..62631ba60e 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -123,6 +123,50 @@ const api = { }, }); }, + useSilenceAlertGroup() { + return useMutation< + void, + Error, + { alertId: string; group: string; mutedUntil: string } + >({ + mutationFn: async ({ alertId, group, mutedUntil }) => { + await server(`alerts/${alertId}/group-silenced`, { + method: 'POST', + json: { group, mutedUntil }, + }); + }, + }); + }, + useUnsilenceAlertGroup() { + return useMutation({ + mutationFn: async ({ alertId, group }) => { + await server(`alerts/${alertId}/group-silenced`, { + method: 'DELETE', + searchParams: { group }, + }); + }, + }); + }, + useResumeAlertGroup() { + return useMutation({ + mutationFn: async ({ alertId, group }) => { + await server(`alerts/${alertId}/group-unsilenced`, { + method: 'POST', + json: { group }, + }); + }, + }); + }, + useClearAlertGroupResume() { + return useMutation({ + mutationFn: async ({ alertId, group }) => { + await server(`alerts/${alertId}/group-unsilenced`, { + method: 'DELETE', + searchParams: { group }, + }); + }, + }); + }, usePresetDashboardFilters( presetDashboard: PresetDashboard, sourceId: string, diff --git a/packages/app/src/components/alerts/AckAlert.tsx b/packages/app/src/components/alerts/AckAlert.tsx index 44bfdf4dc5..b910e04455 100644 --- a/packages/app/src/components/alerts/AckAlert.tsx +++ b/packages/app/src/components/alerts/AckAlert.tsx @@ -1,47 +1,98 @@ import * as React from 'react'; import type { Duration } from 'date-fns'; import { add } from 'date-fns'; -import { Button, Menu } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { IconBell } from '@tabler/icons-react'; +import type { QueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query'; import api from '@/api'; import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; import type { AlertsPageItem } from '@/types'; -import { FormatTime } from '@/useFormatTime'; import { isAlertSilenceExpired } from '@/utils/alerts'; +import { AlertAckMenu } from './AlertAckMenu'; + +type AlertSilence = AlertsPageItem['silenced']; +type AlertGroup = NonNullable[number]; + +function isActiveSilence(silence?: AlertSilence) { + return silence?.at ? !isAlertSilenceExpired(silence) : false; +} + +export function isGroupMutedByParentAck({ + group, + parentSilenced, +}: { + group: AlertGroup; + parentSilenced?: AlertSilence; +}) { + const isParentAckActive = isActiveSilence(parentSilenced); + const isGroupAckActive = isActiveSilence(group.silenced); + const hasGroupResumeOverride = + isParentAckActive && + group.unsilenced?.parentSilencedAt === parentSilenced?.at; + + return ( + group.state === 'ALERT' && + isParentAckActive && + !isGroupAckActive && + !hasGroupResumeOverride + ); +} + +function getErrorStatus(error: unknown): number | undefined { + if (!(error instanceof Error) || !('response' in error)) return undefined; + + const { response } = error; + if (!response || typeof response !== 'object' || !('status' in response)) { + return undefined; + } + + const { status } = response; + return typeof status === 'number' ? status : undefined; +} + +function getAckMutationOptions({ + alertId, + queryClient, +}: { + alertId: string; + queryClient: QueryClient; +}) { + return { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.getAlertsQueryKey() }); + queryClient.invalidateQueries({ + queryKey: api.getAlertQueryKey(alertId), + }); + }, + onError: (error: unknown) => { + const status = getErrorStatus(error); + let message = + 'Failed to update alert acknowledgment, please try again later.'; + + if (status === 404) { + message = 'Alert not found.'; + } else if (status === 400) { + message = 'Invalid acknowledgment request. Refresh and try again.'; + } + + notifications.show({ + color: 'red', + message, + }); + }, + }; +} + export function AckAlert({ alert }: { alert: AlertsPageItem }) { const queryClient = useQueryClient(); const silenceAlert = api.useSilenceAlert(); const unsilenceAlert = api.useUnsilenceAlert(); const mutateOptions = React.useMemo( - () => ({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: api.getAlertsQueryKey() }); - queryClient.invalidateQueries({ - queryKey: api.getAlertQueryKey(alert._id), - }); - }, - onError: (error: any) => { - const status = error?.response?.status; - let message = 'Failed to silence alert, please try again later.'; - - if (status === 404) { - message = 'Alert not found.'; - } else if (status === 400) { - message = - 'Invalid request. Please ensure the silence duration is valid.'; - } - - notifications.show({ - color: 'red', - message, - }); - }, - }), + () => getAckMutationOptions({ alertId: alert._id, queryClient }), [queryClient, alert._id], ); @@ -49,10 +100,6 @@ export function AckAlert({ alert }: { alert: AlertsPageItem }) { unsilenceAlert.mutate(alert._id || '', mutateOptions); }, [alert._id, mutateOptions, unsilenceAlert]); - const isNoLongerMuted = React.useMemo(() => { - return isAlertSilenceExpired(alert.silenced); - }, [alert.silenced]); - const handleSilenceAlert = React.useCallback( (duration: Duration) => { // eslint-disable-next-line no-restricted-syntax @@ -68,123 +115,152 @@ export function AckAlert({ alert }: { alert: AlertsPageItem }) { [alert._id, mutateOptions, silenceAlert], ); - if (alert.silenced?.at) { - return ( - - - - - - - - Acknowledged{' '} - {alert.silenced?.by ? ( - <> - by {alert.silenced?.by} - - ) : null}{' '} - on
- - .
-
- - - {isNoLongerMuted ? ( - 'Alert resumed.' - ) : ( - <> - Resumes . - - )} - - - {isNoLongerMuted ? 'Unacknowledge' : 'Resume alert'} - -
-
-
+ return ( + + + + ); +} + +export function AckAlertGroup({ + alertId, + group, + parentSilenced, +}: { + alertId: string; + group: AlertGroup; + parentSilenced?: AlertSilence; +}) { + const queryClient = useQueryClient(); + const silenceAlertGroup = api.useSilenceAlertGroup(); + const unsilenceAlertGroup = api.useUnsilenceAlertGroup(); + const resumeAlertGroup = api.useResumeAlertGroup(); + const clearAlertGroupResume = api.useClearAlertGroupResume(); + + const mutateOptions = React.useMemo( + () => getAckMutationOptions({ alertId, queryClient }), + [queryClient, alertId], + ); + + const isParentAckActive = React.useMemo( + () => isActiveSilence(parentSilenced), + [parentSilenced], + ); + + const isGroupAckActive = isActiveSilence(group.silenced); + + const hasGroupResumeOverride = + isParentAckActive && + group.unsilenced?.parentSilencedAt === parentSilenced?.at; + + const isMutedByParentAck = isGroupMutedByParentAck({ + group, + parentSilenced, + }); + const effectiveSilenced = isGroupAckActive + ? group.silenced + : isMutedByParentAck + ? parentSilenced + : group.silenced; + + const secondarySilenced = + group.state === 'ALERT' && + isGroupAckActive && + isParentAckActive && + !hasGroupResumeOverride + ? parentSilenced + : undefined; + + const handleUnsilenceAlertGroup = React.useCallback(() => { + unsilenceAlertGroup.mutate( + { + alertId, + group: group.group, + }, + mutateOptions, + ); + }, [alertId, group.group, mutateOptions, unsilenceAlertGroup]); + + const handleResumeAlertGroup = React.useCallback(() => { + resumeAlertGroup.mutate( + { + alertId, + group: group.group, + }, + mutateOptions, ); - } + }, [alertId, group.group, mutateOptions, resumeAlertGroup]); - if (alert.state === 'ALERT') { - return ( - - - - - - - - Acknowledge and silence for - - - handleSilenceAlert({ - minutes: 30, - }) - } - > - 30 minutes - - - handleSilenceAlert({ - hours: 1, - }) - } - > - 1 hour - - - handleSilenceAlert({ - hours: 6, - }) - } - > - 6 hours - - - handleSilenceAlert({ - hours: 24, - }) - } - > - 24 hours - - - - + const handleClearAlertGroupResume = React.useCallback(() => { + clearAlertGroupResume.mutate( + { + alertId, + group: group.group, + }, + mutateOptions, ); - } + }, [alertId, clearAlertGroupResume, group.group, mutateOptions]); - return null; + const handleSilenceAlertGroup = React.useCallback( + (duration: Duration) => { + // eslint-disable-next-line no-restricted-syntax + const mutedUntil = add(new Date(), duration); + silenceAlertGroup.mutate( + { + alertId, + group: group.group, + mutedUntil: mutedUntil.toISOString(), + }, + mutateOptions, + ); + }, + [alertId, group.group, mutateOptions, silenceAlertGroup], + ); + + return ( + + } + acknowledgedButtonLabel={isMutedByParentAck ? 'Muted' : "Ack'd"} + acknowledgedButtonVariant={isMutedByParentAck ? 'subtle' : 'primary'} + canUnsilence={isMutedByParentAck || group.silenced != null} + description={isMutedByParentAck ? 'Muted by parent ack.' : undefined} + isPending={ + silenceAlertGroup.isPending || + unsilenceAlertGroup.isPending || + resumeAlertGroup.isPending || + clearAlertGroupResume.isPending + } + onSilence={handleSilenceAlertGroup} + onResetToInherited={ + hasGroupResumeOverride + ? handleClearAlertGroupResume + : isGroupAckActive && isParentAckActive + ? handleUnsilenceAlertGroup + : undefined + } + onUnsilence={ + isParentAckActive ? handleResumeAlertGroup : handleUnsilenceAlertGroup + } + secondarySilenced={secondarySilenced} + secondarySilencedLabel="Parent alert acknowledgment also exists" + silenced={effectiveSilenced} + silencedLabel={ + isMutedByParentAck ? 'Parent ack' : 'This group is acknowledged' + } + silenceOptionsLabel={ + effectiveSilenced?.at ? 'Acknowledge this group for' : undefined + } + state={group.state} + unacknowledgedButtonIcon={} + unsilenceLabel={isParentAckActive ? 'Resume group' : undefined} + /> + + ); } diff --git a/packages/app/src/components/alerts/AlertAckMenu.tsx b/packages/app/src/components/alerts/AlertAckMenu.tsx new file mode 100644 index 0000000000..1a2211013b --- /dev/null +++ b/packages/app/src/components/alerts/AlertAckMenu.tsx @@ -0,0 +1,209 @@ +import * as React from 'react'; +import type { Duration } from 'date-fns'; +import { Button, Menu } from '@mantine/core'; +import { IconBell } from '@tabler/icons-react'; + +import type { AlertsPageItem } from '@/types'; +import { FormatTime } from '@/useFormatTime'; +import { isAlertSilenceExpired } from '@/utils/alerts'; + +type AlertSilence = AlertsPageItem['silenced']; + +const ACK_DURATIONS: Array<{ label: string; duration: Duration }> = [ + { label: '30 minutes', duration: { minutes: 30 } }, + { label: '1 hour', duration: { hours: 1 } }, + { label: '6 hours', duration: { hours: 6 } }, + { label: '24 hours', duration: { hours: 24 } }, +]; + +export function AlertAckMenu({ + acknowledgedButtonIcon, + acknowledgedButtonLabel = "Ack'd", + acknowledgedButtonVariant = 'primary', + canUnsilence = true, + description, + isPending, + onSilence, + onResetToInherited, + onUnsilence, + resetToInheritedLabel = 'Use parent ack', + secondarySilenced, + secondarySilencedLabel, + silenced, + silencedLabel = 'Acknowledged', + silenceOptionsLabel, + state, + unacknowledgedButtonIcon, + unacknowledgedButtonLabel = 'Ack', + unsilenceLabel, +}: { + acknowledgedButtonIcon?: React.ReactNode; + acknowledgedButtonLabel?: string; + acknowledgedButtonVariant?: 'primary' | 'secondary' | 'subtle'; + canUnsilence?: boolean; + description?: React.ReactNode; + isPending: boolean; + onSilence: (duration: Duration) => void; + onResetToInherited?: () => void; + onUnsilence: () => void; + resetToInheritedLabel?: string; + secondarySilenced?: AlertSilence; + secondarySilencedLabel?: string; + silenced?: AlertSilence; + silencedLabel?: string; + silenceOptionsLabel?: string; + state?: AlertsPageItem['state']; + unacknowledgedButtonIcon?: React.ReactNode; + unacknowledgedButtonLabel?: string; + unsilenceLabel?: string; +}) { + const isNoLongerMuted = React.useMemo( + () => isAlertSilenceExpired(silenced), + [silenced], + ); + + if (silenced?.at && !isNoLongerMuted) { + return ( + + + + + + {description ? {description} : null} + + {silencedLabel}{' '} + {silenced.by ? ( + <> + by {silenced.by} + + ) : null}{' '} + on
+ + .
+
+ + {isNoLongerMuted ? ( + 'Alert resumed.' + ) : ( + <> + Resumes . + + )} + + {secondarySilenced?.at ? ( + <> + + + {secondarySilencedLabel ?? 'Group acknowledgment'}{' '} + {secondarySilenced.by ? ( + <> + by {secondarySilenced.by} + + ) : null} +
+ Resumes . +
+ + ) : null} + {canUnsilence ? ( + + {unsilenceLabel ?? + (isNoLongerMuted ? 'Unacknowledge' : 'Resume alert')} + + ) : null} + {onResetToInherited ? ( + + {resetToInheritedLabel} + + ) : null} + {silenceOptionsLabel ? ( + <> + + + {silenceOptionsLabel} + + {ACK_DURATIONS.map(({ label, duration }) => ( + onSilence(duration)} + > + {label} + + ))} + + ) : null} +
+
+ ); + } + + if (state !== 'ALERT') return null; + + return ( + + + + + + + Acknowledge and silence for + + {ACK_DURATIONS.map(({ label, duration }) => ( + onSilence(duration)} + > + {label} + + ))} + {onResetToInherited ? ( + <> + + + {resetToInheritedLabel} + + + ) : null} + + + ); +} diff --git a/packages/app/src/components/alerts/AlertGroupRows.tsx b/packages/app/src/components/alerts/AlertGroupRows.tsx new file mode 100644 index 0000000000..46d7e953e7 --- /dev/null +++ b/packages/app/src/components/alerts/AlertGroupRows.tsx @@ -0,0 +1,87 @@ +import cx from 'classnames'; +import { Group, Stack, Text } from '@mantine/core'; + +import type { AlertsPageItem } from '@/types'; +import { truncateMiddle } from '@/utils'; + +import { AckAlertGroup, isGroupMutedByParentAck } from './AckAlert'; +import { AlertHistoryCardStack } from './AlertHistoryCards'; +import { AlertStateBadge } from './AlertStateBadge'; + +import styles from '@styles/AlertsPage.module.scss'; + +function getAlertGroupDisplayName(group: string) { + return group.replace( + /^arrayElement\((\w+), '([^']+)'\):/, + (_, mapName: string, key: string) => `${mapName}['${key}']:`, + ); +} + +export function getVisibleAlertGroups( + alert: AlertsPageItem, + state: AlertsPageItem['state'] = alert.state, +) { + return alert.groups?.filter(group => group.state === state) ?? []; +} + +export function isAlertGrouped(alert: AlertsPageItem) { + if (Array.isArray(alert.groupBy)) { + return alert.groupBy.length > 0; + } + return alert.groupBy != null && alert.groupBy.trim().length > 0; +} + +export function AlertGroupRows({ + alert, + state, +}: { + alert: AlertsPageItem; + state?: AlertsPageItem['state']; +}) { + const groups = getVisibleAlertGroups(alert, state); + const testIdState = state ?? alert.state; + if (!groups.length) return null; + + return ( + + {groups.map((group, index) => { + const displayGroup = getAlertGroupDisplayName(group.group); + const truncatedDisplayGroup = truncateMiddle(displayGroup, 35); + const rowTestId = `alert-group-row-${alert._id}-${testIdState}-${index}`; + const isMutedByParentAck = isGroupMutedByParentAck({ + group, + parentSilenced: alert.silenced, + }); + + return ( +
+ + + + {truncatedDisplayGroup} + + + + + + +
+ ); + })} +
+ ); +} diff --git a/packages/app/src/components/alerts/AlertHistoryCards.tsx b/packages/app/src/components/alerts/AlertHistoryCards.tsx index a20537308f..ff15dd1b91 100644 --- a/packages/app/src/components/alerts/AlertHistoryCards.tsx +++ b/packages/app/src/components/alerts/AlertHistoryCards.tsx @@ -183,14 +183,13 @@ function AlertErrorsIndicator({ alert }: { alert: AlertsPageItem }) { ); } -export function AlertHistoryCardList({ - alert, +export function AlertHistoryCardStack({ alertUrl, + history, }: { - alert: AlertsPageItem; alertUrl?: string; + history: AlertHistory[]; }) { - const { history } = alert; const items = React.useMemo(() => { if (history.length < HISTORY_ITEMS) { return history; @@ -205,28 +204,36 @@ export function AlertHistoryCardList({ return new Array(HISTORY_ITEMS - history.length).fill(null); }, [history]); + if (items.length === 0) return null; + + return ( +
+ {paddingItems.map((_, index) => ( + +
+ + ))} + {items + .slice() + .reverse() + .map((history, index) => ( + + ))} +
+ ); +} + +export function AlertHistoryCardList({ + alert, + alertUrl, +}: { + alert: AlertsPageItem; + alertUrl?: string; +}) { return ( - {items.length > 0 && ( -
- {paddingItems.map((_, index) => ( - -
- - ))} - {items - .slice() - .reverse() - .map((history, index) => ( - - ))} -
- )} + ); } diff --git a/packages/app/src/components/alerts/AlertStateBadge.tsx b/packages/app/src/components/alerts/AlertStateBadge.tsx new file mode 100644 index 0000000000..5612ac1c6e --- /dev/null +++ b/packages/app/src/components/alerts/AlertStateBadge.tsx @@ -0,0 +1,29 @@ +import { AlertState } from '@hyperdx/common-utils/dist/types'; +import { Badge } from '@mantine/core'; + +export function AlertStateBadge({ state }: { state?: AlertState }) { + switch (state) { + case AlertState.ALERT: + return ( + + Alert + + ); + case AlertState.PENDING: + return ( + + Pending + + ); + case AlertState.OK: + return Ok; + case AlertState.DISABLED: + return ( + + Disabled + + ); + default: + return null; + } +} diff --git a/packages/app/styles/AlertsPage.module.scss b/packages/app/styles/AlertsPage.module.scss index 6261985499..2e94021a98 100644 --- a/packages/app/styles/AlertsPage.module.scss +++ b/packages/app/styles/AlertsPage.module.scss @@ -57,6 +57,61 @@ border-bottom: 1px solid var(--color-border); } +.alertBlock { + border-bottom: 1px solid var(--color-border); + + .alertRow { + border-bottom: 0; + } +} + +.alertGroupRows { + padding: 0 0 8px 34px; +} + +.alertGroupToggle { + align-items: center; + border-radius: 4px; + color: var(--color-text-muted); + display: inline-flex; + height: 22px; + justify-content: center; + width: 22px; + + &:hover { + background: var(--color-bg-hover); + color: var(--color-text); + } +} + +.alertGroupRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 0; + min-width: 0; +} + +.alertGroupRowInheritedMuted { + .alertGroupMeta { + opacity: 0.58; + } +} + +.alertGroupMeta { + flex: 1 1 auto; + min-width: 0; +} + +.alertGroupLabel { + color: var(--color-text-secondary); + font-family: var(--mantine-font-family-monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .alertLink { font-weight: 500; color: var(--color-text-secondary); diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 9ef47c1da4..e255191c86 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -748,6 +748,23 @@ export const scheduleStartAtSchema = z export const alertNoteSchema = z.string().min(1).max(4096).nullish(); +export const AlertSilenceSchema = z.object({ + by: z.string().optional(), + at: z.string(), + until: z.string(), +}); + +export const AlertSilencedGroupSchema = AlertSilenceSchema.extend({ + group: z.string(), +}); + +export const AlertUnsilencedGroupSchema = z.object({ + group: z.string(), + by: z.string().optional(), + at: z.string(), + parentSilencedAt: z.string(), +}); + export const AlertBaseObjectSchema = z.object({ id: z.string().optional(), interval: AlertIntervalSchema, @@ -766,13 +783,8 @@ export const AlertBaseObjectSchema = z.object({ name: z.string().min(1).max(512).nullish(), message: z.string().min(1).max(4096).nullish(), note: alertNoteSchema, - silenced: z - .object({ - by: z.string(), - at: z.string(), - until: z.string(), - }) - .optional(), + silenced: AlertSilenceSchema.optional(), + silencedGroups: z.array(AlertSilencedGroupSchema).optional(), numConsecutiveWindows: z.number().int().min(1).nullish(), }); @@ -808,6 +820,16 @@ export const AlertHistorySchema = z.object({ export type AlertHistory = z.infer; +export const AlertGroupSummarySchema = z.object({ + group: z.string(), + state: z.nativeEnum(AlertState), + history: z.array(AlertHistorySchema), + silenced: AlertSilenceSchema.optional(), + unsilenced: AlertUnsilencedGroupSchema.omit({ group: true }).optional(), +}); + +export type AlertGroupSummary = z.infer; + // A single alert state transition within a time range, used to draw // firing/recovery annotations on dashboard charts. Only boundary crossings are // emitted: ALERT = fired, OK = recovered. @@ -2106,6 +2128,8 @@ export const AlertsPageItemSchema = z.object({ createdAt: z.string(), updatedAt: z.string(), history: z.array(AlertHistorySchema), + groupBy: z.union([z.string(), z.array(z.string())]).optional(), + groups: z.array(AlertGroupSummarySchema).optional(), dashboard: z .object({ _id: z.string(), @@ -2135,13 +2159,8 @@ export const AlertsPageItemSchema = z.object({ name: z.string().optional(), }) .optional(), - silenced: z - .object({ - by: z.string(), - at: z.string(), - until: z.string(), - }) - .optional(), + silenced: AlertSilenceSchema.optional(), + silencedGroups: z.array(AlertSilencedGroupSchema).optional(), executionErrors: z.array(AlertErrorSchema).optional(), numConsecutiveWindows: z.number().int().min(1).nullish(), });