feat: Add alert history + ack to alert editor (#2123)

## Summary

This PR updates the alert editor forms with 
1. A history of alert states
2. An option to Ack/Silence an alert that is firing

The components and much of the new GET /alert/:id endpoint are shared with the existing alert page functionality.

### Screenshots or video

<img width="799" height="838" alt="Screenshot 2026-04-15 at 10 26 23 AM" src="https://github.com/user-attachments/assets/d1cfc3da-efbf-41a3-83b8-27a2f9e3b760" />
<img width="2107" height="700" alt="Screenshot 2026-04-15 at 10 26 43 AM" src="https://github.com/user-attachments/assets/6884b876-da98-40de-98f7-1f2854def83b" />

### How to test locally or on Vercel

This must be tested locally, since alerts are not supported in the preview environment.

### References



- Linear Issue: Closes HDX-3989
- Related PRs:
This commit is contained in:
Drew Davis 2026-04-16 09:57:23 -04:00 committed by GitHub
parent 7335a23acd
commit 6ff1ba60bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 610 additions and 398 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add alert history + ack to alert editor

View file

@ -302,6 +302,20 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => {
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};
export const getAlertEnhanced = async (
alertId: ObjectId | string,
teamId: ObjectId,
) => {
return Alert.findOne({ _id: alertId, team: teamId }).populate<{
savedSearch: ISavedSearch;
dashboard: IDashboard;
createdBy?: IUser;
silenced?: IAlert['silenced'] & {
by: IUser;
};
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};
export const deleteAlert = async (id: string, teamId: ObjectId) => {
return Alert.deleteOne({
_id: id,

View file

@ -11,7 +11,12 @@ import {
randomMongoId,
RAW_SQL_ALERT_TEMPLATE,
} from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import Alert, {
AlertSource,
AlertState,
AlertThresholdType,
} from '@/models/alert';
import AlertHistory from '@/models/alertHistory';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@ -728,4 +733,84 @@ describe('alerts router', () => {
})
.expect(400);
});
describe('GET /alerts/:id', () => {
it('returns 404 for non-existent alert', async () => {
const fakeId = randomMongoId();
await agent.get(`/alerts/${fakeId}`).expect(404);
});
it('returns alert with empty history when no history exists', 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 res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);
expect(res.body.data._id).toBe(alert.body.data._id);
expect(res.body.data.history).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();
expect(res.body.data.tileId).toBe(dashboard.body.tiles[0].id);
});
it('returns alert with history entries', 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,
lastValues: [{ startTime: now, count: 5 }],
});
await AlertHistory.create({
alert: alert.body.data._id,
createdAt: earlier,
state: AlertState.OK,
counts: 0,
lastValues: [{ startTime: earlier, count: 0 }],
});
const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);
expect(res.body.data._id).toBe(alert.body.data._id);
expect(res.body.data.history).toHaveLength(2);
expect(res.body.data.history[0].state).toBe('ALERT');
expect(res.body.data.history[0].counts).toBe(5);
expect(res.body.data.history[1].state).toBe('OK');
expect(res.body.data.history[1].counts).toBe(0);
});
});
});

View file

