feat: UI for adding alerts for dashboard tiles (#562)

![Screenshot 2025-01-20 at 3 34 13 PM](https://github.com/user-attachments/assets/bebdd2f4-48f8-46a7-9a52-3617ad26fce9)


Co-authored-by: Warren <5959690+wrn14897@users.noreply.github.com>
This commit is contained in:
Ernest Iliiasov 2025-01-27 18:32:24 -06:00 committed by GitHub
parent 406787a542
commit b79433eeb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 584 additions and 152 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/common-utils': patch
---
refactor: Extract alert configuration schema into AlertBaseSchema

View file

@ -1,4 +1,5 @@
import { sign, verify } from 'jsonwebtoken';
import { groupBy } from 'lodash';
import ms from 'ms';
import { z } from 'zod';
@ -122,6 +123,59 @@ export const getAlertById = async (
});
};
export const getTeamDashboardAlertsByTile = async (teamId: ObjectId) => {
const alerts = await Alert.find({
source: AlertSource.TILE,
team: teamId,
});
return groupBy(alerts, 'tileId');
};
export const getDashboardAlertsByTile = async (
teamId: ObjectId,
dashboardId: ObjectId | string,
) => {
const alerts = await Alert.find({
dashboard: dashboardId,
source: AlertSource.TILE,
team: teamId,
});
return groupBy(alerts, 'tileId');
};
export const createOrUpdateDashboardAlerts = async (
dashboardId: ObjectId | string,
teamId: ObjectId,
alertsByTile: Record<string, AlertInput>,
) => {
return Promise.all(
Object.entries(alertsByTile).map(async ([tileId, alert]) => {
return await Alert.findOneAndUpdate(
{
dashboard: dashboardId,
tileId,
source: AlertSource.TILE,
team: teamId,
},
alert,
{ new: true, upsert: true },
);
}),
);
};
export const deleteDashboardAlerts = async (
dashboardId: ObjectId | string,
teamId: ObjectId,
alertIds?: string[],
) => {
return Alert.deleteMany({
dashboard: dashboardId,
team: teamId,
...(alertIds && { _id: { $in: alertIds } }),
});
};
export const getAlertsEnhanced = async (teamId: ObjectId) => {
return Alert.find({ team: teamId }).populate<{
savedSearch: ISavedSearch;

View file

@ -2,26 +2,59 @@ import {
DashboardWithoutIdSchema,
Tile,
} from '@hyperdx/common-utils/dist/types';
import { differenceBy, uniq } from 'lodash';
import { uniq } from 'lodash';
import { z } from 'zod';
import {
createOrUpdateDashboardAlerts,
deleteDashboardAlerts,
getDashboardAlertsByTile,
getTeamDashboardAlertsByTile,
} from '@/controllers/alerts';
import type { ObjectId } from '@/models';
import Alert from '@/models/alert';
import Dashboard from '@/models/dashboard';
import { tagsSchema } from '@/utils/zod';
function pickAlertsByTile(tiles: Tile[]) {
return tiles.reduce((acc, tile) => {
if (tile.config.alert) {
acc[tile.id] = tile.config.alert;
}
return acc;
}, {});
}
export async function getDashboards(teamId: ObjectId) {
const dashboards = await Dashboard.find({
team: teamId,
});
const [_dashboards, alerts] = await Promise.all([
Dashboard.find({ team: teamId }),
getTeamDashboardAlertsByTile(teamId),
]);
const dashboards = _dashboards
.map(d => d.toJSON())
.map(d => ({
...d,
tiles: d.tiles.map(t => ({
...t,
config: { ...t.config, alert: alerts[t.id]?.[0] },
})),
}));
return dashboards;
}
export async function getDashboard(dashboardId: string, teamId: ObjectId) {
return Dashboard.findOne({
_id: dashboardId,
team: teamId,
});
const [_dashboard, alerts] = await Promise.all([
Dashboard.findOne({ _id: dashboardId, team: teamId }),
getDashboardAlertsByTile(teamId, dashboardId),
]);
return {
..._dashboard,
tiles: _dashboard?.tiles.map(t => ({
...t,
config: { ...t.config, alert: alerts[t.id]?.[0] },
})),
};
}
export async function createDashboard(
@ -32,60 +65,33 @@ export async function createDashboard(
...dashboard,
team: teamId,
}).save();
await createOrUpdateDashboardAlerts(
newDashboard._id,
teamId,
pickAlertsByTile(dashboard.tiles),
);
return newDashboard;
}
export async function deleteDashboardAndAlerts(
dashboardId: string,
teamId: ObjectId,
) {
export async function deleteDashboard(dashboardId: string, teamId: ObjectId) {
const dashboard = await Dashboard.findOneAndDelete({
_id: dashboardId,
team: teamId,
});
if (dashboard) {
await Alert.deleteMany({ dashboard: dashboard._id });
await deleteDashboardAlerts(dashboardId, teamId);
}
}
export async function updateDashboard(
dashboardId: string,
teamId: ObjectId,
{
name,
tiles,
tags,
}: {
name: string;
tiles: Tile[];
tags: z.infer<typeof tagsSchema>;
},
updates: Partial<z.infer<typeof DashboardWithoutIdSchema>>,
) {
const updatedDashboard = await Dashboard.findOneAndUpdate(
{
_id: dashboardId,
team: teamId,
},
{
name,
tiles,
tags: tags && uniq(tags),
},
{ new: true },
);
const oldDashboard = await getDashboard(dashboardId, teamId);
return updatedDashboard;
}
export async function updateDashboardAndAlerts(
dashboardId: string,
teamId: ObjectId,
dashboard: z.infer<typeof DashboardWithoutIdSchema>,
) {
const oldDashboard = await Dashboard.findOne({
_id: dashboardId,
team: teamId,
});
if (oldDashboard == null) {
throw new Error('Dashboard not found');
}
@ -96,8 +102,8 @@ export async function updateDashboardAndAlerts(
team: teamId,
},
{
...dashboard,
tags: dashboard.tags && uniq(dashboard.tags),
...updates,
tags: updates.tags && uniq(updates.tags),
},
{ new: true },
);
@ -105,18 +111,33 @@ export async function updateDashboardAndAlerts(
throw new Error('Could not update dashboard');
}
// Delete related alerts
const deletedTileIds = differenceBy(
oldDashboard?.tiles || [],
updatedDashboard?.tiles || [],
'id',
).map(c => c.id);
// Update related alerts
// - Delete
const newAlertIds = new Set(
updates.tiles?.map(t => t.config.alert?.id).filter(Boolean),
);
const deletedAlertIds: string[] = [];
if (deletedTileIds?.length > 0) {
await Alert.deleteMany({
dashboard: dashboardId,
tileId: { $in: deletedTileIds },
});
if (oldDashboard.tiles) {
for (const tile of oldDashboard.tiles) {
const alertId = tile.config.alert?.id;
if (alertId && !newAlertIds.has(alertId)) {
deletedAlertIds.push(alertId);
}
}
if (deletedAlertIds.length > 0) {
await deleteDashboardAlerts(dashboardId, teamId, deletedAlertIds);
}
}
// - Update / Create
if (updates.tiles) {
await createOrUpdateDashboardAlerts(
dashboardId,
teamId,
pickAlertsByTile(updates.tiles),
);
}
return updatedDashboard;

View file

@ -302,16 +302,22 @@ export function buildMetricSeries({
export const randomMongoId = () =>
Math.floor(Math.random() * 1000000000000).toString();
export const makeTile = (opts?: { id?: string }): Tile => ({
export const makeTile = (opts?: {
id?: string;
alert?: SavedChartConfig['alert'];
}): Tile => ({
id: opts?.id ?? randomMongoId(),
x: 1,
y: 1,
w: 1,
h: 1,
config: makeChartConfig(),
config: makeChartConfig(opts),
});
export const makeChartConfig = (opts?: { id?: string }): SavedChartConfig => ({
export const makeChartConfig = (opts?: {
id?: string;
alert?: SavedChartConfig['alert'];
}): SavedChartConfig => ({
name: 'Test Chart',
source: 'test-source',
displayType: DisplayType.Line,
@ -331,6 +337,7 @@ export const makeChartConfig = (opts?: { id?: string }): SavedChartConfig => ({
output: 'number',
},
filters: [],
alert: opts?.alert,
});
// TODO: DEPRECATED

View file

@ -1,3 +1,6 @@
import { AlertThresholdType } from '@hyperdx/common-utils/dist/types';
import { omit } from 'lodash';
import {
getLoggedInAgent,
getServer,
@ -11,6 +14,13 @@ const MOCK_DASHBOARD = {
tags: ['test'],
};
const MOCK_ALERT = {
channel: { type: 'webhook' as const, webhookId: 'abcde' },
interval: '12h' as const,
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
};
describe('dashboard router', () => {
const server = getServer();
@ -74,6 +84,114 @@ describe('dashboard router', () => {
expect(dashboards.body.length).toBe(0);
});
it('alerts are created when creating dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tags: [],
})
.expect(200);
const alerts = await agent.get(`/alerts`).expect(200);
expect(alerts.body.data).toMatchObject([
{
...omit(MOCK_ALERT, 'channel.webhookId'),
tileId: dashboard.body.tiles[0].id,
},
]);
});
it('alerts are created when updating dashboard (adding alert to tile)', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);
const updatedDashboard = await agent
.patch(`/dashboards/${dashboard.body.id}`)
.send({
...dashboard.body,
tiles: [...dashboard.body.tiles, makeTile({ alert: MOCK_ALERT })],
})
.expect(200);
const alerts = await agent.get(`/alerts`).expect(200);
expect(alerts.body.data).toMatchObject([
{
...omit(MOCK_ALERT, 'channel.webhookId'),
tileId: updatedDashboard.body.tiles[MOCK_DASHBOARD.tiles.length].id,
},
]);
});
it('alerts are deleted when updating dashboard (deleting tile alert settings)', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tags: [],
})
.expect(200);
await agent
.patch(`/dashboards/${dashboard.body.id}`)
.send({
...dashboard.body,
tiles: dashboard.body.tiles.slice(1),
})
.expect(200);
const alerts = await agent.get(`/alerts`).expect(200);
expect(alerts.body.data).toEqual([]);
});
it('alerts are updated when updating dashboard (updating tile alert settings)', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tags: [],
})
.expect(200);
const updatedAlert = {
...MOCK_ALERT,
threshold: 2,
};
await agent
.patch(`/dashboards/${dashboard.body.id}`)
.send({
...dashboard.body,
tiles: [
{
...dashboard.body.tiles[0],
config: {
...dashboard.body.tiles[0].config,
alert: updatedAlert,
},
},
],
})
.expect(200);
const alerts = await agent.get(`/alerts`).expect(200);
expect(alerts.body.data).toMatchObject([
{
...omit(updatedAlert, 'channel.webhookId'),
tileId: dashboard.body.tiles[0].id,
},
]);
});
it('deletes attached alerts when deleting tiles', async () => {
const { agent } = await getLoggedInAgent(server);
@ -96,34 +214,40 @@ describe('dashboard router', () => {
),
);
const dashboards = await agent.get(`/dashboards`).expect(200);
// Make sure all alerts are attached to the dashboard charts
const allTiles = dashboard.tiles.map(tile => tile.id).sort();
const tilesWithAlerts = dashboards.body[0].alerts
const alertsPreDelete = await agent.get(`/alerts`).expect(200);
const alertsPreDeleteTiles = alertsPreDelete.body.data
.map(alert => alert.tileId)
.sort();
expect(allTiles).toEqual(tilesWithAlerts);
expect(allTiles).toEqual(alertsPreDeleteTiles);
// Delete the first chart
const dashboardPreDelete = await agent
.get('/dashboards')
.expect(200)
.then(res => res.body[0]);
await agent
.patch(`/dashboards/${dashboard._id}`)
.send({
...dashboard,
tiles: dashboard.tiles.slice(1),
...dashboardPreDelete,
tiles: dashboardPreDelete.tiles.slice(1),
})
.expect(200);
const dashboardPostDelete = (await agent.get(`/dashboards`).expect(200))
.body[0];
const dashboardPostDelete = await agent
.get('/dashboards')
.expect(200)
.then(res => res.body[0]);
// Make sure all alerts are attached to the dashboard charts
const allTilesPostDelete = dashboardPostDelete.tiles
.map(tile => tile.id)
.sort();
const tilesWithAlertsPostDelete = dashboardPostDelete.alerts
const alertsPostDelete = await agent.get(`/alerts`).expect(200);
const alertsPostDeleteTiles = alertsPostDelete.body.data
.map(alert => alert.tileId)
.sort();
expect(allTilesPostDelete).toEqual(tilesWithAlertsPostDelete);
expect(allTilesPostDelete).toEqual(alertsPostDeleteTiles);
});
});

