Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Frontend Port of Anomaly Alerts (Chart Alerts and AlertsPage) #469

Draft
wants to merge 1 commit into
base: cole/hdx-963-new-system-alerts-backend
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 101 additions & 28 deletions packages/app/src/AlertsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,20 @@ import {
Group,
Menu,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';

import api from './api';
import { withAppNav } from './layout';
import { Tags } from './Tags';
import type { Alert, AlertHistory, LogView } from './types';
import type { Alert, AlertData, AlertHistory } from './types';
import { AlertState } from './types';
import { FormatTime } from './useFormatTime';

import styles from '../styles/AlertsPage.module.scss';

type AlertData = Alert & {
history: AlertHistory[];
dashboard?: {
_id: string;
name: string;
charts: { id: string; name: string }[];
tags?: string[];
};
logView?: LogView;
};

const DISABLE_ALERTS_ENABLED = false;

function AlertHistoryCard({ history }: { history: AlertHistory }) {
Expand Down Expand Up @@ -280,6 +270,9 @@ function AlertDetails({ alert }: { alert: AlertData }) {
if (alert.source === 'LOG' && alert.logView) {
return alert.logView?.name;
}
if (alert.source === 'CUSTOM') {
return alert.name;
}
return '–';
}, [alert]);

Expand All @@ -290,9 +283,93 @@ function AlertDetails({ alert }: { alert: AlertData }) {
if (alert.source === 'LOG' && alert.logView) {
return `/search/${alert.logView._id}`;
}
if (alert.source === 'CUSTOM') {
const config = {
id: '',
name: `${alert.name} Dashboard`,
charts: [
{
id: '4rro4',
name: `${alert.name} Chart`,
x: 0,
y: 0,
w: 12,
h: 5,
series:
alert?.customConfig?.series.map(s => {
return {
...s,
type: 'time',
};
}) ?? [],
seriesReturnType: 'column',
},
],
};

return `/dashboards?config=${encodeURIComponent(JSON.stringify(config))}`;
}
return '';
}, [alert]);

const alertIcon = (() => {
switch (alert.source) {
case 'CHART':
return 'bi-graph-up';
case 'LOG':
return 'bi-layout-text-sidebar-reverse';
case 'CUSTOM':
return 'bi-robot';
default:
return 'bi-question';
}
})();

const alertType = React.useMemo(() => {
if (alert.source === 'LOG') {
return (
<>
If count is {alert.type === 'presence' ? 'over' : 'under'}{' '}
<span className="fw-bold">{alert.threshold}</span>
<span className="text-slate-400">&middot;</span>
</>
);
} else if (alert.source === 'CUSTOM') {
return <>If event occurrence is anomalous</>;
} else if (alert.source === 'CHART' && alert.checker?.type === 'anomaly') {
const threshold = alert.checker.config?.models?.find(
m => m.name === 'zscore',
)?.params.threshold;
return (
<>
If value exceeds {threshold} stdv from the last {alert.interval}{' '}
window average
</>
);
} else {
return (
<>
If value is {alert.type === 'presence' ? 'over' : 'under'}{' '}
<span className="fw-bold">{alert.threshold}</span>
<span className="text-slate-400">&middot;</span>
</>
);
}
}, [alert]);

const linkTitle = React.useMemo(() => {
switch (alert.source) {
case 'CHART':
return 'Dashboard chart';
case 'LOG':
return 'Saved search';
case 'CUSTOM':
return 'Custom chart';
default:
return '';
}
}, [alert]);

