diff --git a/.changeset/healthy-spoons-shop.md b/.changeset/healthy-spoons-shop.md
new file mode 100644
index 00000000..54113e9d
--- /dev/null
+++ b/.changeset/healthy-spoons-shop.md
@@ -0,0 +1,6 @@
+---
+"@hyperdx/api": patch
+"@hyperdx/app": patch
+---
+
+feat: Add silence alerts feature
diff --git a/packages/api/src/routers/api/__tests__/alerts.test.ts b/packages/api/src/routers/api/__tests__/alerts.test.ts
index 5d37a3d6..3accf375 100644
--- a/packages/api/src/routers/api/__tests__/alerts.test.ts
+++ b/packages/api/src/routers/api/__tests__/alerts.test.ts
@@ -174,4 +174,124 @@ describe('alerts router', () => {
expect(alert.dashboard).toBeDefined();
}
});
+
+ it('can silence an alert', async () => {
+ const { agent, user } = await getLoggedInAgent(server);
+ 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,
+ }),
+ )
+ .expect(200);
+
+ const mutedUntil = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
+ await agent
+ .post(`/alerts/${alert.body.data._id}/silenced`)
+ .send({ mutedUntil })
+ .expect(200);
+
+ // Verify the alert was silenced
+ const alertFromDb = await Alert.findById(alert.body.data._id);
+ expect(alertFromDb).toBeDefined();
+ expect(alertFromDb!.silenced).toBeDefined();
+ expect(alertFromDb!.silenced!.by).toEqual(user._id);
+ expect(alertFromDb!.silenced!.at).toBeDefined();
+ expect(new Date(alertFromDb!.silenced!.until).toISOString()).toBe(
+ mutedUntil,
+ );
+ });
+
+ it('can unsilence an alert', async () => {
+ const { agent, user } = await getLoggedInAgent(server);
+ 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,
+ }),
+ )
+ .expect(200);
+
+ // First silence the alert
+ const mutedUntil = new Date(Date.now() + 3600000).toISOString();
+ await agent
+ .post(`/alerts/${alert.body.data._id}/silenced`)
+ .send({ mutedUntil })
+ .expect(200);
+
+ // Verify it was silenced
+ let alertFromDb = await Alert.findById(alert.body.data._id);
+ expect(alertFromDb!.silenced).toBeDefined();
+
+ // Now unsilence it
+ await agent.delete(`/alerts/${alert.body.data._id}/silenced`).expect(200);
+
+ // Verify it was unsilenced
+ alertFromDb = await Alert.findById(alert.body.data._id);
+ expect(alertFromDb).toBeDefined();
+ expect(alertFromDb!.silenced).toBeUndefined();
+ });
+
+ it('returns silenced info in GET /alerts', async () => {
+ const { agent } = await getLoggedInAgent(server);
+ 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,
+ }),
+ )
+ .expect(200);
+
+ // Silence the alert
+ const mutedUntil = new Date(Date.now() + 3600000).toISOString();
+ await agent
+ .post(`/alerts/${alert.body.data._id}/silenced`)
+ .send({ mutedUntil })
+ .expect(200);
+
+ // Get alerts and verify silenced info is returned
+ const alerts = await agent.get('/alerts').expect(200);
+ expect(alerts.body.data.length).toBe(1);
+ const silencedAlert = alerts.body.data[0];
+ expect(silencedAlert.silenced).toBeDefined();
+ expect(silencedAlert.silenced.by).toBeDefined(); // Should contain email
+ expect(silencedAlert.silenced.at).toBeDefined();
+ expect(silencedAlert.silenced.until).toBeDefined();
+ });
+
+ it('prevents silencing an alert that does not exist', async () => {
+ const { agent } = await getLoggedInAgent(server);
+ const fakeId = randomMongoId();
+ const mutedUntil = new Date(Date.now() + 3600000).toISOString();
+
+ await agent
+ .post(`/alerts/${fakeId}/silenced`)
+ .send({ mutedUntil })
+ .expect(404); // Should fail because alert doesn't exist
+ });
+
+ it('prevents unsilencing an alert that does not exist', async () => {
+ const { agent } = await getLoggedInAgent(server);
+ const fakeId = randomMongoId();
+
+ await agent.delete(`/alerts/${fakeId}/silenced`).expect(404); // Should fail
+ });
});
diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts
index 9c14fbbf..62eeb2a5 100644
--- a/packages/api/src/routers/api/alerts.ts
+++ b/packages/api/src/routers/api/alerts.ts
@@ -135,7 +135,12 @@ router.post(
'/:id/silenced',
validateRequest({
body: z.object({
- mutedUntil: z.string().datetime(),
+ mutedUntil: z
+ .string()
+ .datetime()
+ .refine(val => new Date(val) > new Date(), {
+ message: 'mutedUntil must be in the future',
+ }),
}),
params: z.object({
id: objectIdSchema,
@@ -150,7 +155,7 @@ router.post(
const alert = await getAlertById(req.params.id, teamId);
if (!alert) {
- throw new Error('Alert not found');
+ return res.status(404).json({ error: 'Alert not found' });
}
alert.silenced = {
by: req.user._id,
@@ -182,7 +187,7 @@ router.delete(
const alert = await getAlertById(req.params.id, teamId);
if (!alert) {
- throw new Error('Alert not found');
+ return res.status(404).json({ error: 'Alert not found' });
}
alert.silenced = undefined;
await alert.save();
diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts
index 9e688eb7..dd484360 100644
--- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts
+++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts
@@ -3604,6 +3604,249 @@ describe('checkAlerts', () => {
// Verify that webhook was called for the alert
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
});
+
+ it('should not fire 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!',
+ },
+ {
+ ServiceName: 'api',
+ Timestamp: eventMs,
+ SeverityText: 'error',
+ Body: 'Oh no! Something went wrong!',
+ },
+ {
+ ServiceName: 'api',
+ Timestamp: eventMs,
+ SeverityText: 'error',
+ Body: 'Oh no! Something went wrong!',
+ },
+ ]);
+
+ const details = await createAlertDetails(
+ team,
+ source,
+ {
+ source: AlertSource.SAVED_SEARCH,
+ channel: {
+ type: 'webhook',
+ webhookId: webhook._id.toString(),
+ },
+ interval: '5m',
+ thresholdType: AlertThresholdType.ABOVE,
+ threshold: 1,
+ savedSearchId: savedSearch.id,
+ },
+ {
+ taskType: AlertTaskType.SAVED_SEARCH,
+ savedSearch,
+ },
+ );
+
+ // Silence the alert until 1 hour from now
+ const alertDoc = await Alert.findById(details.alert.id);
+ alertDoc!.silenced = {
+ at: new Date(),
+ until: new Date(Date.now() + 3600000), // 1 hour from now
+ };
+ await alertDoc!.save();
+
+ // Update the details.alert object to reflect the silenced state
+ // (simulates what would happen if the alert was silenced before task queuing)
+ details.alert.silenced = alertDoc!.silenced;
+
+ // Process the alert - should skip firing because it's silenced
+ await processAlertAtTime(
+ now,
+ details,
+ clickhouseClient,
+ connection.id,
+ alertProvider,
+ teamWebhooksById,
+ );
+
+ // Verify webhook was NOT called
+ expect(slack.postMessageToWebhook).not.toHaveBeenCalled();
+
+ // Verify alert state was still updated
+ expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
+ });
+
+ it('should fire notifications when silenced period 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!',
+ },
+ {
+ ServiceName: 'api',
+ Timestamp: eventMs,
+ SeverityText: 'error',
+ Body: 'Oh no! Something went wrong!',
+ },
+ {
+ ServiceName: 'api',
+ Timestamp: eventMs,
+ SeverityText: 'error',
+ Body: 'Oh no! Something went wrong!',
+ },
+ ]);
+
+ const details = await createAlertDetails(
+ team,
+ source,
+ {
+ source: AlertSource.SAVED_SEARCH,
+ channel: {
+ type: 'webhook',
+ webhookId: webhook._id.toString(),
+ },
+ interval: '5m',
+ thresholdType: AlertThresholdType.ABOVE,
+ threshold: 1,
+ savedSearchId: savedSearch.id,
+ },
+ {
+ taskType: AlertTaskType.SAVED_SEARCH,
+ savedSearch,
+ },
+ );
+
+ // Silence the alert but set expiry to the past
+ const alertDoc = await Alert.findById(details.alert.id);
+ alertDoc!.silenced = {
+ at: new Date(Date.now() - 7200000), // 2 hours ago
+ until: new Date(Date.now() - 3600000), // 1 hour ago (expired)
+ };
+ await alertDoc!.save();
+
+ // Update the details.alert object to reflect the expired silenced state
+ details.alert.silenced = alertDoc!.silenced;
+
+ // Process the alert - should fire because silence has expired
+ await processAlertAtTime(
+ now,
+ details,
+ clickhouseClient,
+ connection.id,
+ alertProvider,
+ teamWebhooksById,
+ );
+
+ // Verify webhook WAS called
+ expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
+ expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
+ });
+
+ it('should fire notifications when alert is unsilenced', 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!',
+ },
+ {
+ ServiceName: 'api',
+ Timestamp: eventMs,
+ SeverityText: 'error',
+ Body: 'Oh no! Something went wrong!',
+ },
+ {
+ ServiceName: 'api',
+ Timestamp: eventMs,
+ SeverityText: 'error',
+ Body: 'Oh no! Something went wrong!',
+ },
+ ]);
+
+ const details = await createAlertDetails(
+ team,
+ source,
+ {
+ source: AlertSource.SAVED_SEARCH,
+ channel: {
+ type: 'webhook',
+ webhookId: webhook._id.toString(),
+ },
+ interval: '5m',
+ thresholdType: AlertThresholdType.ABOVE,
+ threshold: 1,
+ savedSearchId: savedSearch.id,
+ },
+ {
+ taskType: AlertTaskType.SAVED_SEARCH,
+ savedSearch,
+ },
+ );
+
+ // Alert is unsilenced (no silenced field)
+ const alertDoc = await Alert.findById(details.alert.id);
+ alertDoc!.silenced = undefined;
+ await alertDoc!.save();
+
+ // Update the details.alert object to reflect the unsilenced state
+ details.alert.silenced = undefined;
+
+ // Process the alert - should fire normally
+ await processAlertAtTime(
+ now,
+ details,
+ clickhouseClient,
+ connection.id,
+ alertProvider,
+ teamWebhooksById,
+ );
+
+ // Verify webhook WAS called
+ expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
+ expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
+ });
});
describe('getPreviousAlertHistories', () => {
diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts
index 0347c245..bdb8292c 100644
--- a/packages/api/src/tasks/checkAlerts/index.ts
+++ b/packages/api/src/tasks/checkAlerts/index.ts
@@ -101,6 +101,11 @@ const fireChannelEvent = async ({
throw new Error('Team not found');
}
+ // KNOWN LIMITATION: Alert data (including silenced state) is fetched when the
+ // task is queued via AlertProvider, not when it processes. If a user silences
+ // an alert after it's queued but before it processes, this 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()) {
logger.info(
{
diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx
index 6f101d78..e7ab030f 100644
--- a/packages/app/src/AlertsPage.tsx
+++ b/packages/app/src/AlertsPage.tsx
@@ -2,19 +2,35 @@ import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import cx from 'classnames';
-import { formatRelative } from 'date-fns';
+import type { Duration } from 'date-fns';
+import { add, formatRelative } from 'date-fns';
import {
AlertHistory,
AlertSource,
AlertState,
} from '@hyperdx/common-utils/dist/types';
-import { Alert, Badge, Container, Group, Stack, Tooltip } from '@mantine/core';
+import {
+ Alert,
+ Badge,
+ Button,
+ Container,
+ Group,
+ Menu,
+ Stack,
+ Tooltip,
+} from '@mantine/core';
+import { notifications } from '@mantine/notifications';
+import { IconBell } from '@tabler/icons-react';
+import { useQueryClient } from '@tanstack/react-query';
+import { ErrorBoundary } from '@/components/ErrorBoundary';
import { PageHeader } from '@/components/PageHeader';
+import { isAlertSilenceExpired } from './utils/alerts';
import api from './api';
import { withAppNav } from './layout';
import type { AlertsPageItem } from './types';
+import { FormatTime } from './useFormatTime';
import styles from '../styles/AlertsPage.module.scss';
@@ -73,6 +89,179 @@ function AlertHistoryCard({
const HISTORY_ITEMS = 18;
+function AckAlert({ alert }: { alert: AlertsPageItem }) {
+ const queryClient = useQueryClient();
+ const silenceAlert = api.useSilenceAlert();
+ const unsilenceAlert = api.useUnsilenceAlert();
+
+ const mutateOptions = React.useMemo(
+ () => ({
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['alerts'] });
+ },
+ 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,
+ });
+ },
+ }),
+ [queryClient],
+ );
+
+ const handleUnsilenceAlert = React.useCallback(() => {
+ 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) => {
+ const mutedUntil = add(new Date(), duration);
+ silenceAlert.mutate(
+ {
+ alertId: alert._id || '',
+ mutedUntil: mutedUntil.toISOString(),
+ },
+ mutateOptions,
+ );
+ },
+ [alert._id, mutateOptions, silenceAlert],
+ );
+
+ if (alert.silenced?.at) {
+ return (
+