mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: UI for adding alerts for dashboard tiles (#562)
 Co-authored-by: Warren <5959690+wrn14897@users.noreply.github.com>
This commit is contained in:
parent
406787a542
commit
b79433eeb8
14 changed files with 584 additions and 152 deletions
5
.changeset/sharp-worms-impress.md
Normal file
5
.changeset/sharp-worms-impress.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/common-utils': patch
|
||||
---
|
||||
|
||||
refactor: Extract alert configuration schema into AlertBaseSchema
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue