From 6ff1ba60bb0f08e14e7151855fab8086ec7157c4 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Thu, 16 Apr 2026 09:57:23 -0400 Subject: [PATCH] feat: Add alert history + ack to alert editor (#2123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Screenshot 2026-04-15 at 10 26 23 AM Screenshot 2026-04-15 at 10 26 43 AM ### 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: --- .changeset/silly-toes-cough.md | 7 + packages/api/src/controllers/alerts.ts | 14 + .../src/routers/api/__tests__/alerts.test.ts | 87 ++++- packages/api/src/routers/api/alerts.ts | 159 ++++++---- packages/api/src/utils/serialization.ts | 2 +- packages/app/src/AlertsPage.tsx | 297 +----------------- packages/app/src/DBSearchPageAlertModal.tsx | 92 +++--- packages/app/src/api.ts | 13 +- .../app/src/components/ChartEditor/types.ts | 1 + .../DBEditTimeChartForm/TileAlertEditor.tsx | 38 ++- .../app/src/components/alerts/AckAlert.tsx | 190 +++++++++++ .../components/alerts/AlertHistoryCards.tsx | 102 ++++++ packages/common-utils/src/types.ts | 6 + 13 files changed, 610 insertions(+), 398 deletions(-) create mode 100644 .changeset/silly-toes-cough.md create mode 100644 packages/app/src/components/alerts/AckAlert.tsx create mode 100644 packages/app/src/components/alerts/AlertHistoryCards.tsx diff --git a/.changeset/silly-toes-cough.md b/.changeset/silly-toes-cough.md new file mode 100644 index 00000000..6c9dfdc9 --- /dev/null +++ b/.changeset/silly-toes-cough.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: Add alert history + ack to alert editor diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 208e1f14..48ba7602 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -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, diff --git a/packages/api/src/routers/api/__tests__/alerts.test.ts b/packages/api/src/routers/api/__tests__/alerts.test.ts index 857a3a99..06ac4e1a 100644 --- a/packages/api/src/routers/api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -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); + }); + }); }); diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index d1e2e985..148a06d8 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -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>>; + +const formatAlertResponse = ( + alert: EnhancedAlert, + history: Omit[], +): PreSerialized => { + 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; 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; +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 }), diff --git a/packages/api/src/utils/serialization.ts b/packages/api/src/utils/serialization.ts index ab79dfc0..8c536f5e 100644 --- a/packages/api/src/utils/serialization.ts +++ b/packages/api/src/utils/serialization.ts @@ -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 extends string +export type PreSerialized = T extends string ? string | JsonStringifiable : T extends (infer U)[] ? PreSerialized[] diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 79b7e046..73b075a4 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -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 = ( -
- ); - - return ( - - {href ? ( - - {content} - - ) : ( - content - )} - - ); -} - -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 ( - - - - - - - - Acknowledged{' '} - {alert.silenced?.by ? ( - <> - by {alert.silenced?.by} - - ) : null}{' '} - on
- - .
-
- - - {isNoLongerMuted ? ( - 'Alert resumed.' - ) : ( - <> - Resumes . - - )} - - - {isNoLongerMuted ? 'Unacknowledge' : 'Resume alert'} - -
-
-
- ); - } - - 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 - - - - - ); - } - - 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 ( -
- {paddingItems.map((_, index) => ( - -
- - ))} - {items - .slice() - .reverse() - .map((history, index) => ( - - ))} -
- ); -} - function AlertDetails({ alert }: { alert: AlertsPageItem }) { const alertName = React.useMemo(() => { if (alert.source === AlertSource.TILE && alert.dashboard) { diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index 892bfd0e..f653cc87 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -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 (
@@ -158,8 +164,8 @@ const AlertForm = ({ ), )} > - - + + Trigger @@ -234,27 +240,56 @@ const AlertForm = ({ disableKeywordAutocomplete size="xs" /> - - Send to + {groupBy && thresholdType === AlertThresholdType.BELOW && ( + } + bg="dark" + py="xs" + > + + Warning: Alerts with a "Below (<)" threshold and a + "grouped by" value will not alert for periods with no + data for a group. + + + )} + + + + {(defaultValues?.createdBy || alert) && ( + + + {defaultValues?.createdBy && ( + + + Created by + + + {defaultValues.createdBy.name || + defaultValues.createdBy.email} + + {defaultValues.createdBy.name && ( + + {defaultValues.createdBy.email} + + )} + + )} + {alert && ( + + {alert.history.length > 0 && ( + + )} + + + )} + - {groupBy && thresholdType === AlertThresholdType.BELOW && ( - } - bg="dark" - py="xs" - > - - Warning: Alerts with a "Below (<)" threshold and a - "grouped by" value will not alert for periods with no - data for a group. - - - )} - + )} @@ -279,22 +314,6 @@ const AlertForm = ({ - {defaultValues?.createdBy && ( - - - Created by - - - {defaultValues.createdBy.name || defaultValues.createdBy.email} - - {defaultValues.createdBy.name && ( - - {defaultValues.createdBy.email} - - )} - - )} -
{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} > (), }); }, + getAlertsQueryKey: () => ['alerts'] as const, + getAlertQueryKey: (alertId: string | undefined) => + ['alert', alertId] as const, useAlerts() { return useQuery({ - queryKey: [`alerts`], + queryKey: api.getAlertsQueryKey(), queryFn: () => hdxServer(`alerts`).json(), }); }, + useAlert(alertId: string | undefined) { + return useQuery({ + queryKey: api.getAlertQueryKey(alertId), + queryFn: () => hdxServer(`alerts/${alertId}`).json(), + enabled: alertId != null, + }); + }, useServices() { return useQuery({ queryKey: [`services`], diff --git a/packages/app/src/components/ChartEditor/types.ts b/packages/app/src/components/ChartEditor/types.ts index 3e0cabc0..8bc14b78 100644 --- a/packages/app/src/components/ChartEditor/types.ts +++ b/packages/app/src/components/ChartEditor/types.ts @@ -27,6 +27,7 @@ export type SavedChartConfigWithSelectArray = Omit< export type ChartEditorFormState = Partial & Partial> & { alert?: BuilderSavedChartConfig['alert'] & { + id?: string; createdBy?: AlertWithCreatedBy['createdBy']; }; series: SavedChartConfigWithSelectArray['select']; diff --git a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx index dd0b796b..19335df3 100644 --- a/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/TileAlertEditor.tsx @@ -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 ( - + - + - - - - - + + {alertItem && alertItem.history.length > 0 && ( + + )} + {alertItem && } + + + + + + diff --git a/packages/app/src/components/alerts/AckAlert.tsx b/packages/app/src/components/alerts/AckAlert.tsx new file mode 100644 index 00000000..44bfdf4d --- /dev/null +++ b/packages/app/src/components/alerts/AckAlert.tsx @@ -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 ( + + + + + + + + Acknowledged{' '} + {alert.silenced?.by ? ( + <> + by {alert.silenced?.by} + + ) : null}{' '} + on
+ + .
+
+ + + {isNoLongerMuted ? ( + 'Alert resumed.' + ) : ( + <> + Resumes . + + )} + + + {isNoLongerMuted ? 'Unacknowledge' : 'Resume alert'} + +
+
+
+ ); + } + + 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 + + + + + ); + } + + return null; +} diff --git a/packages/app/src/components/alerts/AlertHistoryCards.tsx b/packages/app/src/components/alerts/AlertHistoryCards.tsx new file mode 100644 index 00000000..3248b826 --- /dev/null +++ b/packages/app/src/components/alerts/AlertHistoryCards.tsx @@ -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 = ( +
+ ); + + return ( + + {href ? ( + + {content} + + ) : ( + content + )} + + ); +} + +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 ( +
+ {paddingItems.map((_, index) => ( + +
+ + ))} + {items + .slice() + .reverse() + .map((history, index) => ( + + ))} +
+ ); +} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 01fefd75..6dc350a7 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1319,6 +1319,12 @@ export const AlertsApiResponseSchema = z.object({ export type AlertsApiResponse = z.infer; +export const AlertApiResponseSchema = z.object({ + data: AlertsPageItemSchema, +}); + +export type AlertApiResponse = z.infer; + // Webhooks export const WebhooksApiResponseSchema = z.object({ data: z.array(WebhookSchema),