Chart alerts: add schemas and read path (#95)

* Add schemas and read path for Dashboard chart alerts
* Reuse existing `Alert` model
* Show a bell icon on dashboard chart tile if it has an alert associated

![Screenshot 2023-11-11 at 11 31 44 AM](https://github.com/hyperdxio/hyperdx/assets/149748269/e803679b-dff2-419d-979f-7e7588b89572)
This commit is contained in:
Shorpo 2023-11-11 15:16:17 -07:00 committed by GitHub
parent 04f82d71db
commit bbda6696bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 131 additions and 16 deletions

View file

@ -0,0 +1,6 @@
---
'@hyperdx/api': minor
'@hyperdx/app': minor
---
Chart alerts: add schemas and read path

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"editor.formatOnSave": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View file

@ -27,18 +27,30 @@ export type AlertChannel = {
webhookId: string;
};
export enum AlertSource {
LOG = 'LOG',
CHART = 'CHART',
}
export interface IAlert {
_id: ObjectId;
channel: AlertChannel;
cron: string;
groupBy?: string;
interval: AlertInterval;
logView: ObjectId;
message?: string;
state: AlertState;
threshold: number;
timezone: string;
type: AlertType;
source?: AlertSource;
// Log alerts
groupBy?: string;
logView?: ObjectId;
message?: string;
// Chart alerts
dashboardId?: ObjectId;
chartId?: string;
}
const AlertSchema = new Schema<IAlert>(
@ -47,10 +59,6 @@ const AlertSchema = new Schema<IAlert>(
type: String,
required: true,
},
message: {
type: String,
required: false,
},
threshold: {
type: Number,
required: true,
@ -68,16 +76,43 @@ const AlertSchema = new Schema<IAlert>(
required: true,
},
channel: Schema.Types.Mixed, // slack, email, etc
logView: { type: mongoose.Schema.Types.ObjectId, ref: 'Alert' },
state: {
type: String,
enum: AlertState,
default: AlertState.OK,
},
source: {
type: String,
enum: AlertSource,
required: false,
default: AlertSource.LOG,
},
// Log alerts
logView: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Alert',
required: false,
},
groupBy: {
type: String,
required: false,
},
message: {
type: String,
required: false,
},
// Chart alerts
dashboardId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Dashboard',
required: false,
},
chartId: {
type: String,
required: false,
},
},
{
timestamps: true,

View file

@ -2,12 +2,29 @@ import mongoose, { Schema } from 'mongoose';
import type { ObjectId } from '.';
type Chart = {
id: string;
name: string;
x: number;
y: number;
w: number;
h: number;
series: {
table: string;
type: 'time' | 'histogram' | 'search' | 'number' | 'table' | 'markdown';
aggFn: string;
field?: string;
where?: string;
groupBy?: string[];
}[];
};
export interface IDashboard {
_id: ObjectId;
name: string;
query: string;
team: ObjectId;
charts: any[]; // TODO: Type this eventually
charts: Chart[];
}
const DashboardSchema = new Schema<IDashboard>(

View file

@ -6,6 +6,7 @@ import Alert, {
AlertChannel,
AlertInterval,
AlertType,
AlertSource,
} from '../../models/alert';
import * as clickhouse from '../../clickhouse';
import { SQLSerializer } from '../../clickhouse/searchQueryParser';
@ -59,6 +60,7 @@ const createAlert = async ({
cron: getCron(interval),
groupBy,
interval,
source: AlertSource.LOG,
logView: logViewId,
threshold,
timezone: 'UTC', // TODO: support different timezone
@ -91,6 +93,7 @@ const updateAlert = async ({
cron: getCron(interval),
groupBy: groupBy ?? null,
interval,
source: AlertSource.LOG,
logView: logViewId,
threshold,
timezone: 'UTC', // TODO: support different timezone

View file

@ -1,9 +1,11 @@
import express from 'express';
import Dashboard from '../../models/dashboard';
import Alert from '../../models/alert';
import { isUserAuthenticated } from '../../middleware/auth';
import { validateRequest } from 'zod-express-middleware';
import { z } from 'zod';
import { groupBy } from 'lodash';
// create routes that will get and update dashboards
const router = express.Router();
@ -51,8 +53,18 @@ router.get('/', isUserAuthenticated, async (req, res, next) => {
{ _id: 1, name: 1, createdAt: 1, updatedAt: 1, charts: 1, query: 1 },
).sort({ name: -1 });
const alertsByDashboard = groupBy(
await Alert.find({
dashboardId: { $in: dashboards.map(d => d._id) },
}),
'dashboardId',
);
res.json({
data: dashboards,
data: dashboards.map(d => ({
...d.toJSON(),
alerts: alertsByDashboard[d._id.toString()],
})),
});
} catch (e) {
next(e);
@ -147,6 +159,7 @@ router.delete('/:id', isUserAuthenticated, async (req, res, next) => {
return res.sendStatus(400);
}
await Dashboard.findByIdAndDelete(dashboardId);
await Alert.deleteMany({ dashboardId: dashboardId });
res.json({});
} catch (e) {
next(e);

View file

@ -11,7 +11,7 @@ import { serializeError } from 'serialize-error';
import * as clickhouse from '../clickhouse';
import * as config from '../config';
import * as slack from '../utils/slack';
import Alert, { AlertState, IAlert } from '../models/alert';
import Alert, { AlertState, IAlert, AlertSource } from '../models/alert';
import AlertHistory, { IAlertHistory } from '../models/alertHistory';
import LogView from '../models/logView';
import Webhook from '../models/webhook';
@ -200,6 +200,14 @@ export const roundDownToXMinutes = (x: number) => roundDownTo(1000 * 60 * x);
const processAlert = async (now: Date, alert: IAlert) => {
try {
if (alert.source === AlertSource.CHART || !alert.logView) {
logger.info({
message: `[Not implemented] Skipping Chart alert processing`,
alert,
});
return;
}
const logView = await getLogViewEnhanced(alert.logView);
const previous: IAlertHistory | undefined = (

View file

@ -35,6 +35,7 @@ import HDXHistogramChart from './HDXHistogramChart';
import api from './api';
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
import { parseTimeQuery, useNewTimeQuery, useTimeQuery } from './timeQuery';
import type { Alert } from './types';
import {
EditSearchChartForm,
EditMarkdownChartForm,
@ -59,6 +60,7 @@ type Dashboard = {
id: string;
name: string;
charts: Chart[];
alerts?: Alert[];
query?: string;
};
@ -85,6 +87,7 @@ const Tile = forwardRef(
queued,
onSettled,
granularity,
hasAlert,
// Properties forwarded by grid layout
className,
@ -102,6 +105,7 @@ const Tile = forwardRef(
onSettled?: () => void;
queued?: boolean;
granularity: Granularity | undefined;
hasAlert?: boolean;
// Properties forwarded by grid layout
className?: string;
@ -190,10 +194,18 @@ const Tile = forwardRef(
<div className="d-flex justify-content-between align-items-center mb-3 cursor-grab">
<div className="fs-7 text-muted">{chart.name}</div>
<i className="bi bi-grip-horizontal text-muted" />
<div className="fs-7 text-muted cursor-pointer">
<div className="fs-7 text-muted d-flex gap-2 align-items-center">
{hasAlert && (
<div
className="rounded px-1 text-muted bg-grey opacity-90 cursor-default"
title="Has alert"
>
<span className="bi bi-bell" />
</div>
)}
<Button
variant="link"
className="text-muted-hover p-0 me-2"
className="text-muted-hover p-0"
size="sm"
onClick={onEditClick}
>
@ -673,6 +685,7 @@ export default function DashboardPage() {
dateRange={searchedTimeRange}
onEditClick={() => setEditedChart(chart)}
granularity={granularityQuery}
hasAlert={dashboard?.alerts?.some(a => a.chartId === chart.id)}
onDeleteClick={() => {
if (dashboard != null) {
setDashboard({

View file

@ -67,14 +67,21 @@ export type Alert = {
_id: string;
channel: AlertChannel;
cron: string;
groupBy?: string;
interval: AlertInterval;
logView: string;
message?: string;
state: 'ALERT' | 'OK';
threshold: number;
timezone: string;
type: 'presence' | 'absence';
source: 'LOG' | 'CHART';
// Log alerts
logView?: string;
message?: string;
groupBy?: string;
// Chart alerts
dashboardId?: string;
chartId?: string;
};
export type Session = {

View file

@ -143,6 +143,10 @@ body {
}
}
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}