diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 762c79bb662..a761134eaf9 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -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 | 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, }; }, }), diff --git a/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx b/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx new file mode 100644 index 00000000000..beff7898e50 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/AlertInstanceModalSelector.tsx @@ -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(); + const [selectedInstances, setSelectedInstances] = useState(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 = useMemo(() => { + const rules: Record = {}; + 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 = useMemo(() => { + const filteredRules = Object.keys(rulesWithInstances).filter((rule) => + rule.toLowerCase().includes(ruleFilter.toLowerCase()) + ); + const filteredRulesObject: Record = {}; + 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 ( + + ); + }; + + 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 ( + + ); + }; + + 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 ( +
+ +
+ +
{(selectedRule && 'Select one or more instances from the list below') || ''}
+ +
+ {loading && } + + {!loading && ( + + {({ height, width }) => ( + + {RuleRow} + + )} + + )} +
+ +
+ {!selectedRule && !loading && ( +
+
Select an alert rule to get a list of available instances
+
+ )} + {loading && } + + {selectedRule && rulesWithInstances[selectedRule].length && !loading && ( + + {({ width, height }) => ( + + {InstanceRow} + + )} + + )} +
+
+ + + + +
+
+ ); +} + +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; + `, + }; +}; diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx index 20ab3d80e31..a39cbdbad48 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx @@ -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 (
@@ -93,17 +112,34 @@ export function PayloadEditor({ > Add alert data + + + {payloadFormatError !== null && ( )}
+ + setIsAlertSelectorOpen(false)} + /> ); } diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 52299c0a047..72c28266b14 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -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( diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts index 58fa467f698..5471520a3f5 100644 --- a/public/app/features/alerting/unified/utils/labels.ts +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -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; +}