Alerting: Add alert instance picker (#67138)

* Add Preview template and payload editor to templates form

* Add TemplatePreview test and update css

* Preview errors for each template that is wrong

* Enable preview templating only for Grafana Alert Manager

* Use harcoded default payload instead of requesting it to the backend

* Update error response in the api definition

* Add spinner when loading result for preview

* Update api request followind DD changes

* Use pre instead of TextArea to render the preview

* Fix tests

* Add alert list editor

* Add start and end time for alert generator

* Add preview for data list added in the modal

* Update copies and move submit button in alert generator to the bottom

* Copy updates

* Refactor

* Use tab instead of button to preview

* Move payload editor next to the content

* Copy update

* Refactor

* Adress PR review comments

* Fix wrong json format throwing an exception when adding more data

* Use monaco editor for payload

* Only show text 'Preview for...'  when we have more than one define

* Fix some errors

* Update CollapseSection style

* Add tooltip for the Payload info icon explaining the available list of alert data fields in preview

* Set payload as invalid if it's not an array

* Fix test

* Update text in AlertTemplateDataTable

* Add separators to distinguish lines that belong to the preview

* Use harcoded default payload instead of requesting it to the backend

* Add alert instance picker

* Add rule search capability and cleanup

* Display alert instance extra information on hover

* Rebase and integrate with existing view

* Display folder under rule name

* Display unique labels for alert instances

* Remove unneeded interface

* Reset state after closing the modal

* Refactor useEffect and useMemo

* Move common code to variable

* Refactor to avoid setting filtered rules as state

* Disable instance selector button when there are errors in the payload

* Validate payload on button click

* Change warning text

* Add support for state filters in alertmanager alerts request

* Use RTK Query to fetch alert instances

* Address review comments

* Fix lint

---------

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
Virginia Cepeda 2023-04-28 12:58:15 -03:00 committed by GitHub
parent 8df54a6daa
commit 7338164612
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 448 additions and 4 deletions

View File

@ -40,9 +40,23 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
?.filter((matcher) => matcher.name && matcher.value)
.map((matcher) => `${matcher.name}${matcherToOperator(matcher)}${matcher.value}`);
const { silenced, inhibited, unprocessed, active } = filter || {};
const stateParams = Object.fromEntries(
Object.entries({ silenced, active, inhibited, unprocessed }).filter(([_, value]) => value !== undefined)
);
const params: Record<string, unknown> | undefined = { filter: filterMatchers };
if (stateParams) {
Object.keys(stateParams).forEach((key: string) => {
params[key] = stateParams[key];
});
}
return {
url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts`,
params: { filter: filterMatchers },
params,
};
},
}),

View File

@ -0,0 +1,380 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, useCallback, useMemo, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import {
Button,
clearButtonStyles,
FilterInput,
LoadingPlaceholder,
Modal,
Tooltip,
useStyles2,
Icon,
Tag,
} from '@grafana/ui';
import { AlertmanagerAlert, TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { arrayLabelsToObject, labelsToTags, objectLabelsToArray } from '../../utils/labels';
import { extractCommonLabels, omitLabels } from '../rules/state-history/common';
export function AlertInstanceModalSelector({
onSelect,
isOpen,
onClose,
}: {
onSelect: (alerts: TestTemplateAlert[]) => void;
isOpen: boolean;
onClose: () => void;
}) {
const styles = useStyles2(getStyles);
const [selectedRule, setSelectedRule] = useState<string>();
const [selectedInstances, setSelectedInstances] = useState<AlertmanagerAlert[] | null>(null);
const { useGetAlertmanagerAlertsQuery } = alertmanagerApi;
const {
currentData: result = [],
isFetching: loading,
isError: error,
} = useGetAlertmanagerAlertsQuery({
amSourceName: GRAFANA_RULES_SOURCE_NAME,
filter: {
inhibited: true,
silenced: true,
active: true,
},
});
const [ruleFilter, setRuleFilter] = useState('');
const rulesWithInstances: Record<string, AlertmanagerAlert[]> = useMemo(() => {
const rules: Record<string, AlertmanagerAlert[]> = {};
if (!loading && result) {
result.forEach((instance) => {
if (!rules[instance.labels['alertname']]) {
rules[instance.labels['alertname']] = [];
}
rules[instance.labels['alertname']].push(instance);
});
}
return rules;
}, [loading, result]);
const handleRuleChange = useCallback((rule: string) => {
setSelectedRule(rule);
setSelectedInstances(null);
}, []);
const filteredRules: Record<string, AlertmanagerAlert[]> = useMemo(() => {
const filteredRules = Object.keys(rulesWithInstances).filter((rule) =>
rule.toLowerCase().includes(ruleFilter.toLowerCase())
);
const filteredRulesObject: Record<string, AlertmanagerAlert[]> = {};
filteredRules.forEach((rule) => {
filteredRulesObject[rule] = rulesWithInstances[rule];
});
return filteredRulesObject;
}, [rulesWithInstances, ruleFilter]);
if (error) {
return null;
}
const filteredRulesKeys = Object.keys(filteredRules || []);
const RuleRow = ({ index, style }: { index: number; style?: CSSProperties }) => {
if (!filteredRules) {
return null;
}
const ruleName = filteredRulesKeys[index];
const isSelected = ruleName === selectedRule;
return (
<button
type="button"
title={ruleName}
style={style}
className={cx(styles.rowButton, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })}
onClick={() => handleRuleChange(ruleName)}
>
<div className={cx(styles.ruleTitle, styles.rowButtonTitle)}>{ruleName}</div>
<div className={styles.alertFolder}>
<>
<Icon name="folder" /> {filteredRules[ruleName][0].labels['grafana_folder'] ?? ''}
</>
</div>
</button>
);
};
const getAlertUniqueLabels = (allAlerts: AlertmanagerAlert[], currentAlert: AlertmanagerAlert) => {
const allLabels = allAlerts.map((alert) => alert.labels);
const labelsAsArray = allLabels.map(objectLabelsToArray);
const ruleCommonLabels = extractCommonLabels(labelsAsArray);
const alertUniqueLabels = omitLabels(objectLabelsToArray(currentAlert.labels), ruleCommonLabels);
const tags = alertUniqueLabels.length
? labelsToTags(arrayLabelsToObject(alertUniqueLabels))
: labelsToTags(currentAlert.labels);
return tags;
};
const InstanceRow = ({ index, style }: { index: number; style: CSSProperties }) => {
const alerts = useMemo(() => (selectedRule ? rulesWithInstances[selectedRule] : []), []);
const alert = alerts[index];
const isSelected = selectedInstances?.includes(alert);
const tags = useMemo(() => getAlertUniqueLabels(alerts, alert), [alerts, alert]);
const handleSelectInstances = () => {
if (isSelected && selectedInstances) {
setSelectedInstances(selectedInstances.filter((instance) => instance !== alert));
return;
}
setSelectedInstances([...(selectedInstances || []), alert]);
};
return (
<button
type="button"
style={style}
className={cx(styles.rowButton, styles.instanceButton, {
[styles.rowOdd]: index % 2 === 1,
[styles.rowSelected]: isSelected,
})}
onClick={handleSelectInstances}
>
<div className={styles.rowButtonTitle} title={alert.labels['alertname']}>
<Tooltip placement="bottom" content={<pre>{JSON.stringify(alert, null, 2)}</pre>} theme={'info'}>
<div>
{tags.map((tag, index) => (
<Tag key={index} name={tag} className={styles.tag} />
))}
</div>
</Tooltip>
</div>
</button>
);
};
const handleConfirm = () => {
const instances: TestTemplateAlert[] =
selectedInstances?.map((instance: AlertmanagerAlert) => {
const alert: TestTemplateAlert = {
annotations: instance.annotations,
labels: instance.labels,
startsAt: instance.startsAt,
endsAt: instance.endsAt,
};
return alert;
}) || [];
onSelect(instances);
resetState();
};
const resetState = () => {
setSelectedRule(undefined);
setSelectedInstances(null);
setRuleFilter('');
handleSearchRules('');
};
const onDismiss = () => {
resetState();
onClose();
};
const handleSearchRules = (filter: string) => {
setRuleFilter(filter);
};
return (
<div>
<Modal
title="Select alert instances"
className={styles.modal}
closeOnEscape
isOpen={isOpen}
onDismiss={onDismiss}
contentClassName={styles.modalContent}
>
<div className={styles.container}>
<FilterInput
value={ruleFilter}
onChange={handleSearchRules}
title="Search alert rule"
placeholder="Search alert rule"
autoFocus
/>
<div>{(selectedRule && 'Select one or more instances from the list below') || ''}</div>
<div className={styles.column}>
{loading && <LoadingPlaceholder text="Loading rules..." className={styles.loadingPlaceholder} />}
{!loading && (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList itemSize={50} height={height} width={width} itemCount={filteredRulesKeys.length}>
{RuleRow}
</FixedSizeList>
)}
</AutoSizer>
)}
</div>
<div className={styles.column}>
{!selectedRule && !loading && (
<div className={styles.selectedRulePlaceholder}>
<div>Select an alert rule to get a list of available instances</div>
</div>
)}
{loading && <LoadingPlaceholder text="Loading rule..." className={styles.loadingPlaceholder} />}
{selectedRule && rulesWithInstances[selectedRule].length && !loading && (
<AutoSizer>
{({ width, height }) => (
<FixedSizeList
itemSize={32}
height={height}
width={width}
itemCount={rulesWithInstances[selectedRule].length || 0}
>
{InstanceRow}
</FixedSizeList>
)}
</AutoSizer>
)}
</div>
</div>
<Modal.ButtonRow>
<Button type="button" variant="secondary" onClick={onDismiss}>
Cancel
</Button>
<Button
type="button"
variant="primary"
disabled={!(selectedRule && selectedInstances)}
onClick={() => {
if (selectedRule && selectedInstances) {
handleConfirm();
}
}}
>
Add alert data to payload
</Button>
</Modal.ButtonRow>
</Modal>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const clearButton = clearButtonStyles(theme);
return {
container: css`
display: grid;
grid-template-columns: 1fr 1.5fr;
grid-template-rows: min-content auto;
gap: ${theme.spacing(2)};
flex: 1;
`,
tag: css`
margin: 5px;
`,
column: css`
flex: 1 1 auto;
`,
alertLabels: css`
overflow-x: auto;
height: 32px;
`,
ruleTitle: css`
height: 22px;
font-weight: ${theme.typography.fontWeightBold};
`,
rowButton: css`
${clearButton};
padding: ${theme.spacing(0.5)};
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
white-space: nowrap;
cursor: pointer;
border: 2px solid transparent;
&:disabled {
cursor: not-allowed;
color: ${theme.colors.text.disabled};
}
`,
rowButtonTitle: css`
overflow-x: auto;
`,
rowSelected: css`
border-color: ${theme.colors.primary.border};
`,
rowOdd: css`
background-color: ${theme.colors.background.secondary};
`,
instanceButton: css`
display: flex;
gap: ${theme.spacing(1)};
justify-content: space-between;
align-items: center;
`,
loadingPlaceholder: css`
height: 100%;
display: flex;
justify-content: center;
align-items: center;
`,
selectedRulePlaceholder: css`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
font-weight: ${theme.typography.fontWeightBold};
`,
modal: css`
height: 100%;
`,
modalContent: css`
flex: 1;
display: flex;
flex-direction: column;
`,
modalAlert: css`
flex-grow: 0;
`,
warnIcon: css`
fill: ${theme.colors.warning.main};
`,
labels: css`
justify-content: flex-start;
`,
alertFolder: css`
height: 20px;
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
display: flex;
flex-direction: row;
justify-content: flex-start;
column-gap: ${theme.spacing(1)};
align-items: center;
`,
};
};

View File

@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Button, CodeEditor, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { AlertInstanceModalSelector } from './AlertInstanceModalSelector';
import { AlertTemplatePreviewData } from './TemplateData';
import { TemplateDataTable } from './TemplateDataDocs';
import { GenerateAlertDataModal } from './form/GenerateAlertDataModal';
@ -39,25 +40,43 @@ export function PayloadEditor({
const errorInPayloadJson = payloadFormatError !== null;
const onOpenEditAlertModal = () => {
const validatePayload = () => {
try {
const payloadObj = JSON.parse(payload);
JSON.stringify([...payloadObj]); // check if it's iterable, in order to be able to add more data
setIsEditingAlertData(true);
setPayloadFormatError(null);
} catch (e) {
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
onPayloadError();
throw e;
}
};
const onOpenEditAlertModal = () => {
try {
validatePayload();
setIsEditingAlertData(true);
} catch (e) {}
};
const onOpenAlertSelectorModal = () => {
try {
validatePayload();
setIsAlertSelectorOpen(true);
} catch (e) {}
};
const onAddAlertList = (alerts: TestTemplateAlert[]) => {
onCloseEditAlertModal();
setIsAlertSelectorOpen(false);
setPayload((payload) => {
const payloadObj = JSON.parse(payload);
return JSON.stringify([...payloadObj, ...alerts], undefined, 2);
});
};
const [isAlertSelectorOpen, setIsAlertSelectorOpen] = useState(false);
return (
<div className={styles.wrapper}>
<div className={styles.editor}>
@ -93,17 +112,34 @@ export function PayloadEditor({
>
Add alert data
</Button>
<Button
type="button"
variant="secondary"
icon="bell"
disabled={errorInPayloadJson}
onClick={onOpenAlertSelectorModal}
>
Choose alert instances
</Button>
{payloadFormatError !== null && (
<Badge
color="orange"
icon="exclamation-triangle"
text={'There are some errors in payload JSON.'}
text={'JSON Error'}
tooltip={'Fix errors in payload, and click Refresh preview button'}
/>
)}
</div>
</div>
<GenerateAlertDataModal isOpen={isEditingAlertData} onDismiss={onCloseEditAlertModal} onAccept={onAddAlertList} />
<AlertInstanceModalSelector
onSelect={onAddAlertList}
isOpen={isAlertSelectorOpen}
onClose={() => setIsAlertSelectorOpen(false)}
/>
</div>
);
}

View File

@ -316,6 +316,7 @@ function getErrorsToRender(results: TemplatePreviewErrors[]) {
})
.join(`\n`);
}
export const PREVIEW_NOT_AVAILABLE = 'Preview request failed. Check if the payload data has the correct structure.';
function getPreviewTorender(

View File

@ -1,7 +1,20 @@
import { Labels } from '../../../../types/unified-alerting-dto';
import { Label } from '../components/rules/state-history/common';
export function labelsToTags(labels: Labels) {
return Object.entries(labels)
.map(([label, value]) => `${label}=${value}`)
.sort();
}
export function objectLabelsToArray(labels: Labels): Label[] {
return Object.entries(labels).map(([label, value]) => [label, value]);
}
export function arrayLabelsToObject(labels: Label[]): Labels {
const labelsObject: Labels = {};
labels.forEach((label: Label) => {
labelsObject[label[0]] = label[1];
});
return labelsObject;
}