From 6818430339443579005089dd8830a84ea7015098 Mon Sep 17 00:00:00 2001 From: Ernest Iliiasov Date: Mon, 20 Jan 2025 23:32:28 -0600 Subject: [PATCH] feat: Alerts page --- packages/api/src/routers/api/alerts.ts | 2 +- packages/app/pages/alerts.tsx | 3 + packages/app/src/AlertsPage.tsx | 271 +++++++++++++++++++++ packages/app/src/AppNav.tsx | 16 ++ packages/app/src/api.ts | 18 +- packages/app/src/commonTypes.ts | 146 +++++++++++ packages/app/src/types.ts | 8 + packages/app/styles/AlertsPage.module.scss | 1 + 8 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 packages/app/pages/alerts.tsx create mode 100644 packages/app/src/AlertsPage.tsx create mode 100644 packages/app/src/commonTypes.ts diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index db51efe34..3e36245d3 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -53,7 +53,7 @@ router.get('/', async (req, res, next) => { dashboard: { tiles: alert.dashboard.tiles .filter(tile => tile.id === alert.tileId) - .map(tile => _.pick(tile, ['id', 'name'])), + .map(tile => _.pick(tile, ['id', 'config.name'])), ..._.pick(alert.dashboard, ['_id', 'name', 'updatedAt', 'tags']), }, }), diff --git a/packages/app/pages/alerts.tsx b/packages/app/pages/alerts.tsx new file mode 100644 index 000000000..d1de57b4c --- /dev/null +++ b/packages/app/pages/alerts.tsx @@ -0,0 +1,3 @@ +import AlertsPage from '@/AlertsPage'; + +export default AlertsPage; diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx new file mode 100644 index 000000000..59e8b1ce7 --- /dev/null +++ b/packages/app/src/AlertsPage.tsx @@ -0,0 +1,271 @@ +import * as React from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import cx from 'classnames'; +import { formatRelative } from 'date-fns'; +import { AlertHistory, AlertState } from '@hyperdx/common-utils/dist/types'; +import { Alert, Badge, Container, Group, Stack, Tooltip } from '@mantine/core'; + +import api from './api'; +import { withAppNav } from './layout'; +import type { AlertsPageItem } from './types'; + +import styles from '../styles/AlertsPage.module.scss'; + +// TODO: exceptions latestHighestValue needs to be different condition (total count of exceptions not highest value within an exception) + +function AlertHistoryCard({ history }: { history: AlertHistory }) { + const start = new Date(history.createdAt.toString()); + const today = React.useMemo(() => new Date(), []); + const latestHighestValue = history.lastValues.length + ? Math.max(...history.lastValues.map(({ count }) => count)) + : 0; + + return ( + +
+ + ); +} + +const HISTORY_ITEMS = 18; + +function AlertHistoryCardList({ history }: { history: AlertHistory[] }) { + const items = React.useMemo(() => { + if (history.length < HISTORY_ITEMS) { + return history; + } + return history.slice(0, HISTORY_ITEMS); + }, [history]); + + const paddingItems = React.useMemo(() => { + if (history.length > HISTORY_ITEMS) { + return []; + } + return new Array(HISTORY_ITEMS - history.length).fill(null); + }, [history]); + + return ( +
+ {paddingItems.map((_, index) => ( + +
+ + ))} + {items + .slice() + .reverse() + .map((history, index) => ( + + ))} +
+ ); +} + +function AlertDetails({ alert }: { alert: AlertsPageItem }) { + const alertName = React.useMemo(() => { + if (alert.source === 'tile' && alert.dashboard) { + const tileName = + alert.dashboard?.tiles.find(tile => tile.id === alert.tileId)?.config + .name || 'Tile'; + return ( + <> + {alert.dashboard?.name} + {tileName ? ( + <> + + {tileName} + + ) : null} + + ); + } + if (alert.source === 'saved_search' && alert.savedSearch) { + return alert.savedSearch?.name; + } + return '–'; + }, [alert]); + + const alertUrl = React.useMemo(() => { + if (alert.source === 'tile' && alert.dashboard) { + return `/dashboards/${alert.dashboardId}?highlightedTileId=${alert.tileId}`; + } + if (alert.source === 'saved_search' && alert.savedSearch) { + return `/search/${alert.savedSearchId}`; + } + return ''; + }, [alert]); + + const alertIcon = (() => { + switch (alert.source) { + case 'tile': + return 'bi-graph-up'; + case 'saved_search': + return 'bi-layout-text-sidebar-reverse'; + default: + return 'bi-question'; + } + })(); + + const alertType = React.useMemo(() => { + return ( + <> + If value is {alert.thresholdType === 'above' ? 'over' : 'under'}{' '} + {alert.threshold} + · + + ); + }, [alert]); + + const notificationMethod = React.useMemo(() => { + if (alert.channel.type === 'webhook') { + return ( + + Notify via Webhook + + ); + } + }, [alert]); + + const linkTitle = React.useMemo(() => { + switch (alert.source) { + case 'tile': + return 'Dashboard tile'; + case 'saved_search': + return 'Saved search'; + default: + return ''; + } + }, [alert]); + + return ( +
+ + {alert.state === AlertState.ALERT && ( + + Alert + + )} + {alert.state === AlertState.OK && Ok} + {alert.state === AlertState.DISABLED && ( + + Disabled + + )} + + +
+ + + {alertName} + +
+
+ {alertType} + {notificationMethod} +
+
+
+ + + + +
+ ); +} + +function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) { + const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT); + const okData = alerts.filter(alert => alert.state === AlertState.OK); + + return ( +
+ {alarmAlerts.length > 0 && ( +
+
+ Triggered +
+ {alarmAlerts.map((alert, index) => ( + + ))} +
+ )} +
+
+ OK +
+ {okData.length === 0 && ( +
No alerts
+ )} + {okData.map((alert, index) => ( + + ))} +
+
+ ); +} + +export default function AlertsPage() { + const { data, isError, isLoading } = api.useAlerts(); + + const alerts = React.useMemo(() => data?.data || [], [data?.data]); + + return ( +
+ + Alerts - HyperDX + +
Alerts
+
+ + } + color="gray" + py="xs" + mt="md" + > + Alerts can be{' '} + + created + {' '} + from dashboard charts and saved searches. + + {isLoading ? ( +
+ Loading... +
+ ) : isError ? ( +
Error
+ ) : alerts?.length ? ( + <> + + + ) : ( +
+ No alerts created yet +
+ )} +
+
+
+ ); +} + +AlertsPage.getLayout = withAppNav; diff --git a/packages/app/src/AppNav.tsx b/packages/app/src/AppNav.tsx index 4a7c753f4..241fd6887 100644 --- a/packages/app/src/AppNav.tsx +++ b/packages/app/src/AppNav.tsx @@ -809,6 +809,22 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
+
+ + + {' '} + {!isCollapsed && Alerts} + + +
{/*
; }; +type AlertsResponse = { + data: AlertsPageItem[]; +}; + type MultiSeriesChartInput = { series: ChartSeries[]; endDate: Date; @@ -561,6 +559,12 @@ const api = { }).json(), }); }, + useAlerts() { + return useQuery({ + queryKey: [`alerts`], + queryFn: () => hdxServer(`alerts`).json() as Promise, + }); + }, useServices() { return useQuery({ queryKey: [`services`], diff --git a/packages/app/src/commonTypes.ts b/packages/app/src/commonTypes.ts new file mode 100644 index 000000000..70443be5d --- /dev/null +++ b/packages/app/src/commonTypes.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; + +// ------------------------- +// ALERTS +// ------------------------- + +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'), + z.literal('5m'), + z.literal('15m'), + z.literal('30m'), + z.literal('1h'), + z.literal('6h'), + z.literal('12h'), + z.literal('1d'), + ]), + threshold: z.number().int().min(1), + thresholdType: z.union([z.literal('above'), z.literal('below')]), + channel: 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'), + z.literal('DISABLED'), + z.literal('INSUFFICIENT_DATA'), + ]) + .optional(), + silenced: z + .object({ + by: z.string(), + at: z.string(), + until: z.string(), + }) + .optional(), +}); + +export type Alert = z.infer; + +// -------------------------- +// SAVED SEARCH +// -------------------------- + +export const SavedSearchSchema = z.object({ + id: z.string(), + name: z.string(), + select: z.string(), + where: z.string(), + whereLanguage: z.union([z.literal('sql'), z.literal('lucene')]).optional(), + source: z.string(), + tags: z.array(z.string()), + orderBy: z.string().optional(), + alerts: z.array(AlertSchema).optional(), +}); + +export type SavedSearch = z.infer; + +// -------------------------- +// DASHBOARDS +// -------------------------- + +// TODO: Define this +export const SavedChartConfigSchema = z.any(); + +export const TileSchema = z.object({ + name: z.string().optional(), + id: z.string(), + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + config: SavedChartConfigSchema, +}); + +export const DashboardSchema = z.object({ + id: z.string(), + name: z.string(), + tiles: z.array(TileSchema), + tags: z.array(z.string()), +}); + +export const DashboardWithoutIdSchema = DashboardSchema.omit({ id: true }); + +export const ConnectionSchema = z.object({ + id: z.string(), + name: z.string(), + host: z.string(), + username: z.string(), + password: z.string().optional(), +}); + +// -------------------------- +// TABLE SOURCES +// -------------------------- +export const SourceSchema = z.object({ + from: z.object({ + databaseName: z.string(), + tableName: z.string(), + }), + timestampValueExpression: z.string(), + connection: z.string(), + + // Common + kind: z.enum(['log', 'trace']), + id: z.string(), + name: z.string(), + displayedTimestampValueExpression: z.string().optional(), + implicitColumnExpression: z.string().optional(), + serviceNameExpression: z.string().optional(), + bodyExpression: z.string().optional(), + tableFilterExpression: z.string().optional(), // Future use for v1 compatibility + eventAttributesExpression: z.string().optional(), + resourceAttributesExpression: z.string().optional(), + defaultTableSelectExpression: z.string().optional(), // Default SELECT for search tables + // uniqueRowIdExpression: z.string().optional(), // TODO: Allow users to configure how to identify rows uniquely + + // Logs + severityTextExpression: z.string().optional(), + traceSourceId: z.string().optional(), + + // Traces & Logs + traceIdExpression: z.string().optional(), + spanIdExpression: z.string().optional(), + + // Traces + durationExpression: z.string().optional(), + durationPrecision: z.number().min(0).max(9).optional(), + parentSpanIdExpression: z.string().optional(), + spanKindExpression: z.string().optional(), + spanNameExpression: z.string().optional(), + statusCodeExpression: z.string().optional(), + statusMessageExpression: z.string().optional(), + + logSourceId: z.string().optional(), +}); + +export type TSource = z.infer; diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 630a33a78..c7b05aeb6 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; import { + Alert, + AlertHistory, DashboardSchema, NumberFormat as _NumberFormat, SavedSearchSchema, @@ -45,6 +47,12 @@ export type LogStreamModel = KeyValuePairs & { trace_id?: string; }; +export type AlertsPageItem = Alert & { + history: AlertHistory[]; + dashboard?: ServerDashboard; + savedSearch?: LogView; +}; + // TODO: Migrate export type LogView = z.infer; diff --git a/packages/app/styles/AlertsPage.module.scss b/packages/app/styles/AlertsPage.module.scss index d23b33b57..6f7f4f4a4 100644 --- a/packages/app/styles/AlertsPage.module.scss +++ b/packages/app/styles/AlertsPage.module.scss @@ -4,6 +4,7 @@ .header { align-items: center; + background-color: $body-bg; border-bottom: 1px solid $slate-950; color: $slate-200; display: flex;