Skip to content

Commit

Permalink
feat: UI for adding alerts for dashboard tiles
Browse files Browse the repository at this point in the history
  • Loading branch information
ernestii committed Jan 20, 2025
1 parent d50f709 commit 9aa649b
Show file tree
Hide file tree
Showing 12 changed files with 353 additions and 130 deletions.
1 change: 1 addition & 0 deletions packages/api/src/common/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
152 changes: 97 additions & 55 deletions packages/api/src/controllers/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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,
Expand All @@ -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<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');
}
Expand All @@ -93,27 +109,53 @@ export async function updateDashboardAndAlerts(
team: teamId,
},
{
...dashboard,
tags: dashboard.tags && uniq(dashboard.tags),
...updates,
tags: updates.tags && uniq(updates.tags),
},
{ new: true },
);
if (updatedDashboard == null) {
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;
Expand Down
32 changes: 8 additions & 24 deletions packages/api/src/routers/api/dashboards.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className={`p-2 ${className} d-flex flex-column ${
Expand All @@ -217,6 +231,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"
Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/DBSearchPageAlertModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,12 @@ import { AlertPreviewChart } from './components/AlertPreviewChart';
import { WebhookChannelForm } from './components/Alerts';
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
import api from './api';
import { optionsToSelectData } from './utils';

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 zAlertForm = AlertSchema;
type AlertForm = z.infer<typeof zAlertForm>;

Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const AlertSchema = z.object({
id: z.string().optional(),
source: z.union([z.literal('saved_search'), z.literal('tile')]).optional(),
savedSearchId: z.string().optional(),
dashboardId: z.string().optional(),
tileId: z.string().optional(),
groupBy: z.string().optional(),
interval: z.union([
z.literal('1m'),
Expand All @@ -25,8 +27,17 @@ export const AlertSchema = z.object({
type: z.literal('webhook'),
webhookId: z.string().nonempty("Webhook ID can't be empty"),
}),
state: z.union([z.literal('OK'), z.literal('ALERT')]).optional(),
silenced: z
.object({
by: z.string(),
at: z.string(),
})
.optional(),
});

export type Alert = z.infer<typeof AlertSchema>;

// --------------------------
// SAVED SEARCH
// --------------------------
Expand Down
Loading

0 comments on commit 9aa649b

Please sign in to comment.