From a813a05bbc6c67846d96f3c557f77fc0778d77e5 Mon Sep 17 00:00:00 2001 From: Ernest Iliiasov Date: Mon, 20 Jan 2025 12:36:26 -0600 Subject: [PATCH] feat: UI for adding alerts for dashboard tiles --- packages/api/src/common/commonTypes.ts | 1 + packages/api/src/controllers/dashboard.ts | 152 +++++++++++------- packages/api/src/routers/api/dashboards.ts | 32 +--- packages/app/src/DBDashboardPage.tsx | 36 +++++ packages/app/src/DBSearchPageAlertModal.tsx | 4 +- packages/app/src/commonTypes.ts | 11 ++ .../app/src/components/AlertPreviewChart.tsx | 32 +--- packages/app/src/components/Alerts.tsx | 38 +++++ .../src/components/DBEditTimeChartForm.tsx | 142 +++++++++++++--- packages/app/src/renderChartConfig.tsx | 3 + packages/app/src/utils.ts | 3 + packages/app/src/utils/alerts.ts | 29 +++- 12 files changed, 353 insertions(+), 130 deletions(-) diff --git a/packages/api/src/common/commonTypes.ts b/packages/api/src/common/commonTypes.ts index 40703facc..542971730 100644 --- a/packages/api/src/common/commonTypes.ts +++ b/packages/api/src/common/commonTypes.ts @@ -144,6 +144,7 @@ export const _ChartConfigSchema = z.object({ connection: z.string(), fillNulls: z.number().optional(), selectGroupBy: z.boolean().optional(), + alert: z.any().optional(), // todo }); export const ChartConfigSchema = z.intersection( diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index e4a5a361a..a99fe221e 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -1,24 +1,58 @@ -import { differenceBy, uniq } from 'lodash'; +import { differenceBy, groupBy, uniq } from 'lodash'; import { z } from 'zod'; -import { DashboardWithoutIdSchema, Tile } from '@/common/commonTypes'; +import { DashboardWithoutIdSchema } from '@/common/commonTypes'; import type { ObjectId } from '@/models'; import Alert from '@/models/alert'; import Dashboard from '@/models/dashboard'; -import { tagsSchema } from '@/utils/zod'; export async function getDashboards(teamId: ObjectId) { - const dashboards = await Dashboard.find({ + const _dashboards = await Dashboard.find({ team: teamId, }); + + const alertsByTileId = groupBy( + await Alert.find({ + dashboard: { $in: _dashboards.map(d => d._id) }, + source: 'tile', + }), + 'tileId', + ); + + const dashboards = _dashboards + .map(d => d.toJSON()) + .map(d => ({ + ...d, + tiles: d.tiles.map(t => ({ + ...t, + config: { ...t.config, alert: alertsByTileId[t.id]?.[0] }, + })), + })); + return dashboards; } export async function getDashboard(dashboardId: string, teamId: ObjectId) { - return Dashboard.findOne({ + const _dashboard = await Dashboard.findOne({ _id: dashboardId, team: teamId, }); + + const alertsByTileId = groupBy( + await Alert.find({ + dashboard: dashboardId, + source: 'tile', + }), + 'tileId', + ); + + return { + ..._dashboard, + tiles: _dashboard?.tiles.map(t => ({ + ...t, + config: { ...t.config, alert: alertsByTileId[t.id]?.[0] }, + })), + }; } export async function createDashboard( @@ -29,13 +63,26 @@ export async function createDashboard( ...dashboard, team: teamId, }).save(); + + // Create related alerts + for (const tile of dashboard.tiles) { + if (!tile.config.alert) { + await Alert.findOneAndUpdate( + { + dashboard: newDashboard._id, + tileId: tile.id, + source: 'tile', + }, + { ...tile.config.alert }, + { new: true, upsert: true }, + ); + } + } + 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, @@ -48,41 +95,10 @@ export async function deleteDashboardAndAlerts( export async function updateDashboard( dashboardId: string, teamId: ObjectId, - { - name, - tiles, - tags, - }: { - name: string; - tiles: Tile[]; - tags: z.infer; - }, + updates: Partial>, ) { - 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, -) { - const oldDashboard = await Dashboard.findOne({ - _id: dashboardId, - team: teamId, - }); if (oldDashboard == null) { throw new Error('Dashboard not found'); } @@ -93,8 +109,8 @@ export async function updateDashboardAndAlerts( team: teamId, }, { - ...dashboard, - tags: dashboard.tags && uniq(dashboard.tags), + ...updates, + tags: updates.tags && uniq(updates.tags), }, { new: true }, ); @@ -102,18 +118,44 @@ 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 = updates.tiles?.map(t => t.config.alert?._id); + const deletedAlertIds: string[] = []; - if (deletedTileIds?.length > 0) { - await Alert.deleteMany({ - dashboard: dashboardId, - tileId: { $in: deletedTileIds }, - }); + if (oldDashboard.tiles) { + for (const tile of oldDashboard.tiles) { + if ( + tile.config.alert?._id && + !newAlertIds?.includes(tile.config.alert._id) + ) { + deletedAlertIds.push(tile.config.alert._id.toString()); + } + } + + if (deletedAlertIds?.length > 0) { + await Alert.deleteMany({ + dashboard: dashboardId, + _id: { $in: deletedAlertIds }, + }); + } + } + + // - Update / Create + if (updates.tiles) { + for (const tile of updates.tiles) { + if (tile.config.alert) { + await Alert.findOneAndUpdate( + { + dashboard: dashboardId, + tileId: tile.id, + source: 'tile', + }, + { ...tile.config.alert }, + { new: true, upsert: true }, + ); + } + } } return updatedDashboard; diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index 54c4b654b..023eeb9b2 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -1,5 +1,5 @@ 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'; @@ -10,14 +10,13 @@ import { } from '@/common/commonTypes'; 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) { diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 84e62b78a..c67d2ea08 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -191,6 +191,20 @@ const Tile = forwardRef( [chart.config.source, setRowId, setRowSource], ); + const alert = chart.config.alert; + const alertIndicatorColor = useMemo(() => { + if (!alert) { + return 'transparent'; + } + if (alert.state === 'OK') { + return 'green'; + } + if (alert.silenced?.at) { + return 'yellow'; + } + return 'red'; + }, [alert]); + return (
{hovered ? ( + {chart.config.displayType === DisplayType.Line && ( + + + } + mr={4} + > + + + )} +
); }; + +export const getAlertReferenceLines = ({ + thresholdType, + threshold, + // TODO: zScore +}: { + thresholdType: 'above' | 'below'; + threshold: number; +}) => ( + <> + {threshold != null && thresholdType === 'below' && ( + + )} + {threshold != null && thresholdType === 'above' && ( + + )} + {threshold != null && ( + } + stroke="red" + strokeDasharray="3 3" + /> + )} + +); diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index 11dc0f8e1..766949d3b 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -7,6 +7,9 @@ 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 { Accordion, Box, @@ -16,12 +19,18 @@ import { Flex, Group, Paper, + Stack, Tabs, Text, Textarea, } from '@mantine/core'; import { AGG_FNS } from '@/ChartUtils'; +import { AlertSchema } from '@/commonTypes'; +import { + getAlertReferenceLines, + WebhookChannelForm, +} from '@/components/Alerts'; import ChartSQLPreview from '@/components/ChartSQLPreview'; import { DBSqlRowTable } from '@/components/DBRowTable'; import DBTableChart from '@/components/DBTableChart'; @@ -38,6 +47,13 @@ import { import SearchInputV2 from '@/SearchInputV2'; import { getFirstTimestampValueExpression, useSource } from '@/source'; import { parseTimeQuery } from '@/timeQuery'; +import { optionsToSelectData } from '@/utils'; +import { + ALERT_CHANNEL_OPTIONS, + DEFAULT_TILE_ALERT, + TILE_ALERT_INTERVAL_OPTIONS, + TILE_ALERT_THRESHOLD_TYPE_OPTIONS, +} from '@/utils/alerts'; import HDXMarkdownChart from '../HDXMarkdownChart'; import { SelectList } from '../sqlTypes'; @@ -202,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: AlertSchema.optional(), + }) + .passthrough(); + export type SavedChartConfigWithSelectArray = Omit< SavedChartConfig, 'select' @@ -233,6 +256,7 @@ export default function EditTimeChartForm({ const { control, watch, setValue, handleSubmit, register } = useForm({ defaultValues: chartConfig, + resolver: zodResolver(zSavedChartConfig), }); const { fields, append, remove } = useFieldArray({ @@ -243,6 +267,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; @@ -268,6 +293,12 @@ export default function EditTimeChartForm({ } }, [displayType]); + useEffect(() => { + if (displayType !== DisplayType.Line) { + setValue('granularity', undefined); + } + }, [displayType]); + const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview // const queriedConfig: ChartConfigWithDateRange | undefined = useMemo(() => { @@ -517,24 +548,42 @@ export default function EditTimeChartForm({ )} - {displayType !== DisplayType.Number && ( - - )} + + {displayType !== DisplayType.Number && ( + + )} + {displayType === DisplayType.Line && ( + + )} + @@ -583,6 +632,56 @@ export default function EditTimeChartForm({ )} + {alert && ( + + + + + + Alert when the value + + + + over + + + window via + + + + + Send to + + + + + + )} + {onSave != null && ( @@ -658,6 +757,13 @@ export default function EditTimeChartForm({ sourceId={sourceId} config={queriedConfig} onTimeRangeSelect={onTimeRangeSelect} + referenceLines={ + alert && + getAlertReferenceLines({ + threshold: alert.threshold, + thresholdType: alert.thresholdType, + }) + } /> )} diff --git a/packages/app/src/renderChartConfig.tsx b/packages/app/src/renderChartConfig.tsx index 89c2f0b0a..e4991f423 100644 --- a/packages/app/src/renderChartConfig.tsx +++ b/packages/app/src/renderChartConfig.tsx @@ -18,6 +18,8 @@ import { SQLInterval, } from '@/sqlTypes'; +import { Alert } from './commonTypes'; + // FIXME: SQLParser.ColumnRef is incomplete type ColumnRef = SQLParser.ColumnRef & { array_index?: { @@ -64,6 +66,7 @@ export type ChartConfig = { connection: string; // Connection ID fillNulls?: number | false; // undefined = 0, false = no fill selectGroupBy?: boolean; // Add groupBy elements to select statement (default behavior: true) + alert?: Alert; // TODO: Color support } & SelectSQLStatement; diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index efd9c5f43..a44e08597 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -585,3 +585,6 @@ export const parseJSON = (json: string) => { const [error, result] = _useTry(() => JSON.parse(json)); return result; }; + +export const optionsToSelectData = (options: Record) => + Object.entries(options).map(([value, label]) => ({ value, label })); diff --git a/packages/app/src/utils/alerts.ts b/packages/app/src/utils/alerts.ts index eec919b7c..fae351c93 100644 --- a/packages/app/src/utils/alerts.ts +++ b/packages/app/src/utils/alerts.ts @@ -1,7 +1,8 @@ import { sub } from 'date-fns'; -import { z } from 'zod'; +import _ from 'lodash'; import { Granularity } from '@/ChartUtils'; +import { Alert } from '@/commonTypes'; import { AlertChannelType, AlertInterval } from '@/types'; export function intervalToGranularity(interval: AlertInterval) { @@ -34,6 +35,11 @@ export const ALERT_THRESHOLD_TYPE_OPTIONS: Record = { below: 'Below (<)', }; +export const TILE_ALERT_THRESHOLD_TYPE_OPTIONS: Record = { + above: 'is at least (≥)', + below: 'falls below (<)', +}; + export const ALERT_INTERVAL_OPTIONS: Record = { '1m': '1 minute', '5m': '5 minute', @@ -45,6 +51,27 @@ export const ALERT_INTERVAL_OPTIONS: Record = { '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 = { webhook: 'Webhook', }; + +export const DEFAULT_TILE_ALERT: Alert = { + threshold: 1, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: '', + }, +};