mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
7335a23acd
commit
6ff1ba60bb
13 changed files with 610 additions and 398 deletions
7
.changeset/silly-toes-cough.md
Normal file
7
.changeset/silly-toes-cough.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add alert history + ack to alert editor
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>[]
|
||||
|
|
|
|||
|
|
@ -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'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) {
|
||||
|
|
|
|||
|
|
@ -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 "Below (<)" threshold and a
|
||||
"grouped by" 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 "Below (<)" threshold and a
|
||||
"grouped by" 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
|
||||
|
|
|
|||
|
|
@ -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`],
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
190
packages/app/src/components/alerts/AckAlert.tsx
Normal file
190
packages/app/src/components/alerts/AckAlert.tsx
Normal 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'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;
|
||||
}
|
||||
102
packages/app/src/components/alerts/AlertHistoryCards.tsx
Normal file
102
packages/app/src/components/alerts/AlertHistoryCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue