From 8e5311683a4f8437860abb89e7066f9ba26dd6de Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 1 May 2025 15:24:10 +0100 Subject: [PATCH] WIP for label autocomplete --- .../unified/components/AlertLabelDropdown.tsx | 47 +---- .../EditNotificationPolicyForm.tsx | 9 +- .../rule-editor/labels/LabelsField.tsx | 91 ++++---- .../rule-editor/useAlertRuleSuggestions.tsx | 34 ++- .../components/silences/MatchersField.tsx | 195 +++++++++--------- .../components/silences/SilencesTable.tsx | 2 +- .../alerting/unified/utils/alertmanager.ts | 4 +- public/locales/en-US/grafana.json | 7 +- 8 files changed, 188 insertions(+), 201 deletions(-) diff --git a/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx b/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx index 62da243bba5..e8504664456 100644 --- a/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx +++ b/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx @@ -1,61 +1,32 @@ import { css } from '@emotion/css'; import { FC, forwardRef } from 'react'; -import { GroupBase, OptionsOrGroups, createFilter } from 'react-select'; -import { SelectableValue } from '@grafana/data'; -import { Field, Select, useStyles2 } from '@grafana/ui'; +import { Combobox, ComboboxOption, Field, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; export interface AlertLabelDropdownProps { - onChange: (newValue: SelectableValue) => void; + onChange: (newValue: ComboboxOption | null) => void; onOpenMenu?: () => void; - options: SelectableValue[]; - defaultValue?: SelectableValue; + options: Array>; + value?: ComboboxOption; type: 'key' | 'value'; } -const _customFilter = createFilter({ ignoreCase: false }); -function customFilter(opt: SelectableValue, searchQuery: string) { - return _customFilter( - { - label: opt.label ?? '', - value: opt.value ?? '', - data: {}, - }, - searchQuery - ); -} - -const handleIsValidNewOption = ( - inputValue: string, - _: SelectableValue | null, - options: OptionsOrGroups, GroupBase>> -) => { - const exactValueExists = options.some((el) => el.label === inputValue); - const valueIsNotEmpty = inputValue.trim().length; - return !Boolean(exactValueExists) && Boolean(valueIsNotEmpty); -}; const AlertLabelDropdown: FC = forwardRef( - function LabelPicker({ onChange, options, defaultValue, type, onOpenMenu = () => {} }, ref) { + function LabelPicker({ onChange, options, value, type, onOpenMenu = () => {} }, ref) { const styles = useStyles2(getStyles); return (
- +
diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx index 6023b069deb..35a7b23bef7 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx @@ -36,6 +36,7 @@ import { stringToSelectableValue, stringsToSelectableValues, } from '../../utils/amroutes'; +import MatchersField from '../silences/MatchersField'; import { PromDurationInput } from './PromDurationInput'; import { getFormStyles } from './formStyles'; @@ -67,17 +68,17 @@ export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults object_matchers: route ? formAmRoute.object_matchers : emptyMatcher, }; + const formAPI = useForm({ + defaultValues, + }); const { - handleSubmit, control, register, formState: { errors }, setValue, watch, getValues, - } = useForm({ - defaultValues, - }); + } = formAPI; const { fields, append, remove } = useFieldArray({ control, name: 'object_matchers', diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 6fd7d04a915..e216e0b5623 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -2,8 +2,19 @@ import { css, cx } from '@emotion/css'; import { FC, useCallback, useMemo, useState } from 'react'; import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Space, Stack, Text, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { + Button, + ComboboxOption, + Field, + InlineLabel, + Input, + LoadingPlaceholder, + Space, + Stack, + Text, + useStyles2, +} from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { labelsApi } from '../../../api/labelsApi'; @@ -28,10 +39,16 @@ const useGetOpsLabelsKeys = (skip: boolean) => { function mapLabelsToOptions( items: Iterable = [], - labelsInSubForm?: Array<{ key: string; value: string }> -): Array> { + labelsInSubForm?: Array<{ key: string; value: string }>, + groupName?: string +): Array> { const existingKeys = new Set(labelsInSubForm ? labelsInSubForm.map((label) => label.key) : []); - return Array.from(items, (item) => ({ label: item, value: item, isDisabled: existingKeys.has(item) })); + return Array.from(items, (item) => ({ + label: item, + value: item, + isDisabled: existingKeys.has(item), + group: groupName, + })); } export interface LabelsInRuleProps { @@ -108,7 +125,7 @@ export function LabelsSubForm({ dataSourceName, onClose, initialLabels }: Labels const isKeyAllowed = (labelKey: string) => !isPrivateLabelKey(labelKey); -export function useCombinedLabels( +function useCombinedLabels( dataSourceName: string, labelsPluginInstalled: boolean, loadingLabelsPlugin: boolean, @@ -131,27 +148,19 @@ export function useCombinedLabels( //------- Convert the keys from the ops labels to options for the dropdown const keysFromGopsLabels = useMemo(() => { - return mapLabelsToOptions(Object.keys(labelsByKeyOps).filter(isKeyAllowed), labelsInSubform); + return mapLabelsToOptions(Object.keys(labelsByKeyOps).filter(isKeyAllowed), labelsInSubform, 'From system'); }, [labelsByKeyOps, labelsInSubform]); //------- Convert the keys from the existing alerts to options for the dropdown const keysFromExistingAlerts = useMemo(() => { - return mapLabelsToOptions(Array.from(labelsByKeyFromExisingAlerts.keys()).filter(isKeyAllowed), labelsInSubform); + return mapLabelsToOptions( + Array.from(labelsByKeyFromExisingAlerts.keys()).filter(isKeyAllowed), + labelsInSubform, + 'From alerts' + ); }, [labelsByKeyFromExisingAlerts, labelsInSubform]); - // create two groups of labels, one for ops and one for custom - const groupedOptions = [ - { - label: 'From alerts', - options: keysFromExistingAlerts, - expanded: true, - }, - { - label: 'From system', - options: keysFromGopsLabels, - expanded: true, - }, - ]; + const groupedOptions = [...keysFromExistingAlerts, ...keysFromGopsLabels]; const selectedKeyIsFromAlerts = labelsByKeyFromExisingAlerts.has(selectedKey); const selectedKeyIsFromOps = labelsByKeyOps[selectedKey] !== undefined && labelsByKeyOps[selectedKey]?.size > 0; @@ -249,7 +258,7 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP ); const [selectedKey, setSelectedKey] = useState(''); - const { loading, keysFromExistingAlerts, groupedOptions, getValuesForLabel } = useCombinedLabels( + const { loading, groupedOptions, getValuesForLabel } = useCombinedLabels( dataSourceName, labelsPluginInstalled, loadingLabelsPlugin, @@ -276,7 +285,6 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP return (
{ - onChange(newValue.value); - setSelectedKey(newValue.value); + value={field.key ? { label: field.key, value: field.key } : undefined} + options={groupedOptions} + onChange={(newValue: ComboboxOption | null) => { + onChange(newValue ? newValue.value : ''); + setSelectedKey(newValue?.value || ''); }} type="key" /> @@ -303,7 +311,6 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP = { - onChange(newValue.value); + onChange={(newValue: ComboboxOption | null) => { + onChange(newValue ? newValue.value : ''); }} onOpenMenu={() => { setSelectedKey(labelsInSubform[index].key); @@ -342,7 +349,7 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP ); } -export const LabelsWithoutSuggestions: FC = () => { +const LabelsWithoutSuggestions: FC = () => { const styles = useStyles2(getStyles); const { register, @@ -363,11 +370,7 @@ export const LabelsWithoutSuggestions: FC = () => { return (
- + { /> = - + { justifyContent: 'flex-start', }), centerAlignRow: css({ - alignItems: 'center', + alignItems: 'start', gap: theme.spacing(0.5), }), equalSign: css({ @@ -469,10 +468,6 @@ const getStyles = (theme: GrafanaTheme2) => { justifyContent: 'center', margin: 0, }), - labelInput: css({ - width: '175px', - margin: 0, - }), confirmButton: css({ display: 'flex', flexDirection: 'row', diff --git a/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx b/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx index c74b1cf2045..33e236419a6 100644 --- a/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx @@ -7,6 +7,7 @@ import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; +import { isPrivateLabelKey } from '../../utils/labels'; const { usePrometheusRuleNamespacesQuery, useLazyRulerRulesQuery, useRulerRulesQuery } = alertRuleApi; const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; @@ -14,8 +15,10 @@ const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); const emptyRulerConfig: RulerRulesConfigDTO = {}; -export function useGetLabelsFromDataSourceName(rulesSourceName: string) { - const { data: features, isLoading: isFeaturesLoading } = useDiscoverDsFeaturesQuery({ rulesSourceName }); +export function useGetLabelsFromDataSourceName(rulesSourceName?: string, includeAlerts = false) { + const { data: features, isLoading: isFeaturesLoading } = useDiscoverDsFeaturesQuery( + rulesSourceName ? { rulesSourceName } : skipToken + ); // emptyRulerConfig is used to prevent from triggering labels' useMemo all the time // rulerRules = {} creates a new object and triggers useMemo to recalculate labels @@ -23,8 +26,7 @@ export function useGetLabelsFromDataSourceName(rulesSourceName: string) { useLazyRulerRulesQuery(); const { data: promNamespaces = [], isLoading: isPrometheusRulesLoading } = usePrometheusRuleNamespacesQuery( - { ruleSourceName: rulesSourceName }, - { skip: !prometheusRulesPrimary } + rulesSourceName && prometheusRulesPrimary ? { ruleSourceName: rulesSourceName } : skipToken ); useEffect(() => { @@ -39,11 +41,11 @@ export function useGetLabelsFromDataSourceName(rulesSourceName: string) { } if (prometheusRulesPrimary) { - return promNamespacesToLabels(promNamespaces); + return promNamespacesToLabels(promNamespaces, includeAlerts); } return rulerRulesToLabels(rulerRules); - }, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading]); + }, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading, includeAlerts]); return { labels, isLoading: isPrometheusRulesLoading || isRulerRulesLoading || isFeaturesLoading }; } @@ -128,7 +130,11 @@ export function rulerRulesToNamespaceGroups(rulerConfig: RulerRulesConfigDTO) { return result; } -function promNamespacesToLabels(promNamespace: RuleNamespace[]) { +function promNamespacesToLabels( + promNamespace: RuleNamespace[], + /** Should we also parse out labels from the alerts, when present? */ + includeAlerts = false +) { const rules = promNamespace.flatMap((namespace) => namespace.groups).flatMap((group) => group.rules); return rules.reduce((result, rule) => { @@ -136,8 +142,17 @@ function promNamespacesToLabels(promNamespace: RuleNamespace[]) { return result; } - Object.entries(rule.labels).forEach(([labelKey, labelValue]) => { - if (!labelKey || !labelValue) { + const alertsToCheck = + includeAlerts && 'alerts' in rule + ? (rule?.alerts || []).flatMap((alert) => { + return Object.entries(alert.labels); + }) + : []; + + const toCheck = [...Object.entries(rule.labels), ...alertsToCheck]; + + toCheck.forEach(([labelKey, labelValue]) => { + if (!labelKey || !labelValue || isPrivateLabelKey(labelKey)) { return; } @@ -148,6 +163,7 @@ function promNamespacesToLabels(promNamespace: RuleNamespace[]) { result.set(labelKey, new Set([labelValue])); } }); + return result; }, new Map>()); } diff --git a/public/app/features/alerting/unified/components/silences/MatchersField.tsx b/public/app/features/alerting/unified/components/silences/MatchersField.tsx index a03545eacd2..ef9076f5fb9 100644 --- a/public/app/features/alerting/unified/components/silences/MatchersField.tsx +++ b/public/app/features/alerting/unified/components/silences/MatchersField.tsx @@ -1,82 +1,102 @@ import { css, cx } from '@emotion/css'; -import { useEffect } from 'react'; +import { skipToken } from '@reduxjs/toolkit/query'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Divider, Field, IconButton, Input, Select, useStyles2 } from '@grafana/ui'; +import { Button, Combobox, ComboboxOption, Divider, Field, IconButton, Input, Stack, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { SilenceFormFields } from '../../types/silence-form'; import { matcherFieldOptions } from '../../utils/alertmanager'; +import AlertLabelDropdown from '../AlertLabelDropdown'; +import { useGetLabelsFromDataSourceName } from '../rule-editor/useAlertRuleSuggestions'; interface Props { - className?: string; required: boolean; ruleUid?: string; } -const MatchersField = ({ className, required, ruleUid }: Props) => { +function mapToOptions(items: Iterable = []): Array> { + return Array.from(items, (item) => ({ label: item, value: item })); +} +const { useGetAlertRuleQuery } = alertRuleApi; + +const MatchersField = ({ required, ruleUid }: Props) => { const styles = useStyles2(getStyles); const formApi = useFormContext(); const { control, - register, + watch, + setValue, formState: { errors }, } = formApi; + const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager(); + + const matchersWatch = watch('matchers'); + + const { labels } = useGetLabelsFromDataSourceName(isGrafanaAlertmanager ? selectedAlertmanager : undefined, true); + const labelsArray = mapToOptions(Array.from(labels.keys())); const { fields: matchers = [], append, remove } = useFieldArray({ name: 'matchers' }); - const [getAlertRule, { data: alertRule }] = alertRuleApi.endpoints.getAlertRule.useLazyQuery(); - useEffect(() => { - // If we have a UID, fetch the alert rule details so we can display the rule name - if (ruleUid) { - getAlertRule({ uid: ruleUid }); - } - }, [getAlertRule, ruleUid]); + const { data: alertRule } = useGetAlertRuleQuery(ruleUid ? { uid: ruleUid } : skipToken); return ( -
- -
-
- {alertRule && ( -
- - - - -
- )} - {matchers.map((matcher, index) => { - return ( -
+ +
+
+ {alertRule && ( +
+ + + + +
+ )} + {matchers.map((matcher, index) => { + return ( +
+ - { + return ( + { + onChange(newValue ? newValue.value : ''); + setValue(`matchers.${index}.value`, ''); + }} + type="key" + /> + ); + }} /> + ( - { + const labelValue = matchersWatch.at(index)?.name; + const labelValues = labelValue ? labels.get(labelValue) : []; + const labelValuesArray = mapToOptions(labelValues); + return ( + { + onChange(newValue ? newValue.value : ''); + }} + type="value" + /> + ); + }} /> {(matchers.length > 1 || !required) && ( remove(index)} > Remove )} -
- ); - })} -
- + +
+ ); + })}
- -
+ +
+
); }; const getStyles = (theme: GrafanaTheme2) => { return { - wrapper: css({ - marginTop: theme.spacing(2), - }), row: css({ - marginTop: theme.spacing(1), - display: 'flex', - alignItems: 'flex-start', - flexDirection: 'row', + padding: theme.spacing(1, 1, 0, 1), backgroundColor: theme.colors.background.secondary, - padding: `${theme.spacing(1)} ${theme.spacing(1)} 0 ${theme.spacing(1)}`, - '& > * + *': { - marginLeft: theme.spacing(2), - }, - }), - removeButton: css({ - marginLeft: theme.spacing(1), - marginTop: theme.spacing(2.5), - }), - matcherOptions: css({ - minWidth: '140px', }), matchers: css({ - maxWidth: `${theme.breakpoints.values.sm}px`, - margin: `${theme.spacing(1)} 0`, - paddingTop: theme.spacing(0.5), - }), - indent: css({ - marginLeft: theme.spacing(2), + margin: theme.spacing(1, 0), }), }; }; diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index a911329390e..739a2644d39 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -220,7 +220,7 @@ function SilenceList({ /> ); } else { - return No matching silences found;; + return No matching silences found; } } diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 7b2e4f163b7..7ef32dbf8f1 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -1,6 +1,6 @@ import { isEqual, uniqWith } from 'lodash'; -import { SelectableValue } from '@grafana/data'; +import { ComboboxOption } from '@grafana/ui'; import { AlertManagerCortexConfig, Matcher, @@ -113,7 +113,7 @@ export function matchersToString(matchers: Matcher[]) { return `{ ${combinedMatchers} }`; } -export const matcherFieldOptions: SelectableValue[] = [ +export const matcherFieldOptions: Array> = [ { label: MatcherOperator.equal, description: 'Equals', value: MatcherOperator.equal }, { label: MatcherOperator.notEqual, description: 'Does not equal', value: MatcherOperator.notEqual }, { label: MatcherOperator.regex, description: 'Matches regex', value: MatcherOperator.regex }, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 724c3e565fa..277616f18d0 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1331,9 +1331,6 @@ "notes": "Notes", "returns": "Returns" }, - "label-picker": { - "no-options-message": "No labels found" - }, "labels-editor-modal": { "title-edit-labels": "Edit labels" }, @@ -1420,8 +1417,6 @@ "label-operator": "Operator", "label-refine-affected-alerts": "Refine affected alerts", "label-value": "Value", - "placeholder-label": "label", - "placeholder-value": "value", "remove": "Remove", "tooltip-refine-which-alert-instances-silenced-selecting": "Refine which alert instances are silenced by selecting label matchers" }, @@ -8407,7 +8402,7 @@ "add-silence-button": "Add Silence", "edit-button": "Edit", "expired-silences": "Expired silences are automatically deleted after 5 days.", - "no-matching-silences": "No matching silences found;", + "no-matching-silences": "No matching silences found", "noConfig": "Create a new contact point to create a configuration using the default values or contact your administrator to set up the Alertmanager.", "recreate-button": "Recreate", "unsilence-button": "Unsilence"