return (
<div className={styles.alertRow}>
<Group>
Expand All @@ -313,25 +390,14 @@ function AlertDetails({ alert }: { alert: AlertData }) {
<Link
href={alertUrl}
className={styles.alertLink}
title={
alert.source === 'CHART' ? 'Dashboard chart' : 'Saved search'
}
title={linkTitle}
>
<i
className={`bi ${
alert.source === 'CHART'
? 'bi-graph-up'
: 'bi-layout-text-sidebar-reverse'
} text-slate-200 me-2 fs-8`}
/>
<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">
If {alert.source === 'LOG' ? 'count' : 'value'} is{' '}
{alert.type === 'presence' ? 'at least' : 'under'}{' '}
<span className="fw-bold">{alert.threshold}</span>
<span className="text-slate-400">&middot;</span>
{alertType}
{alert.channel.type === 'webhook' && (
<span>Notify via Webhook</span>
)}
Expand Down Expand Up @@ -443,12 +509,19 @@ export default function AlertsPage() {
<Head>
<title>Alerts - HyperDX</title>
</Head>
<div className={styles.header}>Alerts</div>
<div className={styles.sectionHeader}>
Your Alerts
<Text size="sm" c="gray.6" mt="xs">
Alerts created from dashboard charts and saved searches
</Text>
</div>
<div className="my-4">
<Container>
<Container maw={1500}>
<MAlert
icon={<i className="bi bi-info-circle-fill text-slate-400" />}
color="gray"
py="xs"
mt="md"
>
Alerts can be{' '}
<a
Expand Down
103 changes: 76 additions & 27 deletions packages/app/src/HDXMultiSeriesTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import cx from 'classnames';
import { add } from 'date-fns';
import { withErrorBoundary } from 'react-error-boundary';
import {
Area,
Bar,
BarChart,
CartesianGrid,
ComposedChart,
Label,
Legend,
Line,
Expand Down Expand Up @@ -88,6 +90,7 @@ const HDXLineChartTooltip = withErrorBoundary(
<div className={styles.chartTooltipContent}>
{payload
.sort((a: any, b: any) => b.value - a.value)
.filter((p: any) => p.dataKey !== 'anomalyThreshold')
.map((p: any) => (
<TooltipItem
key={p.dataKey}
Expand Down Expand Up @@ -240,12 +243,17 @@ const MemoChart = memo(function MemoChart({
lineNames: string[];
lineColors: Array<string | undefined>;
alertThreshold?: number;
alertThresholdType?: 'above' | 'below';
alertThresholdType?: 'above' | 'below' | 'zscore';
displayType?: 'stacked_bar' | 'line';
numberFormat?: NumberFormat;
logReferenceTimestamp?: number;
}) {
const ChartComponent = displayType === 'stacked_bar' ? BarChart : LineChart;
const ChartComponent =
displayType === 'stacked_bar'
? BarChart
: alertThresholdType === 'zscore'
? ComposedChart
: LineChart;

const lines = useMemo(() => {
const limitedGroupKeys = groupKeys.slice(0, HARD_LINES_LIMIT);
Expand Down Expand Up @@ -323,6 +331,33 @@ const MemoChart = memo(function MemoChart({
[numberFormat],
);

const zscoreBoundaries = useMemo(() => {
let sum = 0;
let sumSquared = 0;
let count = 0;
const stdDeviation = alertThreshold ?? 3;
return graphResults.map((data, index) => {
if (index === 0) {
return {
...data,
anomalyThreshold: [0, 0],
};
}
sum += graphResults[index - 1]['series_0.data:::'];
sumSquared += graphResults[index - 1]['series_0.data:::'] ** 2;
count++;
const mean = sum / count;
const variance = sumSquared / count - mean ** 2;
const stdDev = Math.sqrt(variance);
const upperBound = mean + stdDev * stdDeviation;
const lowerBound = Math.max(mean - stdDev * stdDeviation, 0);
return {
...data,
anomalyThreshold: [lowerBound, upperBound],
};
});
}, [graphResults, alertThreshold]);

return (
<ResponsiveContainer
width="100%"
Expand All @@ -335,7 +370,11 @@ const MemoChart = memo(function MemoChart({
<ChartComponent
width={500}
height={300}
data={graphResults}
data={
alertThresholdType === 'zscore' && graphResults.length > 10
? graphResults.slice(3)
: graphResults
}
syncId="hdx"
syncMethod="value"
onClick={(state, e) => {
Expand Down Expand Up @@ -367,10 +406,14 @@ const MemoChart = memo(function MemoChart({
/>
<XAxis
dataKey={'ts_bucket'}
domain={[
dateRange[0].getTime() / 1000,
dateRange[1].getTime() / 1000,
]}
domain={
alertThresholdType === 'zscore' && graphResults.length > 10
? [
dateRange[0].getTime() / 1000,
dateRange[1].getTime() / 1000,
].slice(3)
: [dateRange[0].getTime() / 1000, dateRange[1].getTime() / 1000]
}
interval="preserveStartEnd"
scale="time"
type="number"
Expand Down Expand Up @@ -405,7 +448,24 @@ const MemoChart = memo(function MemoChart({
fillOpacity={0.05}
/>
)}
{alertThreshold != null && (
{alertThreshold != null && alertThresholdType === 'zscore' && (
<Area
type="monotone"
data={
zscoreBoundaries.length > 10
? zscoreBoundaries.slice(3)
: zscoreBoundaries
}
dataKey="anomalyThreshold"
fill="#1ef956"
fillOpacity={0.05}
strokeWidth={1}
strokeDasharray={'3 3'}
stroke="#1ef956"
connectNulls={true}
/>
)}
{alertThreshold != null && alertThresholdType !== 'zscore' && (
<ReferenceLine
y={alertThreshold}
label={<Label value="Alert Threshold" fill={'white'} />}
Expand Down Expand Up @@ -454,29 +514,18 @@ const HDXMultiSeriesTimeChart = memo(
};
onSettled?: () => void;
alertThreshold?: number;
alertThresholdType?: 'above' | 'below';
alertThresholdType?: 'above' | 'below' | 'zscore';
showDisplaySwitcher?: boolean;
defaultDisplayType?: 'stacked_bar' | 'line';
logReferenceTimestamp?: number;
}) => {
const { data, isError, isLoading } = api.useMultiSeriesChart(
{
series,
granularity,
endDate: dateRange[1] ?? new Date(),
startDate: dateRange[0] ?? new Date(),
seriesReturnType,
},
{
enabled:
series.length > 0 &&
series[0].type === 'time' &&
series[0].table === 'metrics' &&
series[0].field == null
? false
: true,
},
);
const { data, isError, isLoading } = api.useMultiSeriesChart({
series,
granularity,
endDate: dateRange[1] ?? new Date(),
startDate: dateRange[0] ?? new Date(),
seriesReturnType,
});

const tsBucketMap = new Map();
let graphResults: {
Expand Down
Loading