Skip to content

Commit

Permalink
Feat: Alerts per check (#1011)
Browse files Browse the repository at this point in the history
* feat: define types for CheckAlert

* feat: add AlertsPerCheck and AlertCard components in Check form

* feat: add schema for new alert fields

* feat: add useCheckAlert hook and datasource methods to fetch and update alerts

* feat: fetch alerts when creating/updating checks and format payload for api

* feat: fetch alerts when editing a check, and format response for form

* chore: mock API response (only for testing purposes)

* fix: filter alerts according to the supported checktypes

* fix: make current tests pass

- adjust schema validation to make it optional
- add test handlers for new alerts requests

* fix: remove mocked response for testing with dev API

* fix: set correct alert id to payload

- Also, invalidate cache for fetching fresh check alerts

* fix: remove alert mocks as the API is available in dev

* test: add tests for creating a check with alerts per check

* fix: remove percentiles dropdown

- Instead of grouping by alert type, I'm displaying all alerts as separate ones
- Grouping by type didn't allow to specify threshold for different percentiles

* refactor: introduce new CheckAlert types: Base, Draft and Published

* test: add mocks back temporarily

- since the alerts API is no longer in dev, I'm adding the mocks back to be able to test it
- This should be improved by #850

* refactor: avoid passing unneeded props to AlertsPerCheck

- Also, adding loading and error states

* fix: remove emtpy response from listAlertsforCheck

* refactor: move predefined alerts to external file

- Add specific predefined alerts according to the check type in a single constants file
- Display threshold units

* fix: tests

* fix: rebase conflicts

* refactor: display new alerts in list format

* chore: remove unneeded AlertCard component

* fix: tests

* fix: humanize alert labels

* fix: tests

* feat: add feature flag

- To enable the feature, set sm-alerts-per-check=true

* fix: review comments

* fix: add comment on query key

* fix: change layout

* feat: add default values for check alerts

* fix: address review comments

* fix: setting submitting form state when alerts request fails

* fix: allow to delete input values

* fix: set default value when generating form values

* fix: tests

* chore: remove checkAlert id concept (#1041)

The API is removing the id property for check alerts as they can be identified just by the name

* fix: center header and tweak description text

* chore: remove mocked data as API is available in dev

* fix: avoid setting 0 when there's no threshold value

* fix: remove success message when alerts are saved

* fix: add validation on percentage values

* fix: when alerts load, add them to form defaults

This prevents show the unsaved changes warning when there are no changes

* fix: address review comments

* fix: review comments
  • Loading branch information
VikaCep authored Jan 31, 2025
1 parent d37081e commit ef8c3f3
Show file tree
Hide file tree
Showing 25 changed files with 1,038 additions and 25 deletions.
25 changes: 25 additions & 0 deletions src/components/CheckEditor/transformations/toFormValues.alerts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { CheckAlertFormRecord, CheckAlertType } from 'types';
import { CheckAlertsResponse } from 'datasource/responses.types';
import { ALL_PREDEFINED_ALERTS } from 'components/CheckForm/AlertsPerCheck/AlertsPerCheck.constants';

export function getAlertCheckFormValues(data: CheckAlertsResponse): CheckAlertFormRecord {
return Object.keys(CheckAlertType).reduce<CheckAlertFormRecord>((acc, alertTypeKey) => {
const alertType = CheckAlertType[alertTypeKey as keyof typeof CheckAlertType];

const existingAlert = data.alerts.find((alert) => alert.name.includes(alertType));

if (existingAlert) {
acc[alertType] = {
threshold: existingAlert.threshold,
isSelected: true,
};
} else {
acc[alertType] = {
threshold: ALL_PREDEFINED_ALERTS.find((alert) => alert.type === alertType)?.default || 0,
isSelected: false,
};
}

return acc;
}, {});
}
17 changes: 17 additions & 0 deletions src/components/CheckEditor/transformations/toPayload.alerts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CheckAlertDraft, CheckAlertFormRecord, CheckAlertType } from 'types';

export function getAlertsPayload(formValues?: CheckAlertFormRecord, checkId?: number): CheckAlertDraft[] {
if (!checkId || !formValues) {
return [];
}

return Object.entries(formValues).reduce<CheckAlertDraft[]>((alerts, [alertType, alert]) => {
if (alert.isSelected) {
alerts.push({
name: alertType as CheckAlertType,
threshold: alert.threshold!!,
});
}
return alerts;
}, []);
}
99 changes: 99 additions & 0 deletions src/components/CheckForm/AlertsPerCheck/AlertItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Field, Input, Label, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';

import { CheckAlertType, CheckFormValues } from 'types';

import { useCheckFormContext } from '../CheckFormContext/CheckFormContext';
import { PredefinedAlertInterface } from './AlertsPerCheck.constants';

export const AlertItem = ({
alert,
selected,
onSelectionChange,
}: {
alert: PredefinedAlertInterface;
selected: boolean;
onSelectionChange: (type: CheckAlertType) => void;
}) => {
const styles = useStyles2(getStyles);

const { control, formState, getValues } = useFormContext<CheckFormValues>();
const { isFormDisabled } = useCheckFormContext();

const handleToggleAlert = (type: CheckAlertType) => {
onSelectionChange(type);
};

const threshold: number = getValues(`alerts.${alert.type}.threshold`);
const thresholdError = formState.errors?.alerts?.[alert.type]?.threshold?.message;

return (
<div key={alert.type} className={styles.item}>
<div className={styles.itemInfo}>
<Checkbox id={`alert-${alert.type}`} onClick={() => handleToggleAlert(alert.type)} checked={selected} />
<Label htmlFor={`alert-${alert.type}`} className={styles.columnLabel}>
{alert.name}
</Label>
</div>
<div className={styles.thresholdInput}>
<Field
label="Threshold"
htmlFor={`alert-threshold-${alert.type}`}
invalid={!!thresholdError}
error={thresholdError}
>
<Controller
name={`alerts.${alert.type}.threshold`}
control={control}
render={({ field }) => (
<Input
aria-disabled={!selected}
suffix={alert.unit}
type="number"
step="any"
id={`alert-threshold-${alert.type}`}
value={field.value !== undefined ? field.value : threshold}
onChange={(e) => {
const value = e.currentTarget.value;
return field.onChange(value !== '' ? Number(value) : undefined);
}}
width={10}
disabled={!selected || isFormDisabled}
/>
)}
/>
</Field>
</div>
</div>
);
};

const getStyles = (theme: GrafanaTheme2) => ({
item: css({
display: `flex`,
gap: theme.spacing(1),
marginLeft: theme.spacing(1),
}),

itemInfo: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
width: '50%',
textWrap: 'wrap',
}),

columnLabel: css({
fontWeight: theme.typography.fontWeightLight,
fontSize: theme.typography.h6.fontSize,
lineHeight: theme.typography.body.lineHeight,
marginBottom: '0',
}),

thresholdInput: css({
marginLeft: '22px',
}),
});
83 changes: 83 additions & 0 deletions src/components/CheckForm/AlertsPerCheck/AlertsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Label, Stack, Text, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';

import { CheckAlertFormValues, CheckAlertType } from 'types';

import { AlertItem } from './AlertItem';
import { PredefinedAlertInterface } from './AlertsPerCheck.constants';

export const AlertsList = ({
title,
alerts,
selectedAlerts,
onSelectionChange,
}: {
title: string;
alerts: PredefinedAlertInterface[];
selectedAlerts?: Partial<Record<CheckAlertType, CheckAlertFormValues>>;
onSelectionChange: (type: CheckAlertType) => void;
}) => {
const styles = useStyles2(getStyles);

const handleToggleAlert = (type: CheckAlertType) => {
onSelectionChange(type);
};

return (
<div className={styles.column}>
<div className={styles.sectionHeader}>
<Label htmlFor={`header-${title}`} className={styles.headerLabel}>
<Stack>
<Text>{title}</Text>
</Stack>
</Label>
</div>
<div className={styles.list}>
{alerts.map((alert: PredefinedAlertInterface) => (
<AlertItem
key={alert.type}
alert={alert}
selected={!!selectedAlerts?.[alert.type]?.isSelected}
onSelectionChange={() => handleToggleAlert(alert.type)}
/>
))}
</div>
</div>
);
};

const getStyles = (theme: GrafanaTheme2) => ({
column: css({
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.fontWeightLight,
flex: 1,
}),

list: css({
display: 'flex',
flexDirection: 'column',
whiteSpace: 'nowrap',
overflowY: 'auto',
}),

sectionHeader: css({
display: 'flex',
border: `1px solid ${theme.colors.border.weak}`,
backgroundColor: `${theme.colors.background.secondary}`,
padding: theme.spacing(1),
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
gap: theme.spacing(1),
verticalAlign: 'middle',
alignItems: 'center',
}),

headerLabel: css({
fontWeight: theme.typography.fontWeightLight,
fontSize: theme.typography.h5.fontSize,
color: theme.colors.text.primary,
marginBottom: 0,
}),
});
105 changes: 105 additions & 0 deletions src/components/CheckForm/AlertsPerCheck/AlertsPerCheck.constants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { CheckAlertCategory, CheckAlertType, CheckType, ThresholdUnit } from 'types';

export interface PredefinedAlertInterface {
type: CheckAlertType;
name: string;
unit: ThresholdUnit;
category: CheckAlertCategory;
default?: number;
}

const GLOBAL_PREDEFINED_ALERTS: PredefinedAlertInterface[] = [
{
type: CheckAlertType.ProbeFailedExecutionsTooHigh,
name: 'Probe Failed Executions Too High',
unit: '%',
category: CheckAlertCategory.SystemHealth,
default: 10,
},
];

const HTTP_PREDEFINED_ALERTS: PredefinedAlertInterface[] = [
{
type: CheckAlertType.HTTPRequestDurationTooHighP50,
name: 'HTTP Request Duration Too High (P50)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 300,
},
{
type: CheckAlertType.HTTPRequestDurationTooHighP90,
name: 'HTTP Request Duration Too High (P90)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 500,
},
{
type: CheckAlertType.HTTPRequestDurationTooHighP95,
name: 'HTTP Request Duration Too High (P95)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 800,
},
{
type: CheckAlertType.HTTPRequestDurationTooHighP99,
name: 'HTTP Request Duration Too High (P99)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 1500,
},
{
type: CheckAlertType.HTTPTargetCertificateCloseToExpiring,
name: 'HTTP Target Certificate Close To Expiring',
unit: 'd',
category: CheckAlertCategory.SystemHealth,
default: 60,
},
];

const PING_PREDEFINED_ALERTS: PredefinedAlertInterface[] = [
{
type: CheckAlertType.PingICMPDurationTooHighP50,
name: 'Ping ICMP Duration Too High (P50)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 50,
},
{
type: CheckAlertType.PingICMPDurationTooHighP90,
name: 'Ping ICMP Duration Too High (P90)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 100,
},
{
type: CheckAlertType.PingICMPDurationTooHighP95,
name: 'Ping ICMP Duration Too High (P95)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 200,
},
{
type: CheckAlertType.PingICMPDurationTooHighP99,
name: 'Ping ICMP Duration Too High (P99)',
unit: 'ms',
category: CheckAlertCategory.RequestDuration,
default: 400,
},
];

export const PREDEFINED_ALERTS: Record<CheckType, PredefinedAlertInterface[]> = Object.fromEntries(
Object.values(CheckType).map((checkType) => [
checkType,
[
...GLOBAL_PREDEFINED_ALERTS,
...(checkType === CheckType.HTTP ? HTTP_PREDEFINED_ALERTS : []),
...(checkType === CheckType.PING ? PING_PREDEFINED_ALERTS : []),
],
])
) as Record<CheckType, PredefinedAlertInterface[]>;

export const ALL_PREDEFINED_ALERTS: PredefinedAlertInterface[] = [
...GLOBAL_PREDEFINED_ALERTS,
...HTTP_PREDEFINED_ALERTS,
...PING_PREDEFINED_ALERTS,
];
Loading

0 comments on commit ef8c3f3

Please sign in to comment.