Skip to content

Commit

Permalink
feat: Alerts page
Browse files Browse the repository at this point in the history
  • Loading branch information
ernestii committed Jan 21, 2025
1 parent e7e9885 commit f7c2111
Show file tree
Hide file tree
Showing 8 changed files with 333 additions and 2 deletions.
2 changes: 1 addition & 1 deletion packages/api/src/routers/api/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
},
}),
Expand Down
3 changes: 3 additions & 0 deletions packages/app/pages/alerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import AlertsPage from '@/AlertsPage';

export default AlertsPage;
271 changes: 271 additions & 0 deletions packages/app/src/AlertsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 { Alert, Badge, Container, Group, Stack, Tooltip } from '@mantine/core';

import api from './api';
import { withAppNav } from './layout';
import type { AlertHistory, AlertsPageItem } from './types';
import { AlertState } 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 (
<Tooltip
label={latestHighestValue + ' ' + formatRelative(start, today)}
color="dark"
withArrow
>
<div
className={cx(
styles.historyCard,
history.state === AlertState.OK ? styles.ok : styles.alarm,
)}
/>
</Tooltip>
);
}

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 (
<div className={styles.historyCardWrapper}>
{paddingItems.map((_, index) => (
<Tooltip label="No data" color="dark" withArrow key={index}>
<div className={styles.historyCard} />
</Tooltip>
))}
{items
.slice()
.reverse()
.map((history, index) => (
<AlertHistoryCard key={index} history={history} />
))}
</div>
);
}

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 ? (
<>
<i className="bi bi-chevron-right fs-8 mx-1 text-slate-400" />
{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'}{' '}
<span className="fw-bold">{alert.threshold}</span>
<span className="text-slate-400">&middot;</span>
</>
);
}, [alert]);

const notificationMethod = React.useMemo(() => {
if (alert.channel.type === 'webhook') {
return (
<span>
Notify via <i className="bi bi-slack"></i> Webhook
</span>
);
}
}, [alert]);

const linkTitle = React.useMemo(() => {
switch (alert.source) {
case 'tile':
return 'Dashboard tile';
case 'saved_search':
return 'Saved search';
default:
return '';
}
}, [alert]);

return (
<div className={styles.alertRow}>
<Group>
{alert.state === AlertState.ALERT && (
<Badge variant="light" color="red">
Alert
</Badge>
)}
{alert.state === AlertState.OK && <Badge variant="light">Ok</Badge>}
{alert.state === AlertState.DISABLED && (
<Badge variant="light" color="gray">
Disabled
</Badge>
)}

<Stack gap={2}>
<div>
<Link
href={alertUrl}
className={styles.alertLink}
title={linkTitle}
>
<i className={`bi ${alertIcon} text-slate-200 me-2 fs-8`} />
{alertName}
</Link>
</div>
<div className="text-slate-400 fs-8 d-flex gap-2">
{alertType}
{notificationMethod}
</div>
</Stack>
</Group>

<Group>
<AlertHistoryCardList history={alert.history} />
</Group>
</div>
);
}

function AlertCardList({ alerts }: { alerts: AlertsPageItem[] }) {
const alarmAlerts = alerts.filter(alert => alert.state === AlertState.ALERT);
const okData = alerts.filter(alert => alert.state === AlertState.OK);

return (
<div className="d-flex flex-column gap-4">
{alarmAlerts.length > 0 && (
<div>
<div className={styles.sectionHeader}>
<i className="bi bi-exclamation-triangle"></i> Triggered
</div>
{alarmAlerts.map((alert, index) => (
<AlertDetails key={index} alert={alert} />
))}
</div>
)}
<div>
<div className={styles.sectionHeader}>
<i className="bi bi-check-lg"></i> OK
</div>
{okData.length === 0 && (
<div className="text-center text-slate-400 my-4 fs-8">No alerts</div>
)}
{okData.map((alert, index) => (
<AlertDetails key={index} alert={alert} />
))}
</div>
</div>
);
}

export default function AlertsPage() {
const { data, isError, isLoading } = api.useAlerts();

const alerts = React.useMemo(() => data?.data || [], [data?.data]);

return (
<div className="AlertsPage">
<Head>
<title>Alerts - HyperDX</title>
</Head>
<div className={styles.header}>Alerts</div>
<div className="my-4">
<Container maw={1500}>
<Alert
icon={<i className="bi bi-info-circle-fill text-slate-400" />}
color="gray"
py="xs"
mt="md"
>
Alerts can be{' '}
<a
href="https://www.hyperdx.io/docs/alerts"
target="_blank"
rel="noopener noreferrer"
>
created
</a>{' '}
from dashboard charts and saved searches.
</Alert>
{isLoading ? (
<div className="text-center text-slate-400 my-4 fs-8">
Loading...
</div>
) : isError ? (
<div className="text-center text-slate-400 my-4 fs-8">Error</div>
) : alerts?.length ? (
<>
<AlertCardList alerts={alerts} />
</>
) : (
<div className="text-center text-slate-400 my-4 fs-8">
No alerts created yet
</div>
)}
</Container>
</div>
</div>
);
}

AlertsPage.getLayout = withAppNav;
16 changes: 16 additions & 0 deletions packages/app/src/AppNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,22 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
</span>
</Link>
</div>
<div className="px-3 my-3">
<Link
href="/alerts"
className={cx(
'text-decoration-none d-flex justify-content-between align-items-center fs-7 text-muted-hover',
{
'fw-bold text-success': pathname.includes('/alerts'),
},
)}
>
<span>
<i className="bi bi-bell pe-1 text-slate-300" />{' '}
{!isCollapsed && <span>Alerts</span>}
</span>
</Link>
</div>
{/* <div className="px-3 my-3">
<Link
href="/sessions"
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { IS_LOCAL_MODE } from './config';
import type {
Alert,
AlertsPageItem,
ChartSeries,
LogView,
MetricsDataType,
Expand All @@ -32,6 +33,10 @@ type ServicesResponse = {
>;
};

type AlertsResponse = {
data: AlertsPageItem[];
};

type MultiSeriesChartInput = {
series: ChartSeries[];
endDate: Date;
Expand Down Expand Up @@ -561,6 +566,12 @@ const api = {
}).json(),
});
},
useAlerts() {
return useQuery({
queryKey: [`alerts`],
queryFn: () => hdxServer(`alerts`).json() as Promise<AlertsResponse>,
});
},
useServices() {
return useQuery({
queryKey: [`services`],
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ 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(),
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(),
});
Expand Down Expand Up @@ -64,6 +72,7 @@ export type SavedSearch = z.infer<typeof SavedSearchSchema>;
export const SavedChartConfigSchema = z.any();

export const TileSchema = z.object({
name: z.string().optional(),
id: z.string(),
x: z.number(),
y: z.number(),
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ export type AlertChannelType = 'webhook';

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

export enum AlertState {
ALERT = 'ALERT',
DISABLED = 'DISABLED',
INSUFFICIENT_DATA = 'INSUFFICIENT_DATA',
OK = 'OK',
}

export type AlertHistory = {
counts: number;
createdAt: string;
lastValues: { startTime: string; count: number }[];
state: AlertState;
};

export type AlertsPageItem = Alert & {
history: AlertHistory[];
dashboard?: ServerDashboard;
savedSearch?: LogView;
};

// {
// _id: string;
// createdAt: string;
Expand Down
Loading

0 comments on commit f7c2111

Please sign in to comment.