mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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 
This commit is contained in:
parent
678c2ca70e
commit
418c293836
12 changed files with 569 additions and 116 deletions
5
.changeset/itchy-maps-happen.md
Normal file
5
.changeset/itchy-maps-happen.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
---
|
||||
|
||||
feat: extract AlertChannelType to its own schema
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? '', {
|
||||
|
|
|
|||
323
packages/app/src/DBSearchPageAlertModal.tsx
Normal file
323
packages/app/src/DBSearchPageAlertModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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`],
|
||||
|
|
|
|||
79
packages/app/src/components/Alerts.tsx
Normal file
79
packages/app/src/components/Alerts.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
52
packages/app/src/utils/alerts.ts
Normal file
52
packages/app/src/utils/alerts.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
14
yarn.lock
14
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue