feat: Alert UI for saved search page (#559)

Allow users to manage alerts for a saved search:
- Create new alert
- Update existing alert
- Delete alert

![Screenshot 2025-01-18 at 9 49 14 PM](https://github.com/user-attachments/assets/e7758f87-0c66-4654-90e9-9b123ef3cac7)
This commit is contained in:
Ernest Iliiasov 2025-01-23 23:16:17 -06:00 committed by GitHub
parent 678c2ca70e
commit 418c293836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 569 additions and 116 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": patch
---
feat: extract AlertChannelType to its own schema

View file

@ -1,14 +1,27 @@
import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types';
import { groupBy } from 'lodash';
import { z } from 'zod';
import Alert from '@/models/alert';
import { SavedSearch } from '@/models/savedSearch';
type SavedSearchWithoutId = Omit<z.infer<typeof SavedSearchSchema>, 'id'>;
export function getSavedSearches(teamId: string) {
return SavedSearch.find({
team: teamId,
});
export async function getSavedSearches(teamId: string) {
const savedSearches = await SavedSearch.find({ team: teamId });
const alerts = await Alert.find(
{ team: teamId, savedSearch: { $exists: true, $ne: null } },
{ __v: 0 },
);
const alertsBySavedSearchId = groupBy(alerts, 'savedSearch');
return savedSearches.map(savedSearch => ({
...savedSearch.toJSON(),
alerts: alertsBySavedSearchId[savedSearch._id.toString()]
?.map(alert => alert.toJSON())
.map(({ _id, ...alert }) => ({ id: _id, ...alert })), // Remap _id to id
}));
}
export function getSavedSearch(teamId: string, savedSearchId: string) {

View file

@ -76,6 +76,7 @@
"react-error-boundary": "^3.1.4",
"react-grid-layout": "^1.3.4",
"react-hook-form": "^7.43.8",
"react-hook-form-mantine": "^3.1.3",
"react-hotkeys-hook": "^4.3.7",
"react-json-tree": "^0.17.0",
"react-markdown": "^8.0.4",

View file

@ -37,8 +37,9 @@ import {
Paper,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { useDebouncedCallback } from '@mantine/hooks';
import { useDebouncedCallback, useDisclosure } from '@mantine/hooks';
import { useIsFetching } from '@tanstack/react-query';
import { useTimeChartSettings } from '@/ChartUtils';
@ -58,6 +59,7 @@ import { SourceSelectControlled } from '@/components/SourceSelect';
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import { TimePicker } from '@/components/TimePicker';
import WhereLanguageControlled from '@/components/WhereLanguageControlled';
import { IS_DEV } from '@/config';
import { IS_LOCAL_MODE } from '@/config';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useExplainQuery } from '@/hooks/useExplainQuery';
@ -79,6 +81,8 @@ import {
import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
import { usePrevious } from '@/utils';
import { DBSearchPageAlertModal } from './DBSearchPageAlertModal';
type SearchConfig = {
select?: string | null;
source?: string | null;
@ -756,8 +760,18 @@ function DBSearchPage() {
setValue('orderBy', defaultOrderBy);
}, [inputSource, inputSourceObj, defaultOrderBy]);
const [isAlertModalOpen, { open: openAlertModal, close: closeAlertModal }] =
useDisclosure();
return (
<Flex direction="column" h="100vh" style={{ overflow: 'hidden' }}>
{IS_DEV && isAlertModalOpen && (
<DBSearchPageAlertModal
id={savedSearch?.id ?? ''}
open={isAlertModalOpen}
onClose={closeAlertModal}
/>
)}
<OnboardingModal />
<form
onSubmit={e => {
@ -828,6 +842,27 @@ function DBSearchPage() {
>
Save
</Button>
{IS_DEV && (
<Tooltip
label={
savedSearchId
? 'Manage or create alerts for this search'
: 'Save this view to create alerts'
}
color="dark"
>
<Button
variant="outline"
color="dark.2"
px="xs"
size="xs"
onClick={openAlertModal}
disabled={!savedSearchId}
>
Alerts
</Button>
</Tooltip>
)}
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {

View file

@ -0,0 +1,323 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { NativeSelect, NumberInput } from 'react-hook-form-mantine';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
type Alert,
AlertIntervalSchema,
AlertSource,
AlertThresholdType,
zAlertChannel,
} from '@hyperdx/common-utils/dist/types';
import {
Box,
Button,
Group,
LoadingOverlay,
Modal,
Paper,
Stack,
Tabs,
Text,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import { useSavedSearch } from '@/savedSearch';
import { useSource } from '@/source';
import {
ALERT_CHANNEL_OPTIONS,
ALERT_INTERVAL_OPTIONS,
ALERT_THRESHOLD_TYPE_OPTIONS,
} from '@/utils/alerts';
import { AlertChannelForm } from './components/Alerts';
import api from './api';
const SavedSearchAlertFormSchema = z
.object({
interval: AlertIntervalSchema,
threshold: z.number().int().min(1),
thresholdType: z.nativeEnum(AlertThresholdType),
channel: zAlertChannel,
})
.passthrough();
const CHANNEL_ICONS = {
webhook: <i className="bi bi-slack fs-7 text-slate-400" />,
};
const optionsToSelectData = (options: Record<string, string>) =>
Object.entries(options).map(([value, label]) => ({ value, label }));
const AlertForm = ({
sourceId,
defaultValues,
loading,
deleteLoading,
onDelete,
onSubmit,
onClose,
}: {
sourceId?: string;
defaultValues?: null | Alert;
loading?: boolean;
deleteLoading?: boolean;
onDelete: (id: string) => void;
onSubmit: (data: Alert) => void;
onClose: () => void;
}) => {
const { data: source } = useSource({ id: sourceId });
const databaseName = source?.from.databaseName;
const tableName = source?.from.tableName;
const connectionId = source?.connection;
const { control, handleSubmit, watch } = useForm<Alert>({
defaultValues: defaultValues || {
interval: '5m',
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: '',
},
},
resolver: zodResolver(SavedSearchAlertFormSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="xs">
<Paper px="md" py="sm" bg="dark.6" radius="xs">
<Text size="xxs" opacity={0.5}>
Trigger
</Text>
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
name={`thresholdType`}
control={control}
/>
<NumberInput
min={1}
size="xs"
w={80}
control={control}
name={`threshold`}
/>
<Text size="sm" opacity={0.7}>
lines appear within
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`channel.type`}
control={control}
/>
</Group>
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
grouped by
</Text>
<SQLInlineEditorControlled
database={databaseName}
table={tableName}
control={control}
connectionId={connectionId}
name={`groupBy`}
placeholder="SQL Columns"
disableKeywordAutocomplete
size="xs"
/>
</Paper>
<Paper px="md" py="sm" bg="dark.6" radius="xs">
<Text size="xxs" opacity={0.5} mb={4}>
Send to
</Text>
<AlertChannelForm control={control} type={watch('channel.type')} />
</Paper>
</Stack>
<Group mt="lg" justify="space-between" gap="xs">
<div>
{defaultValues && (
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={() => onDelete(defaultValues.id!)}
loading={deleteLoading}
>
Delete Alert
</Button>
)}
</div>
<Group gap="xs">
<Button variant="light" color="gray" onClick={onClose}>
Cancel
</Button>
<Button variant="light" type="submit" loading={loading}>
{defaultValues ? 'Save Alert' : 'Create Alert'}
</Button>
</Group>
</Group>
</form>
);
};
export const DBSearchPageAlertModal = ({
id,
onClose,
open,
}: {
id: string;
onClose: () => void;
open: boolean;
}) => {
const createAlert = api.useCreateAlert();
const updateAlert = api.useUpdateAlert();
const deleteAlert = api.useDeleteAlert();
const { data: savedSearch, isLoading } = useSavedSearch({ id });
const [activeIndex, setActiveIndex] = React.useState<'stage' | `${number}`>(
'stage',
);
const setTab = (value: string | null) => {
if (value === null) {
return;
} else if (value === 'stage') {
setActiveIndex(value);
} else {
setActiveIndex(`${parseInt(value)}`);
}
};
const onSubmit = async (data: Alert) => {
try {
// Create new alert
if (activeIndex === 'stage') {
await createAlert.mutate({
...data,
source: AlertSource.SAVED_SEARCH,
savedSearchId: id,
});
} else if (data.id) {
// Update existing alert
await updateAlert.mutate({
...data,
id: data.id,
source: AlertSource.SAVED_SEARCH,
savedSearchId: id,
});
} else {
return;
}
notifications.show({
color: 'green',
message: `Alert ${activeIndex === 'stage' ? 'created' : 'updated'}!`,
autoClose: 5000,
});
} catch (error) {
notifications.show({
color: 'red',
message: 'Something went wrong. Please contact HyperDX team.',
autoClose: 5000,
});
}
onClose();
};
const onDelete = async (id: string) => {
try {
await deleteAlert.mutate(id);
notifications.show({
color: 'green',
message: 'Alert deleted!',
autoClose: 5000,
});
} catch (error) {
notifications.show({
color: 'red',
message: 'Something went wrong. Please contact HyperDX team.',
autoClose: 5000,
});
}
onClose();
};
return (
<Modal opened={open} onClose={onClose} size="xl" withCloseButton={false}>
<Box pos="relative">
<LoadingOverlay
visible={isLoading}
m={-16}
zIndex={1000}
overlayProps={{
radius: 'sm',
}}
loaderProps={{ type: 'dots' }}
/>
<Stack gap={0} mb="md">
<Text c="dark.1" size="sm">
Alerts for <strong>{savedSearch?.name}</strong>
</Text>
<Text c="dark.2" size="xxs">
{savedSearch?.where}
</Text>
</Stack>
<Tabs value={activeIndex} onChange={setTab} mb="xs">
<Tabs.List>
{(savedSearch?.alerts || []).map((alert, index) => (
<Tabs.Tab key={alert.id} value={`${index}`}>
<Group gap="xs">
{CHANNEL_ICONS[alert.channel.type]} Alert {index + 1}
</Group>
</Tabs.Tab>
))}
<Tabs.Tab value="stage">
<Group gap={4}>
<i
className="bi bi-plus fs-5 text-slate-400"
style={{ marginLeft: -8 }}
/>
New Alert
</Group>
</Tabs.Tab>
</Tabs.List>
</Tabs>
<AlertForm
sourceId={savedSearch?.source}
key={activeIndex}
defaultValues={
activeIndex === 'stage'
? null
: savedSearch?.alerts?.[parseInt(activeIndex)]
}
onSubmit={onSubmit}
onDelete={onDelete}
deleteLoading={deleteAlert.isPending}
onClose={onClose}
loading={updateAlert.isPending || createAlert.isPending}
/>
</Box>
</Modal>
);
};

View file

@ -2,6 +2,7 @@ import React from 'react';
import Router from 'next/router';
import type { HTTPError, Options, ResponsePromise } from 'ky';
import ky from 'ky-universal';
import type { Alert } from '@hyperdx/common-utils/dist/types';
import type {
InfiniteData,
QueryKey,
@ -12,10 +13,6 @@ import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { IS_LOCAL_MODE } from './config';
import type {
AlertChannel,
AlertInterval,
AlertSource,
AlertType,
ChartSeries,
LogView,
MetricsDataType,
@ -23,23 +20,6 @@ import type {
Session,
} from './types';
type ApiAlertInput = {
channel: AlertChannel;
interval: AlertInterval;
threshold: number;
type: AlertType;
source: AlertSource;
groupBy?: string;
logViewId?: string;
dashboardId?: string;
chartId?: string;
};
type ApiAlertAckInput = {
alertId: string;
mutedUntil: Date;
};
type ServicesResponse = {
data: Record<
string,
@ -70,6 +50,8 @@ type MultiSeriesChartResponse = {
}[];
};
type ApiAlertInput = Alert;
const getEnrichedSeries = (series: ChartSeries[]) =>
series
.filter(s => {
@ -493,6 +475,32 @@ const api = {
...options,
});
},
useCreateAlert() {
return useMutation<any, Error, ApiAlertInput>({
mutationFn: async alert =>
server('alerts', {
method: 'POST',
json: alert,
}).json(),
});
},
useUpdateAlert() {
return useMutation<any, Error, { id: string } & ApiAlertInput>({
mutationFn: async alert =>
server(`alerts/${alert.id}`, {
method: 'PUT',
json: alert,
}).json(),
});
},
useDeleteAlert() {
return useMutation<any, Error, string>({
mutationFn: async (alertId: string) =>
server(`alerts/${alertId}`, {
method: 'DELETE',
}),
});
},
useDashboards(options?: UseQueryOptions<any, Error>) {
return useQuery({
queryKey: [`dashboards`],

View file

@ -0,0 +1,79 @@
import { useMemo } from 'react';
import Link from 'next/link';
import { Control } from 'react-hook-form';
import { Select, SelectProps } from 'react-hook-form-mantine';
import type { Alert, AlertChannelType } from '@hyperdx/common-utils/dist/types';
import { Button, ComboboxData, Group } from '@mantine/core';
import api from '@/api';
type Webhook = {
_id: string;
name: string;
};
const WebhookChannelForm = <T extends object>(
props: Partial<SelectProps<T>>,
) => {
const { data: webhooks } = api.useWebhooks(['slack', 'generic']);
const hasWebhooks = Array.isArray(webhooks?.data) && webhooks.data.length > 0;
const options = useMemo<ComboboxData>(() => {
const webhookOptions =
webhooks?.data.map((sw: Webhook) => ({
value: sw._id,
label: sw.name,
})) || [];
return [
{
value: '',
label: 'Select a Webhook',
disabled: true,
},
...webhookOptions,
];
}, [webhooks]);
return (
<div>
<Group gap="md" justify="space-between">
<Select
comboboxProps={{
withinPortal: false,
}}
required
size="xs"
flex={1}
placeholder={
hasWebhooks ? 'Select a Webhook' : 'No Webhooks available'
}
data={options}
name={props.name!}
control={props.control}
{...props}
/>
<Link href="/team" passHref>
<Button size="xs" variant="subtle" color="gray">
Add New Incoming Webhook
</Button>
</Link>
</Group>
</div>
);
};
export const AlertChannelForm = ({
control,
type,
}: {
control: Control<Alert>;
type: AlertChannelType;
}) => {
if (type === 'webhook') {
return <WebhookChannelForm control={control} name={`channel.webhookId`} />;
}
return null;
};

View file

@ -1,13 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { Select, SelectProps } from '@mantine/core';
export default function SelectControlled(
props: SelectProps &
UseControllerProps<any> & {
onCreate?: () => void;
},
) {
export type SelectControlledProps = SelectProps &
UseControllerProps<any> & {
onCreate?: () => void;
};
export default function SelectControlled(props: SelectControlledProps) {
const { field, fieldState } = useController(props);
// This is needed as mantine does not clear the select

View file

@ -48,90 +48,8 @@ export type LogStreamModel = KeyValuePairs & {
// TODO: Migrate
export type LogView = z.infer<typeof SavedSearchSchema>;
// {
// _id: string;
// name: string;
// query: string;
// alerts?: Alert[];
// tags?: string[];
// };
export type ServerDashboard = z.infer<typeof DashboardSchema>;
// {
// _id: string;
// createdAt: string;
// updatedAt: string;
// name: string;
// charts: Chart[];
// alerts?: Alert[];
// query?: string;
// tags: string[];
// };
export type AlertType = 'presence' | 'absence';
export type AlertInterval =
| '1m'
| '5m'
| '15m'
| '30m'
| '1h'
| '6h'
| '12h'
| '1d';
export type AlertChannelType = 'webhook';
export type AlertSource = 'LOG' | 'CHART';
export type AlertChannel = {
channelId?: string;
recipients?: string[];
severity?: 'critical' | 'error' | 'warning' | 'info';
type: AlertChannelType;
webhookId?: string;
};
export enum AlertState {
ALERT = 'ALERT',
DISABLED = 'DISABLED',
INSUFFICIENT_DATA = 'INSUFFICIENT_DATA',
OK = 'OK',
}
export type Alert = {
_id?: string;
channel: AlertChannel;
cron?: string;
interval: AlertInterval;
state?: AlertState;
threshold: number;
timezone?: string;
type: AlertType;
source: AlertSource;
silenced?: {
by?: string;
at: string;
until: string;
};
// Log alerts
logView?: string;
message?: string;
groupBy?: string;
// Chart alerts
dashboardId?: string;
chartId?: string;
};
export type AlertHistory = {
counts: number;
createdAt: Date;
lastValues: { startTime: Date; count: number }[];
state: AlertState;
};
export type Session = {
errorCount: string;
maxTimestamp: string;

View file

@ -0,0 +1,52 @@
import { sub } from 'date-fns';
import {
AlertChannelType,
AlertInterval,
} from '@hyperdx/common-utils/dist/types';
import { Granularity } from '@/ChartUtils';
export function intervalToGranularity(interval: AlertInterval) {
if (interval === '1m') return Granularity.OneMinute;
if (interval === '5m') return Granularity.FiveMinute;
if (interval === '15m') return Granularity.FifteenMinute;
if (interval === '30m') return Granularity.ThirtyMinute;
if (interval === '1h') return Granularity.OneHour;
if (interval === '6h') return Granularity.SixHour;
if (interval === '12h') return Granularity.TwelveHour;
if (interval === '1d') return Granularity.OneDay;
return Granularity.OneDay;
}
export function intervalToDateRange(interval: AlertInterval): [Date, Date] {
const now = new Date();
if (interval === '1m') return [sub(now, { minutes: 15 }), now];
if (interval === '5m') return [sub(now, { hours: 1 }), now];
if (interval === '15m') return [sub(now, { hours: 4 }), now];
if (interval === '30m') return [sub(now, { hours: 8 }), now];
if (interval === '1h') return [sub(now, { hours: 16 }), now];
if (interval === '6h') return [sub(now, { days: 4 }), now];
if (interval === '12h') return [sub(now, { days: 7 }), now];
if (interval === '1d') return [sub(now, { days: 7 }), now];
return [now, now];
}
export const ALERT_THRESHOLD_TYPE_OPTIONS: Record<string, string> = {
above: 'At least (≥)',
below: 'Below (<)',
};
export const ALERT_INTERVAL_OPTIONS: Record<AlertInterval, string> = {
'1m': '1 minute',
'5m': '5 minute',
'15m': '15 minute',
'30m': '30 minute',
'1h': '1 hour',
'6h': '6 hour',
'12h': '12 hour',
'1d': '1 day',
};
export const ALERT_CHANNEL_OPTIONS: Record<AlertChannelType, string> = {
webhook: 'Webhook',
};

View file

@ -203,8 +203,12 @@ export const AlertIntervalSchema = z.union([
export type AlertInterval = z.infer<typeof AlertIntervalSchema>;
export const zAlertChannelType = z.literal('webhook');
export type AlertChannelType = z.infer<typeof zAlertChannelType>;
export const zAlertChannel = z.object({
type: z.literal('webhook'),
type: zAlertChannelType,
webhookId: z.string().nonempty("Webhook ID can't be empty"),
});
@ -262,6 +266,7 @@ export const SavedSearchSchema = z.object({
source: z.string(),
tags: z.array(z.string()),
orderBy: z.string().optional(),
alerts: z.array(AlertSchema).optional(),
});
export type SavedSearch = z.infer<typeof SavedSearchSchema>;

View file

@ -4386,6 +4386,7 @@ __metadata:
react-error-boundary: "npm:^3.1.4"
react-grid-layout: "npm:^1.3.4"
react-hook-form: "npm:^7.43.8"
react-hook-form-mantine: "npm:^3.1.3"
react-hotkeys-hook: "npm:^4.3.7"
react-json-tree: "npm:^0.17.0"
react-markdown: "npm:^8.0.4"
@ -23734,6 +23735,19 @@ __metadata:
languageName: node
linkType: hard
"react-hook-form-mantine@npm:^3.1.3":
version: 3.1.3
resolution: "react-hook-form-mantine@npm:3.1.3"
peerDependencies:
"@mantine/core": ^7.0.0
"@mantine/dates": ^7.0.0
react: ^18.2.0
react-dom: ^18.2.0
react-hook-form: ^7.43
checksum: 10c0/a440f94195a5380759b85e3e685c516aff46b305c15ac9b356b87b7582e393123c9acdd747a7cf3ed6cc179fea18dcd2583838db2781d631c9f8c6b084192302
languageName: node
linkType: hard
"react-hook-form@npm:^7.43.8":
version: 7.43.8
resolution: "react-hook-form@npm:7.43.8"