@ -1,24 +1,90 @@
import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types';
import type {
AlertApiResponse,
AlertsApiResponse,
AlertsPageItem,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { pick } from 'lodash';
import { ObjectId } from 'mongodb';
import { z } from 'zod';
import { processRequest, validateRequest } from 'zod-express-middleware';
import { getRecentAlertHistoriesBatch } from '@/controllers/alertHistory';
import {
getRecentAlertHistories,
getRecentAlertHistoriesBatch,
} from '@/controllers/alertHistory';
import {
createAlert,
deleteAlert,
getAlertById,
getAlertEnhanced,
getAlertsEnhanced,
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
import { sendJson } from '@/utils/serialization';
import { IAlertHistory } from '@/models/alertHistory';
import { PreSerialized, sendJson } from '@/utils/serialization';
import { alertSchema, objectIdSchema } from '@/utils/zod';
const router = express.Router();
type EnhancedAlert = NonNullable<Awaited<ReturnType<typeof getAlertEnhanced>>>;
const formatAlertResponse = (
alert: EnhancedAlert,
history: Omit<IAlertHistory, 'alert'>[],
): PreSerialized<AlertsPageItem> => {
return {
history,
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
...pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
};
type AlertsExpRes = express.Response<AlertsApiResponse>;
router.get('/', async (req, res: AlertsExpRes, next) => {
try {
@ -39,63 +105,50 @@ router.get('/', async (req, res: AlertsExpRes, next) => {
const data = alerts.map(alert => {
const history = historyMap.get(alert._id.toString()) ?? [];
return {
history,
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
...pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
return formatAlertResponse(alert, history);
});
sendJson(res, { data });
} catch (e) {
next(e);
}
});
type AlertExpRes = express.Response<AlertApiResponse>;
router.get(
'/:id',
validateRequest({
params: z.object({
id: objectIdSchema,
}),
}),
async (req, res: AlertExpRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
return res.sendStatus(403);
}
const alert = await getAlertEnhanced(req.params.id, teamId);
if (!alert) {
return res.sendStatus(404);
}
const history = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: alert.interval,
limit: 20,
});
const data = formatAlertResponse(alert, history);
sendJson(res, { data });
} catch (e) {
next(e);
}
},
);
router.post(
'/',
processRequest({ body: alertSchema }),

View file

@ -9,7 +9,7 @@ type JsonStringifiable = { toJSON(): string };
* toJSON(): string). This allows passing raw Mongoose data to sendJson()
* while keeping type inference from the typed Express response.
*/
type PreSerialized<T> = T extends string
export type PreSerialized<T> = T extends string
? string | JsonStringifiable
: T extends (infer U)[]
? PreSerialized<U>[]

View file

@ -1,26 +1,8 @@
import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import cx from 'classnames';
import type { Duration } from 'date-fns';
import { add, formatRelative } from 'date-fns';
import {
AlertHistory,
AlertSource,
AlertState,
} from '@hyperdx/common-utils/dist/types';
import {
Alert,
Anchor,
Badge,
Button,
Container,
Group,
Menu,
Stack,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { AlertSource, AlertState } from '@hyperdx/common-utils/dist/types';
import { Alert, Anchor, Badge, Container, Group, Stack } from '@mantine/core';
import {
IconAlertTriangle,
IconBell,
@ -31,291 +13,20 @@ import {
IconInfoCircleFilled,
IconTableRow,
} from '@tabler/icons-react';
import { useQueryClient } from '@tanstack/react-query';
import { AckAlert } from '@/components/alerts/AckAlert';
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
import EmptyState from '@/components/EmptyState';
import { ErrorBoundary } from '@/components/Error/ErrorBoundary';
import { PageHeader } from '@/components/PageHeader';
import { useBrandDisplayName } from './theme/ThemeProvider';
import { isAlertSilenceExpired } from './utils/alerts';
import { getWebhookChannelIcon } from './utils/webhookIcons';
import api from './api';
import { withAppNav } from './layout';
import type { AlertsPageItem } from './types';
import { FormatTime } from './useFormatTime';
import styles from '../styles/AlertsPage.module.scss';
function AlertHistoryCard({
history,
alertUrl,
}: {
history: AlertHistory;
alertUrl: string;
}) {
const start = new Date(history.createdAt.toString());
// eslint-disable-next-line no-restricted-syntax
const today = React.useMemo(() => new Date(), []);
const href = React.useMemo(() => {
if (!alertUrl || !history.lastValues?.[0]?.startTime) return null;
// Create time window from alert creation to last recorded value
const to = new Date(history.createdAt).getTime();
const from = new Date(history.lastValues[0].startTime).getTime();
// Construct URL with time range parameters
const url = new URL(alertUrl, window.location.origin);
url.searchParams.set('from', from.toString());
url.searchParams.set('to', to.toString());
url.searchParams.set('isLive', 'false');
return url.pathname + url.search;
}, [history, alertUrl]);
const content = (
<div
className={cx(
styles.historyCard,
history.state === AlertState.OK ? styles.ok : styles.alarm,
href && styles.clickable,
)}
/>
);
return (
<Tooltip
label={`${history.counts ?? 0} alerts ${formatRelative(start, today)}`}
color="dark"
withArrow
>
{href ? (
<a href={href} className={styles.historyCardLink}>
{content}
</a>
) : (
content
)}
</Tooltip>
);
}
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) => {
// eslint-disable-next-line no-restricted-syntax
const mutedUntil = add(new Date(), duration);
silenceAlert.mutate(
{
alertId: alert._id || '',
mutedUntil: mutedUntil.toISOString(),
},
mutateOptions,
);
},
[alert._id, mutateOptions, silenceAlert],
);
if (alert.silenced?.at) {
return (
<ErrorBoundary message="Failed to load alert acknowledgment menu">
<Menu>
<Menu.Target>
<Button
size="compact-sm"
variant="primary"
color={
isNoLongerMuted
? 'var(--color-bg-warning)'
: 'var(--color-bg-success)'
}
leftSection={<IconBell size={16} />}
>
Ack&apos;d
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label py={6}>
Acknowledged{' '}
{alert.silenced?.by ? (
<>
by <strong>{alert.silenced?.by}</strong>
</>
) : null}{' '}
on <br />
<FormatTime value={alert.silenced?.at} />
.<br />
</Menu.Label>
<Menu.Label py={6}>
{isNoLongerMuted ? (
'Alert resumed.'
) : (
<>
Resumes <FormatTime value={alert.silenced.until} />.
</>
)}
</Menu.Label>
<Menu.Item
lh="1"
py={8}
color="orange"
onClick={handleUnsilenceAlert}
disabled={unsilenceAlert.isPending}
>
{isNoLongerMuted ? 'Unacknowledge' : 'Resume alert'}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ErrorBoundary>
);
}
if (alert.state === 'ALERT') {
return (
<ErrorBoundary message="Failed to load alert acknowledgment menu">
<Menu disabled={silenceAlert.isPending}>
<Menu.Target>
<Button size="compact-sm" variant="secondary">
Ack
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label lh="1" py={6}>
Acknowledge and silence for
</Menu.Label>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
minutes: 30,
})
}
>
30 minutes
</Menu.Item>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
hours: 1,
})
}
>
1 hour
</Menu.Item>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
hours: 6,
})
}
>
6 hours
</Menu.Item>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
hours: 24,
})
}
>
24 hours
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ErrorBoundary>
);
}
return null;
}
function AlertHistoryCardList({
history,
alertUrl,
}: {
history: AlertHistory[];
alertUrl: string;
}) {
const items = React.useMemo(() => {
if (history.length < HISTORY_ITEMS) {
return history;
}
return history.slice(0, HISTORY_ITEMS);
}, [history]);
const paddingItems = React.useMemo(() => {
if (history.length > HISTORY_ITEMS) {
return [];
}
return new Array(HISTORY_ITEMS - history.length).fill(null);
}, [history]);
return (
<div className={styles.historyCardWrapper}>
{paddingItems.map((_, index) => (
<Tooltip label="No data" withArrow key={index}>
<div className={styles.historyCard} />
</Tooltip>
))}
{items
.slice()
.reverse()
.map((history, index) => (
<AlertHistoryCard key={index} history={history} alertUrl={alertUrl} />
))}
</div>
);
}
function AlertDetails({ alert }: { alert: AlertsPageItem }) {
const alertName = React.useMemo(() => {
if (alert.source === AlertSource.TILE && alert.dashboard) {

View file

@ -16,9 +16,9 @@ import {
validateAlertScheduleOffsetMinutes,
zAlertChannel,
} from '@hyperdx/common-utils/dist/types';
import { Alert as MantineAlert, TextInput } from '@mantine/core';
import {
Accordion,
Alert as MantineAlert,
Box,
Button,
Group,
@ -30,6 +30,7 @@ import {
Stack,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
@ -54,6 +55,8 @@ import {
import { AlertPreviewChart } from './components/AlertPreviewChart';
import { AlertChannelForm } from './components/Alerts';
import { AckAlert } from './components/alerts/AckAlert';
import { AlertHistoryCardList } from './components/alerts/AlertHistoryCards';
import { AlertScheduleFields } from './components/AlertScheduleFields';
import { getStoredLanguage } from './components/SearchInput/SearchWhereInput';
import { getWebhookChannelIcon } from './utils/webhookIcons';
@ -145,6 +148,9 @@ const AlertForm = ({
);
const intervalLabel = ALERT_INTERVAL_OPTIONS[interval ?? '5m'];
const { data: alertData } = api.useAlert(defaultValues?.id);
const alert = alertData?.data;
return (
<form
onSubmit={handleSubmit(data =>
@ -158,8 +164,8 @@ const AlertForm = ({
),
)}
>
<Stack gap="xs">
<Paper px="md" py="sm" radius="xs">
<Paper px="sm" py="xs" radius="xs">
<Stack gap="xs">
<Text size="xxs" opacity={0.5}>
Trigger
</Text>
@ -234,27 +240,56 @@ const AlertForm = ({
disableKeywordAutocomplete
size="xs"
/>
</Paper>
<Paper px="md" py="sm" radius="xs">
<Text size="xxs" opacity={0.5} mb={4}>
Send to
</Text>
<AlertChannelForm control={control} type={channelType} />
{groupBy && thresholdType === AlertThresholdType.BELOW && (
<MantineAlert
icon={<IconInfoCircleFilled size={16} />}
bg="dark"
py="xs"
>
<Text size="sm" opacity={0.7}>
Warning: Alerts with a &quot;Below (&lt;)&quot; threshold and a
&quot;grouped by&quot; value will not alert for periods with no
data for a group.
</Text>
</MantineAlert>
)}
</Stack>
</Paper>
{(defaultValues?.createdBy || alert) && (
<Paper px="md" py="sm" radius="xs" mt="sm">
<Group justify="space-between">
{defaultValues?.createdBy && (
<Box>
<Text size="xxs" opacity={0.5} mb={4}>
Created by
</Text>
<Text size="sm" opacity={0.8}>
{defaultValues.createdBy.name ||
defaultValues.createdBy.email}
</Text>
{defaultValues.createdBy.name && (
<Text size="xs" opacity={0.6}>
{defaultValues.createdBy.email}
</Text>
)}
</Box>
)}
{alert && (
<Group>
{alert.history.length > 0 && (
<AlertHistoryCardList history={alert.history} />
)}
<AckAlert alert={alert} />
</Group>
)}
</Group>
</Paper>
{groupBy && thresholdType === AlertThresholdType.BELOW && (
<MantineAlert
icon={<IconInfoCircleFilled size={16} />}
bg="dark"
py="xs"
>
<Text size="sm" opacity={0.7}>
Warning: Alerts with a &quot;Below (&lt;)&quot; threshold and a
&quot;grouped by&quot; value will not alert for periods with no
data for a group.
</Text>
</MantineAlert>
)}
</Stack>
)}
<Accordion defaultValue={'chart'} mt="sm" mx={-16}>
<Accordion.Item value="chart">
@ -279,22 +314,6 @@ const AlertForm = ({
</Accordion.Item>
</Accordion>
{defaultValues?.createdBy && (
<Paper px="md" py="sm" radius="xs" mt="sm">
<Text size="xxs" opacity={0.5} mb={4}>
Created by
</Text>
<Text size="sm" opacity={0.8}>
{defaultValues.createdBy.name || defaultValues.createdBy.email}
</Text>
{defaultValues.createdBy.name && (
<Text size="xs" opacity={0.6}>
{defaultValues.createdBy.email}
</Text>
)}
</Paper>
)}
<Group mt="lg" justify="space-between" gap="xs">
<div>
{defaultValues && (
@ -421,6 +440,7 @@ export const DBSearchPageAlertModal = ({
});
}
} catch (error) {
console.error('Error creating/updating alert:', error);
notifications.show({
color: 'red',
message: `Something went wrong. Please contact ${brandName} team.`,
@ -440,6 +460,7 @@ export const DBSearchPageAlertModal = ({
autoClose: 5000,
});
} catch (error) {
console.error('Failed to delete alert:', error);
notifications.show({
color: 'red',
message: `Something went wrong. Please contact ${brandName} team.`,
@ -457,7 +478,6 @@ export const DBSearchPageAlertModal = ({
onClose={onClose}
size="xl"
withCloseButton={false}
zIndex={9999}
>
<Box pos="relative">
<LoadingOverlay

View file

@ -3,6 +3,7 @@ import type { HTTPError, Options, ResponsePromise } from 'ky';
import ky from 'ky-universal';
import type {
Alert,
AlertApiResponse,
AlertsApiResponse,
InstallationApiResponse,
MeApiResponse,
@ -234,12 +235,22 @@ const api = {
}).json<PresetDashboardFilter>(),
});
},
getAlertsQueryKey: () => ['alerts'] as const,
getAlertQueryKey: (alertId: string | undefined) =>
['alert', alertId] as const,
useAlerts() {
return useQuery({
queryKey: [`alerts`],
queryKey: api.getAlertsQueryKey(),
queryFn: () => hdxServer(`alerts`).json<AlertsApiResponse>(),
});
},
useAlert(alertId: string | undefined) {
return useQuery({
queryKey: api.getAlertQueryKey(alertId),
queryFn: () => hdxServer(`alerts/${alertId}`).json<AlertApiResponse>(),
enabled: alertId != null,
});
},
useServices() {
return useQuery({
queryKey: [`services`],

View file

@ -27,6 +27,7 @@ export type SavedChartConfigWithSelectArray = Omit<
export type ChartEditorFormState = Partial<BuilderSavedChartConfig> &
Partial<Omit<RawSqlSavedChartConfig, 'configType'>> & {
alert?: BuilderSavedChartConfig['alert'] & {
id?: string;
createdBy?: AlertWithCreatedBy['createdBy'];
};
series: SavedChartConfigWithSelectArray['select'];

View file

@ -24,7 +24,10 @@ import {
IconTrash,
} from '@tabler/icons-react';
import api from '@/api';
import { AlertChannelForm } from '@/components/Alerts';
import { AckAlert } from '@/components/alerts/AckAlert';
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
import { AlertScheduleFields } from '@/components/AlertScheduleFields';
import { ChartEditorFormState } from '@/components/ChartEditor/types';
import { optionsToSelectData } from '@/utils';
@ -66,11 +69,14 @@ export function TileAlertEditor({
? TILE_ALERT_INTERVAL_OPTIONS[alert.interval]
: undefined;
const { data: alertData } = api.useAlert(alert.id);
const alertItem = alertData?.data;
return (
<Paper data-testid="alert-details">
<Group justify="space-between" px="sm" pt="sm" pb={opened ? 0 : 'sm'}>
<Group justify="space-between" px="sm" pt="sm" pb="sm">
<UnstyledButton onClick={toggle}>
<Group gap="xs" mb="xs">
<Group gap="xs">
<IconChevronDown
size={14}
style={{
@ -109,17 +115,23 @@ export function TileAlertEditor({
</Group>
</Group>
</UnstyledButton>
<Tooltip label="Remove alert">
<ActionIcon
variant="danger"
color="red"
size="sm"
onClick={onRemove}
data-testid="remove-alert-button"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
<Group gap="xs">
{alertItem && alertItem.history.length > 0 && (
<AlertHistoryCardList history={alertItem.history} />
)}
{alertItem && <AckAlert alert={alertItem} />}
<Tooltip label="Remove alert">
<ActionIcon
variant="danger"
color="red"
size="sm"
onClick={onRemove}
data-testid="remove-alert-button"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Collapse expanded={opened}>
<Box px="sm" pb="sm">

View file

@ -0,0 +1,190 @@
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 { 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';
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,
});
},
}),
[queryClient, alert._id],
);
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) => {
// eslint-disable-next-line no-restricted-syntax
const mutedUntil = add(new Date(), duration);
silenceAlert.mutate(
{
alertId: alert._id || '',
mutedUntil: mutedUntil.toISOString(),
},
mutateOptions,
);
},
[alert._id, mutateOptions, silenceAlert],
);
if (alert.silenced?.at) {
return (
<ErrorBoundary message="Failed to load alert acknowledgment menu">
<Menu>
<Menu.Target>
<Button
size="compact-sm"
variant="primary"
color={
isNoLongerMuted
? 'var(--color-bg-warning)'
: 'var(--color-bg-success)'
}
leftSection={<IconBell size={16} />}
>
Ack&apos;d
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label py={6}>
Acknowledged{' '}
{alert.silenced?.by ? (
<>
by <strong>{alert.silenced?.by}</strong>
</>
) : null}{' '}
on <br />
<FormatTime value={alert.silenced?.at} />
.<br />
</Menu.Label>
<Menu.Label py={6}>
{isNoLongerMuted ? (
'Alert resumed.'
) : (
<>
Resumes <FormatTime value={alert.silenced.until} />.
</>
)}
</Menu.Label>
<Menu.Item
lh="1"
py={8}
color="orange"
onClick={handleUnsilenceAlert}
disabled={unsilenceAlert.isPending}
>
{isNoLongerMuted ? 'Unacknowledge' : 'Resume alert'}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ErrorBoundary>
);
}
if (alert.state === 'ALERT') {
return (
<ErrorBoundary message="Failed to load alert acknowledgment menu">
<Menu disabled={silenceAlert.isPending}>
<Menu.Target>
<Button size="compact-sm" variant="secondary">
Ack
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label lh="1" py={6}>
Acknowledge and silence for
</Menu.Label>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
minutes: 30,
})
}
>
30 minutes
</Menu.Item>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
hours: 1,
})
}
>
1 hour
</Menu.Item>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
hours: 6,
})
}
>
6 hours
</Menu.Item>
<Menu.Item
lh="1"
py={8}
onClick={() =>
handleSilenceAlert({
hours: 24,
})
}
>
24 hours
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ErrorBoundary>
);
}
return null;
}

View file

@ -0,0 +1,102 @@
import * as React from 'react';
import cx from 'classnames';
import { formatRelative } from 'date-fns';
import { AlertHistory, AlertState } from '@hyperdx/common-utils/dist/types';
import { Tooltip } from '@mantine/core';
import styles from '../../../styles/AlertsPage.module.scss';
const HISTORY_ITEMS = 18;
function AlertHistoryCard({
history,
alertUrl,
}: {
history: AlertHistory;
alertUrl?: string;
}) {
const start = new Date(history.createdAt.toString());
// eslint-disable-next-line no-restricted-syntax
const today = React.useMemo(() => new Date(), []);
const href = React.useMemo(() => {
if (!alertUrl || !history.lastValues?.[0]?.startTime) return null;
// Create time window from alert creation to last recorded value
const to = new Date(history.createdAt).getTime();
const from = new Date(history.lastValues[0].startTime).getTime();
// Construct URL with time range parameters
const url = new URL(alertUrl, window.location.origin);
url.searchParams.set('from', from.toString());
url.searchParams.set('to', to.toString());
url.searchParams.set('isLive', 'false');
return url.pathname + url.search;
}, [history, alertUrl]);
const content = (
<div
className={cx(
styles.historyCard,
history.state === AlertState.OK ? styles.ok : styles.alarm,
href && styles.clickable,
)}
/>
);
return (
<Tooltip
label={`${history.counts ?? 0} alerts ${formatRelative(start, today)}`}
color="dark"
withArrow
>
{href ? (
<a href={href} className={styles.historyCardLink}>
{content}
</a>
) : (
content
)}
</Tooltip>
);
}
export function AlertHistoryCardList({
history,
alertUrl,
}: {
history: AlertHistory[];
alertUrl?: string;
}) {
const items = React.useMemo(() => {
if (history.length < HISTORY_ITEMS) {
return history;
}
return history.slice(0, HISTORY_ITEMS);
}, [history]);
const paddingItems = React.useMemo(() => {
if (history.length > HISTORY_ITEMS) {
return [];
}
return new Array(HISTORY_ITEMS - history.length).fill(null);
}, [history]);
return (
<div className={styles.historyCardWrapper}>
{paddingItems.map((_, index) => (
<Tooltip label="No data" withArrow key={index}>
<div className={styles.historyCard} />
</Tooltip>
))}
{items
.slice()
.reverse()
.map((history, index) => (
<AlertHistoryCard key={index} history={history} alertUrl={alertUrl} />
))}
</div>
);
}

View file

@ -1319,6 +1319,12 @@ export const AlertsApiResponseSchema = z.object({
export type AlertsApiResponse = z.infer<typeof AlertsApiResponseSchema>;
export const AlertApiResponseSchema = z.object({
data: AlertsPageItemSchema,
});
export type AlertApiResponse = z.infer<typeof AlertApiResponseSchema>;
// Webhooks
export const WebhooksApiResponseSchema = z.object({
data: z.array(WebhookSchema),