View file

@ -3,21 +3,20 @@ import {
DashboardWithoutIdSchema,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { differenceBy, groupBy, uniq } from 'lodash';
import { groupBy } from 'lodash';
import _ from 'lodash';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
import {
createDashboard,
deleteDashboardAndAlerts,
deleteDashboard,
getDashboard,
getDashboards,
updateDashboardAndAlerts,
updateDashboard,
} from '@/controllers/dashboard';
import { getNonNullUserWithTeam } from '@/middleware/auth';
import Alert from '@/models/alert';
import { chartSchema, objectIdSchema, tagsSchema } from '@/utils/zod';
import { objectIdSchema } from '@/utils/zod';
// create routes that will get and update dashboards
const router = express.Router();
@ -28,19 +27,7 @@ router.get('/', async (req, res, next) => {
const dashboards = await getDashboards(teamId);
const alertsByDashboard = groupBy(
await Alert.find({
dashboard: { $in: dashboards.map(d => d._id) },
}),
'dashboard',
);
res.json(
dashboards.map(d => ({
...d.toJSON(),
alerts: alertsByDashboard[d._id.toString()],
})),
);
return res.json(dashboards);
} catch (e) {
next(e);
}
@ -87,13 +74,10 @@ router.patch(
const updates = _.omitBy(req.body, _.isNil);
const updatedDashboard = await updateDashboardAndAlerts(
const updatedDashboard = await updateDashboard(
dashboardId,
teamId,
{
...dashboard.toJSON(),
...updates,
},
updates,
);
res.json(updatedDashboard);
@ -113,7 +97,7 @@ router.delete(
const { teamId } = getNonNullUserWithTeam(req);
const { id: dashboardId } = req.params;
await deleteDashboardAndAlerts(dashboardId, teamId);
await deleteDashboard(dashboardId, teamId);
res.sendStatus(204);
} catch (e) {

View file

@ -5,11 +5,7 @@ import { ObjectId } from 'mongodb';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
import {
deleteDashboardAndAlerts,
updateDashboard,
updateDashboardAndAlerts,
} from '@/controllers/dashboard';
import { deleteDashboard, updateDashboard } from '@/controllers/dashboard';
import Dashboard, { IDashboard } from '@/models/dashboard';
import {
translateDashboardDocumentToExternalDashboard,
@ -182,7 +178,7 @@ router.delete(
return res.sendStatus(403);
}
await deleteDashboardAndAlerts(dashboardId, teamId);
await deleteDashboard(dashboardId, teamId);
res.json({});
} catch (e) {

View file

@ -17,6 +17,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import RGL, { WidthProvider } from 'react-grid-layout';
import { Controller, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ChartConfigWithDateRange,
DisplayType,
@ -191,6 +192,20 @@ const Tile = forwardRef(
[chart.config.source, setRowId, setRowSource],
);
const alert = chart.config.alert;
const alertIndicatorColor = useMemo(() => {
if (!alert) {
return 'transparent';
}
if (alert.state === AlertState.OK) {
return 'green';
}
if (alert.silenced?.at) {
return 'yellow';
}
return 'red';
}, [alert]);
return (
<div
className={`p-2 ${className} d-flex flex-column ${
@ -217,6 +232,28 @@ const Tile = forwardRef(
</Text>
{hovered ? (
<Flex gap="0px">
{chart.config.displayType === DisplayType.Line && (
<Indicator
size={5}
zIndex={1}
color={alertIndicatorColor}
label={
!alert && <span className="text-slate-400 fs-8">+</span>
}
mr={4}
>
<Button
variant="subtle"
color="gray.4"
size="xxs"
onClick={onEditClick}
title="Alerts"
>
<i className="bi bi-bell fs-7"></i>
</Button>
</Indicator>
)}
<Button
variant="subtle"
color="gray.4"
@ -319,12 +356,14 @@ const Tile = forwardRef(
);
const EditTileModal = ({
dashboardId,
chart,
onClose,
onSave,
isSaving,
dateRange,
}: {
dashboardId?: string;
chart: Tile | undefined;
onClose: () => void;
dateRange: [Date, Date];
@ -342,6 +381,7 @@ const EditTileModal = ({
>
{chart != null && (
<EditTimeChartForm
dashboardId={dashboardId}
chartConfig={chart.config}
setChartConfig={config => {}}
dateRange={dateRange}
@ -438,7 +478,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const confirm = useConfirm();
const router = useRouter();
const { dashboardId } = router.query;
const dashboardId = router.query.dashboardId as string | undefined;
const {
dashboard,
@ -677,6 +717,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
</Head>
<OnboardingModal />
<EditTileModal
dashboardId={dashboardId}
chart={editedTile}
onClose={() => {
if (!isSaving) {

View file

@ -37,6 +37,7 @@ import { AlertPreviewChart } from './components/AlertPreviewChart';
import { AlertChannelForm } from './components/Alerts';
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
import api from './api';
import { optionsToSelectData } from './utils';
const SavedSearchAlertFormSchema = z
.object({
@ -51,9 +52,6 @@ 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 = ({
savedSearch,
defaultValues,

View file

@ -68,12 +68,19 @@ const WebhookChannelForm = <T extends object>(
export const AlertChannelForm = ({
control,
type,
namePrefix = '',
}: {
control: Control<Alert>;
control: Control<any>; // TODO: properly type this
type: AlertChannelType;
namePrefix?: string;
}) => {
if (type === 'webhook') {
return <WebhookChannelForm control={control} name={`channel.webhookId`} />;
return (
<WebhookChannelForm
control={control}
name={`${namePrefix}channel.webhookId`}
/>
);
}
return null;

View file

@ -7,7 +7,11 @@ import {
UseFormSetValue,
UseFormWatch,
} from 'react-hook-form';
import { NativeSelect, NumberInput } from 'react-hook-form-mantine';
import z from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
AlertBaseSchema,
ChartConfigWithDateRange,
DisplayType,
Filter,
@ -23,23 +27,34 @@ import {
Flex,
Group,
Paper,
Stack,
Tabs,
Text,
Textarea,
} from '@mantine/core';
import { AGG_FNS } from '@/ChartUtils';
import { AlertChannelForm, getAlertReferenceLines } from '@/components/Alerts';
import ChartSQLPreview from '@/components/ChartSQLPreview';
import { DBSqlRowTable } from '@/components/DBRowTable';
import DBTableChart from '@/components/DBTableChart';
import { DBTimeChart } from '@/components/DBTimeChart';
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import { TimePicker } from '@/components/TimePicker';
import { useUpdateDashboard } from '@/dashboard';
import { IS_DEV } from '@/config';
import { GranularityPickerControlled } from '@/GranularityPicker';
import SearchInputV2 from '@/SearchInputV2';
import { getFirstTimestampValueExpression, useSource } from '@/source';
import { parseTimeQuery } from '@/timeQuery';
import { optionsToSelectData } from '@/utils';
import {
ALERT_CHANNEL_OPTIONS,
DEFAULT_TILE_ALERT,
extendDateRangeToInterval,
intervalToGranularity,
TILE_ALERT_INTERVAL_OPTIONS,
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
} from '@/utils/alerts';
import HDXMarkdownChart from '../HDXMarkdownChart';
@ -203,6 +218,13 @@ function ChartSeriesEditor({
// TODO: This is a hack to set the default time range
const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date];
const zSavedChartConfig = z
.object({
// TODO: Chart
alert: AlertBaseSchema.optional(),
})
.passthrough();
export type SavedChartConfigWithSelectArray = Omit<
SavedChartConfig,
'select'
@ -211,6 +233,7 @@ export type SavedChartConfigWithSelectArray = Omit<
};
export default function EditTimeChartForm({
dashboardId,
chartConfig,
displayedTimeInputValue,
dateRange,
@ -222,6 +245,7 @@ export default function EditTimeChartForm({
onTimeRangeSelect,
onClose,
}: {
dashboardId?: string;
chartConfig: SavedChartConfig;
displayedTimeInputValue?: string;
dateRange: [Date, Date];
@ -236,6 +260,7 @@ export default function EditTimeChartForm({
const { control, watch, setValue, handleSubmit, register } =
useForm<SavedChartConfig>({
defaultValues: chartConfig,
resolver: zodResolver(zSavedChartConfig),
});
const { fields, append, remove } = useFieldArray({
@ -246,6 +271,7 @@ export default function EditTimeChartForm({
const select = watch('select');
const sourceId = watch('source');
const whereLanguage = watch('whereLanguage');
const alert = watch('alert');
const { data: tableSource } = useSource({ id: sourceId });
const databaseName = tableSource?.from.databaseName;
@ -271,6 +297,12 @@ export default function EditTimeChartForm({
}
}, [displayType]);
useEffect(() => {
if (displayType !== DisplayType.Line) {
setValue('alert', undefined);
}
}, [displayType]);
const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview
// const queriedConfig: ChartConfigWithDateRange | undefined = useMemo(() => {
@ -375,7 +407,6 @@ export default function EditTimeChartForm({
limit: { limit: 200 },
select: tableSource?.defaultTableSelectExpression || '',
groupBy: undefined,
granularity: undefined,
filters: seriesToFilters(queriedConfig.select),
filtersLogicalOperator: 'OR' as const,
}
@ -520,24 +551,44 @@ export default function EditTimeChartForm({
)}
<Divider mt="md" mb="sm" />
<Flex mt={4} align="center" justify="space-between">
{displayType !== DisplayType.Number && (
<Button
variant="subtle"
size="sm"
color="gray"
onClick={() => {
append({
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
});
}}
>
<i className="bi bi-plus-circle me-2" />
Add Series
</Button>
)}
<Group gap={0}>
{displayType !== DisplayType.Number && (
<Button
variant="subtle"
size="sm"
color="gray"
onClick={() => {
append({
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
});
}}
>
<i className="bi bi-plus-circle me-2" />
Add Series
</Button>
)}
{displayType === DisplayType.Line &&
dashboardId &&
IS_DEV && (
<Button
variant="subtle"
size="sm"
color={alert ? 'red' : 'gray'}
onClick={() =>
setValue(
'alert',
alert ? undefined : DEFAULT_TILE_ALERT,
)
}
>
<i className="bi bi-bell-fill me-2" />
{!alert ? 'Add Alert' : 'Remove Alert'}
</Button>
)}
</Group>
<NumberFormatInputControlled control={control} />
</Flex>
</>
@ -586,6 +637,57 @@ export default function EditTimeChartForm({
</>
)}
{alert && (
<Paper my="sm">
<Stack gap="xs">
<Paper px="md" py="sm" bg="dark.6" radius="xs">
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when the value
</Text>
<NativeSelect
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
min={1}
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
</Group>
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
Send to
</Text>
<AlertChannelForm
control={control}
type={watch('alert.channel.type')}
namePrefix="alert."
/>
</Paper>
</Stack>
</Paper>
)}
<Flex justify="space-between" mt="sm">
<Flex gap="sm">
{onSave != null && (
@ -665,8 +767,26 @@ export default function EditTimeChartForm({
>
<DBTimeChart
sourceId={sourceId}
config={queriedConfig}
config={{
...queriedConfig,
granularity: alert
? intervalToGranularity(alert.interval)
: undefined,
dateRange: alert
? extendDateRangeToInterval(
queriedConfig.dateRange,
alert.interval,
)
: queriedConfig.dateRange,
}}
onTimeRangeSelect={onTimeRangeSelect}
referenceLines={
alert &&
getAlertReferenceLines({
threshold: alert.threshold,
thresholdType: alert.thresholdType,
})
}
/>
</div>
)}

View file

@ -585,3 +585,6 @@ export const parseJSON = <T = any>(json: string) => {
const [error, result] = _useTry<T>(() => JSON.parse(json));
return result;
};
export const optionsToSelectData = (options: Record<string, string>) =>
Object.entries(options).map(([value, label]) => ({ value, label }));

View file

@ -1,7 +1,18 @@
import { sub } from 'date-fns';
import {
differenceInDays,
differenceInHours,
differenceInMinutes,
sub,
} from 'date-fns';
import _ from 'lodash';
import { z } from 'zod';
import {
Alert,
AlertBaseSchema,
AlertChannelType,
AlertInterval,
AlertSource,
AlertThresholdType,
} from '@hyperdx/common-utils/dist/types';
import { Granularity } from '@/ChartUtils';
@ -31,11 +42,49 @@ export function intervalToDateRange(interval: AlertInterval): [Date, Date] {
return [now, now];
}
export function extendDateRangeToInterval(
dateRange: [Date, Date],
interval: AlertInterval,
): [Date, Date] {
const [start, end] = dateRange;
if (interval === '1m' && differenceInMinutes(end, start) < 15) {
return [sub(end, { minutes: 15 }), end];
}
if (interval === '5m' && differenceInHours(end, start) < 1) {
return [sub(end, { hours: 1 }), end];
}
if (interval === '15m' && differenceInHours(end, start) < 4) {
return [sub(end, { hours: 4 }), end];
}
if (interval === '30m' && differenceInHours(end, start) < 8) {
return [sub(end, { hours: 8 }), end];
}
if (interval === '1h' && differenceInHours(end, start) < 16) {
return [sub(end, { hours: 16 }), end];
}
if (interval === '6h' && differenceInDays(end, start) < 4) {
return [sub(end, { days: 4 }), end];
}
if (interval === '12h' && differenceInDays(end, start) < 7) {
return [sub(end, { days: 7 }), end];
}
if (interval === '1d' && differenceInDays(end, start) < 7) {
return [sub(end, { days: 7 }), end];
}
return dateRange;
}
export const ALERT_THRESHOLD_TYPE_OPTIONS: Record<string, string> = {
above: 'At least (≥)',
below: 'Below (<)',
};
export const TILE_ALERT_THRESHOLD_TYPE_OPTIONS: Record<string, string> = {
above: 'is at least (≥)',
below: 'falls below (<)',
};
export const ALERT_INTERVAL_OPTIONS: Record<AlertInterval, string> = {
'1m': '1 minute',
'5m': '5 minute',
@ -47,6 +96,27 @@ export const ALERT_INTERVAL_OPTIONS: Record<AlertInterval, string> = {
'1d': '1 day',
};
export const TILE_ALERT_INTERVAL_OPTIONS = _.pick(ALERT_INTERVAL_OPTIONS, [
// Exclude 1m
'5m',
'15m',
'30m',
'1h',
'6h',
'12h',
'1d',
]);
export const ALERT_CHANNEL_OPTIONS: Record<AlertChannelType, string> = {
webhook: 'Webhook',
};
export const DEFAULT_TILE_ALERT: z.infer<typeof AlertBaseSchema> = {
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
interval: '5m',
channel: {
type: 'webhook',
webhookId: '',
},
};

View file

@ -224,26 +224,27 @@ export const zTileAlert = z.object({
dashboardId: z.string().min(1),
});
export const AlertSchema = z
.object({
id: z.string().optional(),
interval: AlertIntervalSchema,
threshold: z.number().int().min(1),
thresholdType: z.nativeEnum(AlertThresholdType),
channel: zAlertChannel,
state: z.nativeEnum(AlertState).optional(),
name: z.string().min(1).max(512).nullish(),
message: z.string().min(1).max(4096).nullish(),
source: z.nativeEnum(AlertSource),
silenced: z
.object({
by: z.string(),
at: z.string(),
until: z.string(),
})
.optional(),
})
.and(zSavedSearchAlert.or(zTileAlert));
export const AlertBaseSchema = z.object({
id: z.string().optional(),
interval: AlertIntervalSchema,
threshold: z.number().int().min(1),
thresholdType: z.nativeEnum(AlertThresholdType),
channel: zAlertChannel,
state: z.nativeEnum(AlertState).optional(),
name: z.string().min(1).max(512).nullish(),
message: z.string().min(1).max(4096).nullish(),
silenced: z
.object({
by: z.string(),
at: z.string(),
until: z.string(),
})
.optional(),
});
export const AlertSchema = AlertBaseSchema.and(
zSavedSearchAlert.or(zTileAlert),
);
export type Alert = z.infer<typeof AlertSchema>;
@ -346,6 +347,7 @@ export const SavedChartConfigSchema = z.intersection(
z.object({
name: z.string(),
source: z.string(),
alert: AlertBaseSchema.optional(),
}),
_ChartConfigSchema.omit({
connection: true,