diff --git a/Makefile b/Makefile index 2053fd62b..a670e8d68 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ dev-lint: ci-lint: ./docker/ingestor/run_linting.sh && npx nx run-many -t ci:lint +.PHONY: dev-int-build +dev-int-build: + docker compose -p int -f ./docker-compose.ci.yml build + .PHONY: dev-int dev-int: docker compose -p int -f ./docker-compose.ci.yml run --rm api dev:int $(FILE) diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 74c0d4839..8a4815f43 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -98,6 +98,7 @@ export const createAlert = async (alertInput: AlertInput) => { // create an update alert function based off of the above create alert function export const updateAlert = async (id: string, alertInput: AlertInput) => { + // should consider clearing AlertHistory when updating an alert? return Alert.findByIdAndUpdate(id, makeAlert(alertInput), { returnDocument: 'after', }); diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index 5fb1959e6..6cd933e52 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -89,7 +89,7 @@ const AlertSchema = new Schema( // Log alerts logView: { type: mongoose.Schema.Types.ObjectId, - ref: 'Alert', + ref: 'LogView', required: false, }, groupBy: { diff --git a/packages/api/src/models/alertHistory.ts b/packages/api/src/models/alertHistory.ts index 6863e6edc..c9c57299d 100644 --- a/packages/api/src/models/alertHistory.ts +++ b/packages/api/src/models/alertHistory.ts @@ -10,6 +10,7 @@ export interface IAlertHistory { counts: number; createdAt: Date; state: AlertState; + lastValues: { startTime: Date; count: number }[]; } const AlertHistorySchema = new Schema({ @@ -27,6 +28,18 @@ const AlertHistorySchema = new Schema({ enum: Object.values(AlertState), required: true, }, + lastValues: [ + { + startTime: { + type: Date, + required: true, + }, + count: { + type: Number, + required: true, + }, + }, + ], }); AlertHistorySchema.index( diff --git a/packages/api/src/routers/api/__tests__/alerts.test.ts b/packages/api/src/routers/api/__tests__/alerts.test.ts new file mode 100644 index 000000000..8be0637b7 --- /dev/null +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -0,0 +1,95 @@ +import { + clearDBCollections, + closeDB, + getLoggedInAgent, + getServer, +} from '@/fixtures'; + +const randomId = () => Math.random().toString(36).substring(7); + +const makeChart = () => ({ + id: randomId(), + name: 'Test Chart', + x: 1, + y: 1, + w: 1, + h: 1, + series: [ + { + type: 'time', + table: 'metrics', + }, + ], +}); + +const makeAlert = ({ + dashboardId, + chartId, +}: { + dashboardId: string; + chartId: string; +}) => ({ + channel: { + type: 'webhook', + webhookId: 'test-webhook-id', + }, + interval: '15m', + threshold: 8, + type: 'presence', + source: 'CHART', + dashboardId, + chartId, +}); + +const MOCK_DASHBOARD = { + name: 'Test Dashboard', + charts: [makeChart(), makeChart(), makeChart(), makeChart(), makeChart()], + query: 'test query', +}; + +describe('alerts router', () => { + const server = getServer(); + + it('index has alerts attached to dashboards', async () => { + const { agent } = await getLoggedInAgent(server); + + await agent.post('/dashboards').send(MOCK_DASHBOARD).expect(200); + const initialDashboards = await agent.get('/dashboards').expect(200); + + // Create alerts for all charts + const dashboard = initialDashboards.body.data[0]; + await Promise.all( + dashboard.charts.map(chart => + agent + .post('/alerts') + .send( + makeAlert({ + dashboardId: dashboard._id, + chartId: chart.id, + }), + ) + .expect(200), + ), + ); + + const alerts = await agent.get(`/alerts`).expect(200); + expect(alerts.body.data.length).toBe(5); + for (const alert of alerts.body.data) { + expect(alert.chartId).toBeDefined(); + expect(alert.dashboard).toBeDefined(); + } + }); + + beforeAll(async () => { + await server.start(); + }); + + afterEach(async () => { + await clearDBCollections(); + }); + + afterAll(async () => { + await server.closeHttpServer(); + await closeDB(); + }); +}); diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 8855517e6..ed44e334a 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -1,4 +1,5 @@ -import express from 'express'; +import express, { NextFunction, Request, Response } from 'express'; +import _ from 'lodash'; import { z } from 'zod'; import { validateRequest } from 'zod-express-middleware'; @@ -9,6 +10,9 @@ import { } from '@/controllers/alerts'; import { getTeam } from '@/controllers/team'; import Alert from '@/models/alert'; +import AlertHistory from '@/models/alertHistory'; +import { IDashboard } from '@/models/dashboard'; +import { ILogView } from '@/models/logView'; const router = express.Router(); @@ -41,10 +45,12 @@ const zAlert = z }) .and(zLogAlert.or(zChartAlert)); -const zAlertInput = zAlert; - // Validate groupBy property -const validateGroupBy = async (req, res, next) => { +const validateGroupBy = async ( + req: Request, + res: Response, + next: NextFunction, +) => { const { groupBy, source } = req.body || {}; if (source === 'LOG' && groupBy) { const teamId = req.user?.team; @@ -70,10 +76,63 @@ const validateGroupBy = async (req, res, next) => { next(); }; -// Routes +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + const alerts = await Alert.find({ team: teamId }).populate<{ + logView: ILogView; + dashboardId: IDashboard; + }>(['logView', 'dashboardId']); + + const data = await Promise.all( + alerts.map(async alert => { + const history = await AlertHistory.find( + { + alert: alert._id, + team: teamId, + }, + { + __v: 0, + _id: 0, + alert: 0, + }, + ) + .sort({ createdAt: -1 }) + .limit(20); + + return { + history, + dashboard: alert.dashboardId, + ..._.pick(alert, [ + '_id', + 'channel', + 'interval', + 'threshold', + 'state', + 'type', + 'source', + 'logView', + 'chartId', + 'createdAt', + 'updatedAt', + ]), + }; + }), + ); + res.json({ + data, + }); + } catch (e) { + next(e); + } +}); + router.post( '/', - validateRequest({ body: zAlertInput }), + validateRequest({ body: zAlert }), validateGroupBy, async (req, res, next) => { try { @@ -89,7 +148,7 @@ router.post( router.put( '/:id', - validateRequest({ body: zAlertInput }), + validateRequest({ body: zAlert }), validateGroupBy, async (req, res, next) => { try { diff --git a/packages/api/src/tasks/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/__tests__/checkAlerts.test.ts index 5bf41264e..7e7416f0c 100644 --- a/packages/api/src/tasks/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/__tests__/checkAlerts.test.ts @@ -360,16 +360,17 @@ describe('checkAlerts', () => { createdAt: 1, }); expect(alertHistories.length).toBe(2); - expect(alertHistories[0].state).toBe('ALERT'); - expect(alertHistories[0].counts).toBe(1); - expect(alertHistories[0].createdAt).toEqual( - new Date('2023-11-16T22:10:00.000Z'), - ); - expect(alertHistories[1].state).toBe('OK'); - expect(alertHistories[1].counts).toBe(0); - expect(alertHistories[1].createdAt).toEqual( - new Date('2023-11-16T22:15:00.000Z'), - ); + const [history1, history2] = alertHistories; + expect(history1.state).toBe('ALERT'); + expect(history1.counts).toBe(1); + expect(history1.createdAt).toEqual(new Date('2023-11-16T22:10:00.000Z')); + expect(history1.lastValues.length).toBe(1); + expect(history1.lastValues.length).toBeGreaterThan(0); + expect(history1.lastValues[0].count).toBeGreaterThanOrEqual(1); + + expect(history2.state).toBe('OK'); + expect(history2.counts).toBe(0); + expect(history2.createdAt).toEqual(new Date('2023-11-16T22:15:00.000Z')); // check if getLogsChart query + webhook were triggered expect(clickhouse.getLogsChart).toHaveBeenNthCalledWith(1, { diff --git a/packages/api/src/tasks/checkAlerts.ts b/packages/api/src/tasks/checkAlerts.ts index 5f10d8b49..fa41a8eb0 100644 --- a/packages/api/src/tasks/checkAlerts.ts +++ b/packages/api/src/tasks/checkAlerts.ts @@ -476,6 +476,7 @@ export const processAlert = async (now: Date, alert: AlertDocument) => { const totalCount = isString(checkData.data) ? parseInt(checkData.data) : checkData.data; + const bucketStart = new Date(checkData.ts_bucket * 1000); if (doesExceedThreshold(alert, totalCount)) { alertState = AlertState.ALERT; logger.info({ @@ -484,7 +485,6 @@ export const processAlert = async (now: Date, alert: AlertDocument) => { totalCount, checkData, }); - const bucketStart = new Date(checkData.ts_bucket * 1000); await fireChannelEvent({ alert, @@ -498,6 +498,7 @@ export const processAlert = async (now: Date, alert: AlertDocument) => { }); history.counts += 1; } + history.lastValues.push({ count: totalCount, startTime: bucketStart }); } history.state = alertState; diff --git a/packages/app/pages/alerts.tsx b/packages/app/pages/alerts.tsx new file mode 100644 index 000000000..5111e90af --- /dev/null +++ b/packages/app/pages/alerts.tsx @@ -0,0 +1,3 @@ +import AlertsPage from '../src/AlertsPage'; + +export default AlertsPage; diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx new file mode 100644 index 000000000..d76a6aac4 --- /dev/null +++ b/packages/app/src/AlertsPage.tsx @@ -0,0 +1,235 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import { formatRelative } from 'date-fns'; + +import api from './api'; +import AppNav from './AppNav'; +import type { Alert, AlertHistory, LogView } from './types'; +import { AlertState } from './types'; + +type AlertData = Alert & { + history: AlertHistory[]; + dashboard?: { + _id: string; + name: string; + }; + logView?: LogView; +}; + +function AlertHistoryCard({ history }: { history: AlertHistory }) { + const start = new Date(history.createdAt.toString()); + const latestValues = history.lastValues + .map(({ count }, index) => { + return count.toString(); + }) + .join(', '); + return ( +
+ {history.state === AlertState.OK ? '.' : '!'} +
+ ); +} + +function AlertHistoryCardList({ history }: { history: AlertHistory[] }) { + return ( +
+ {history.map((history, index) => ( + + ))} +
+ ); +} + +function disableAlert(alertId?: string) { + if (!alertId) { + return; // no ID yet to disable? + } + // TODO do some lovely disabling of the alert here +} + +function AlertDetails({ + alert, + history, +}: { + alert: AlertData; + history: AlertHistory[]; +}) { + // TODO enable once disable handler is implemented above + const showDisableButton = false; + return ( + <> +
+ {alert.state === AlertState.ALERT && ( +
ALERT
+ )} + {alert.state === AlertState.OK && ( +
OK
+ )} + {alert.state === AlertState.DISABLED && ( +
DISABLED
+ )}{' '} + {/* can we disable an alert that is alarming? hmmmmm */} + {/* also, will make the alert jump from under the cursor to the disabled area */} + {showDisableButton ? ( + + ) : null} +
+ {alert.channel.type === 'webhook' && ( + + Notifies via Webhook + + )} +
+
+ Alerts if + + {' '} + {alert.source === 'LOG' ? 'count' : 'value'}{' '} + + is + + {' '} + {alert.type === 'presence' ? 'over' : 'under'}{' '} + + {alert.threshold} + {history.length > 0 && history[0]?.lastValues.length > 0 && ( + + {' '} + (most recently{' '} + {history[0].lastValues.map(({ count }) => count).join(', ')}) + + )} +
+
+ + + ); +} + +function ChartAlertCard({ alert }: { alert: AlertData }) { + const { history } = alert; + if (!alert.dashboard) { + throw new Error('alertData.dashboard is undefined'); + } + return ( +
+ + {alert.dashboard.name} + + +
+ ); +} + +function LogAlertCard({ alert }: { alert: AlertData }) { + const { history } = alert; + if (!alert.logView) { + throw new Error('alert.logView is undefined'); + } + return ( +
+ + {alert.logView?.name} + + +
+ ); +} + +function AlertCard({ alert }: { alert: AlertData }) { + if (alert.source === 'LOG') { + return ; + } else { + return ; + } +} + +function AlertCardList({ alerts }: { alerts: AlertData[] }) { + const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT); + const okData = alerts.filter(alert => alert.state === AlertState.OK); + const disabledData = alerts.filter( + alert => + alert.state === AlertState.DISABLED || + alert.state === AlertState.INSUFFICIENT_DATA, + ); + return ( +
+ {alarmAlerts.length > 0 && ( +
+
+ Alarmed +
+ {alarmAlerts.map((alert, index) => ( + + ))} +
+ )} +
+
+ Running +
+ {okData.length === 0 && ( +
No alerts
+ )} + {okData.map((alert, index) => ( + + ))} +
+
+
+ Disabled +
+ {disabledData.length === 0 && ( +
No alerts
+ )} + {disabledData.map((alert, index) => ( + + ))} +
+
+ ); +} + +export default function AlertsPage() { + const alerts = api.useAlerts().data?.data; + return ( +
+ + Alerts - HyperDX + + +
+
+
Alerts
+
+
+ Note that for now, you'll need to go to either the dashboard or + saved search pages in order to create alerts. This is merely a place + to enable/disable and get an overview of which alerts are in which + state. +
+
+ +
+
+
+ ); +} diff --git a/packages/app/src/AppNav.tsx b/packages/app/src/AppNav.tsx index 9aef55e20..69457f8db 100644 --- a/packages/app/src/AppNav.tsx +++ b/packages/app/src/AppNav.tsx @@ -425,6 +425,8 @@ function PresetSearchLink({ query, name }: { query: string; name: string }) { } export default function AppNav({ fixed = false }: { fixed?: boolean }) { + // TODO enable this once the alerts page is ready for public consumption + const showAlertSidebar = false; useEffect(() => { let redirectUrl; try { @@ -453,6 +455,9 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { api.useDashboards(); const dashboards = dashboardsData?.data ?? []; + const { data: alertsData, isLoading: isAlertsLoading } = api.useAlerts(); + const alerts = alertsData?.data?.alerts ?? []; + const router = useRouter(); const { pathname, query } = router; @@ -794,6 +799,50 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { /> )} + {showAlertSidebar ? ( + + ) : null}
diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 273885c8e..ddc4aaa81 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -382,6 +382,9 @@ const api = { }).json(), ); }, + useAlerts() { + return useQuery(`alerts`, () => server.get(`alerts`).json()); + }, useSaveAlert() { return useMutation(`alerts`, async alert => server('alerts', { diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index a1060df12..053063240 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -67,12 +67,19 @@ export type AlertChannel = { webhookId?: string; }; +export enum AlertState { + ALERT = 'ALERT', + DISABLED = 'DISABLED', + INSUFFICIENT_DATA = 'INSUFFICIENT_DATA', + OK = 'OK', +} + export type Alert = { _id?: string; channel: AlertChannel; cron?: string; interval: AlertInterval; - state?: 'ALERT' | 'OK'; + state?: AlertState; threshold: number; timezone?: string; type: AlertType; @@ -88,6 +95,13 @@ export type Alert = { chartId?: string; }; +export type AlertHistory = { + counts: number; + createdAt: Date; + lastValues: { startTime: Date; count: number }[]; + state: AlertState; +}; + export type Session = { errorCount: string; maxTimestamp: string;