Skip to content

Commit b79433e

Browse files
ernestiiwrn14897
andauthored
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 <[email protected]>
1 parent 406787a commit b79433e

File tree

14 files changed

+585
-153
lines changed

14 files changed

+585
-153
lines changed

.changeset/sharp-worms-impress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
---
4+
5+
refactor: Extract alert configuration schema into AlertBaseSchema

packages/api/src/controllers/alerts.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { sign, verify } from 'jsonwebtoken';
2+
import { groupBy } from 'lodash';
23
import ms from 'ms';
34
import { z } from 'zod';
45

@@ -122,6 +123,59 @@ export const getAlertById = async (
122123
});
123124
};
124125

126+
export const getTeamDashboardAlertsByTile = async (teamId: ObjectId) => {
127+
const alerts = await Alert.find({
128+
source: AlertSource.TILE,
129+
team: teamId,
130+
});
131+
return groupBy(alerts, 'tileId');
132+
};
133+
134+
export const getDashboardAlertsByTile = async (
135+
teamId: ObjectId,
136+
dashboardId: ObjectId | string,
137+
) => {
138+
const alerts = await Alert.find({
139+
dashboard: dashboardId,
140+
source: AlertSource.TILE,
141+
team: teamId,
142+
});
143+
return groupBy(alerts, 'tileId');
144+
};
145+
146+
export const createOrUpdateDashboardAlerts = async (
147+
dashboardId: ObjectId | string,
148+
teamId: ObjectId,
149+
alertsByTile: Record<string, AlertInput>,
150+
) => {
151+
return Promise.all(
152+
Object.entries(alertsByTile).map(async ([tileId, alert]) => {
153+
return await Alert.findOneAndUpdate(
154+
{
155+
dashboard: dashboardId,
156+
tileId,
157+
source: AlertSource.TILE,
158+
team: teamId,
159+
},
160+
alert,
161+
{ new: true, upsert: true },
162+
);
163+
}),
164+
);
165+
};
166+
167+
export const deleteDashboardAlerts = async (
168+
dashboardId: ObjectId | string,
169+
teamId: ObjectId,
170+
alertIds?: string[],
171+
) => {
172+
return Alert.deleteMany({
173+
dashboard: dashboardId,
174+
team: teamId,
175+
...(alertIds && { _id: { $in: alertIds } }),
176+
});
177+
};
178+
125179
export const getAlertsEnhanced = async (teamId: ObjectId) => {
126180
return Alert.find({ team: teamId }).populate<{
127181
savedSearch: ISavedSearch;

packages/api/src/controllers/dashboard.ts

Lines changed: 83 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,59 @@ import {
22
DashboardWithoutIdSchema,
33
Tile,
44
} from '@hyperdx/common-utils/dist/types';
5-
import { differenceBy, uniq } from 'lodash';
5+
import { uniq } from 'lodash';
66
import { z } from 'zod';
77

8+
import {
9+
createOrUpdateDashboardAlerts,
10+
deleteDashboardAlerts,
11+
getDashboardAlertsByTile,
12+
getTeamDashboardAlertsByTile,
13+
} from '@/controllers/alerts';
814
import type { ObjectId } from '@/models';
9-
import Alert from '@/models/alert';
1015
import Dashboard from '@/models/dashboard';
11-
import { tagsSchema } from '@/utils/zod';
16+
17+
function pickAlertsByTile(tiles: Tile[]) {
18+
return tiles.reduce((acc, tile) => {
19+
if (tile.config.alert) {
20+
acc[tile.id] = tile.config.alert;
21+
}
22+
return acc;
23+
}, {});
24+
}
1225

1326
export async function getDashboards(teamId: ObjectId) {
14-
const dashboards = await Dashboard.find({
15-
team: teamId,
16-
});
27+
const [_dashboards, alerts] = await Promise.all([
28+
Dashboard.find({ team: teamId }),
29+
getTeamDashboardAlertsByTile(teamId),
30+
]);
31+
32+
const dashboards = _dashboards
33+
.map(d => d.toJSON())
34+
.map(d => ({
35+
...d,
36+
tiles: d.tiles.map(t => ({
37+
...t,
38+
config: { ...t.config, alert: alerts[t.id]?.[0] },
39+
})),
40+
}));
41+
1742
return dashboards;
1843
}
1944

2045
export async function getDashboard(dashboardId: string, teamId: ObjectId) {
21-
return Dashboard.findOne({
22-
_id: dashboardId,
23-
team: teamId,
24-
});
46+
const [_dashboard, alerts] = await Promise.all([
47+
Dashboard.findOne({ _id: dashboardId, team: teamId }),
48+
getDashboardAlertsByTile(teamId, dashboardId),
49+
]);
50+
51+
return {
52+
..._dashboard,
53+
tiles: _dashboard?.tiles.map(t => ({
54+
...t,
55+
config: { ...t.config, alert: alerts[t.id]?.[0] },
56+
})),
57+
};
2558
}
2659

2760
export async function createDashboard(
@@ -32,60 +65,33 @@ export async function createDashboard(
3265
...dashboard,
3366
team: teamId,
3467
}).save();
68+
69+
await createOrUpdateDashboardAlerts(
70+
newDashboard._id,
71+
teamId,
72+
pickAlertsByTile(dashboard.tiles),
73+
);
74+
3575
return newDashboard;
3676
}
3777

38-
export async function deleteDashboardAndAlerts(
39-
dashboardId: string,
40-
teamId: ObjectId,
41-
) {
78+
export async function deleteDashboard(dashboardId: string, teamId: ObjectId) {
4279
const dashboard = await Dashboard.findOneAndDelete({
4380
_id: dashboardId,
4481
team: teamId,
4582
});
4683
if (dashboard) {
47-
await Alert.deleteMany({ dashboard: dashboard._id });
84+
await deleteDashboardAlerts(dashboardId, teamId);
4885
}
4986
}
5087

5188
export async function updateDashboard(
5289
dashboardId: string,
5390
teamId: ObjectId,
54-
{
55-
name,
56-
tiles,
57-
tags,
58-
}: {
59-
name: string;
60-
tiles: Tile[];
61-
tags: z.infer<typeof tagsSchema>;
62-
},
91+
updates: Partial<z.infer<typeof DashboardWithoutIdSchema>>,
6392
) {
64-
const updatedDashboard = await Dashboard.findOneAndUpdate(
65-
{
66-
_id: dashboardId,
67-
team: teamId,
68-
},
69-
{
70-
name,
71-
tiles,
72-
tags: tags && uniq(tags),
73-
},
74-
{ new: true },
75-
);
93+
const oldDashboard = await getDashboard(dashboardId, teamId);
7694

77-
return updatedDashboard;
78-
}
79-
80-
export async function updateDashboardAndAlerts(
81-
dashboardId: string,
82-
teamId: ObjectId,
83-
dashboard: z.infer<typeof DashboardWithoutIdSchema>,
84-
) {
85-
const oldDashboard = await Dashboard.findOne({
86-
_id: dashboardId,
87-
team: teamId,
88-
});
8995
if (oldDashboard == null) {
9096
throw new Error('Dashboard not found');
9197
}
@@ -96,27 +102,42 @@ export async function updateDashboardAndAlerts(
96102
team: teamId,
97103
},
98104
{
99-
...dashboard,
100-
tags: dashboard.tags && uniq(dashboard.tags),
105+
...updates,
106+
tags: updates.tags && uniq(updates.tags),
101107
},
102108
{ new: true },
103109
);
104110
if (updatedDashboard == null) {
105111
throw new Error('Could not update dashboard');
106112
}
107113

108-
// Delete related alerts
109-
const deletedTileIds = differenceBy(
110-
oldDashboard?.tiles || [],
111-
updatedDashboard?.tiles || [],
112-
'id',
113-
).map(c => c.id);
114-
115-
if (deletedTileIds?.length > 0) {
116-
await Alert.deleteMany({
117-
dashboard: dashboardId,
118-
tileId: { $in: deletedTileIds },
119-
});
114+
// Update related alerts
115+
// - Delete
116+
const newAlertIds = new Set(
117+
updates.tiles?.map(t => t.config.alert?.id).filter(Boolean),
118+
);
119+
const deletedAlertIds: string[] = [];
120+
121+
if (oldDashboard.tiles) {
122+
for (const tile of oldDashboard.tiles) {
123+
const alertId = tile.config.alert?.id;
124+
if (alertId && !newAlertIds.has(alertId)) {
125+
deletedAlertIds.push(alertId);
126+
}
127+
}
128+
129+
if (deletedAlertIds.length > 0) {
130+
await deleteDashboardAlerts(dashboardId, teamId, deletedAlertIds);
131+
}
132+
}
133+
134+
// - Update / Create
135+
if (updates.tiles) {
136+
await createOrUpdateDashboardAlerts(
137+
dashboardId,
138+
teamId,
139+
pickAlertsByTile(updates.tiles),
140+
);
120141
}
121142

122143
return updatedDashboard;

packages/api/src/fixtures.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,16 +302,22 @@ export function buildMetricSeries({
302302
export const randomMongoId = () =>
303303
Math.floor(Math.random() * 1000000000000).toString();
304304

305-
export const makeTile = (opts?: { id?: string }): Tile => ({
305+
export const makeTile = (opts?: {
306+
id?: string;
307+
alert?: SavedChartConfig['alert'];
308+
}): Tile => ({
306309
id: opts?.id ?? randomMongoId(),
307310
x: 1,
308311
y: 1,
309312
w: 1,
310313
h: 1,
311-
config: makeChartConfig(),
314+
config: makeChartConfig(opts),
312315
});
313316

314-
export const makeChartConfig = (opts?: { id?: string }): SavedChartConfig => ({
317+
export const makeChartConfig = (opts?: {
318+
id?: string;
319+
alert?: SavedChartConfig['alert'];
320+
}): SavedChartConfig => ({
315321
name: 'Test Chart',
316322
source: 'test-source',
317323
displayType: DisplayType.Line,
@@ -331,6 +337,7 @@ export const makeChartConfig = (opts?: { id?: string }): SavedChartConfig => ({
331337
output: 'number',
332338
},
333339
filters: [],
340+
alert: opts?.alert,
334341
});
335342

336343
// TODO: DEPRECATED

0 commit comments

Comments
 (0)