mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
8df54a6daa
commit
7338164612
@ -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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user