Alerting: Gops labels integration (#85467)

* Show list of labels on the alert rule form in a more visual way, and use a modal for managing them

* fix test

* Fix more tests

* Show orange badge when no labels are selected

* Remove unused datasource property in LabelsField

* Remove unused div and add comment

* Use button instead of icon for editing labels

* Use subform for labels

* Move logic fetching labels from different places in a separate hook

* Fix tests

* remove unused getLabelInput const in test

* Add ellispis and tooltip for long labels and move labels list in modal to the bottom

* Use text instead of badge when no using labels

* Fix tests after adding ellipsis and tooltip to the labels

* simplify styles

* Fix fetching values from gops when new key is used

* Address pr review comments

* Address pr review comments part2

* Fix tag on rtkq

* Remove color for no labels selected text

* Disable already used keys in the labels sub form

* Fix typo

* use the UseFieldArrayRemove type from react-hook-form

* Update some styles and text in the labels modal

* Address some review comments (nits)

* Address review comments part1

* Move logic getting labels in useCombinedLabels hook

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar
2024-04-23 14:38:31 +02:00
committed by GitHub
parent c77ab53819
commit 68564b1940
24 changed files with 947 additions and 534 deletions
@@ -93,7 +93,9 @@ describe('AlertGroups', () => {
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveTextContent('No grouping');
expect(groups[1]).toHaveTextContent('severitywarning regionUS-Central');
const labels = byTestId('label-value').getAll();
expect(labels[0]).toHaveTextContent('severitywarning');
expect(labels[1]).toHaveTextContent('regionUS-Central');
await userEvent.click(ui.groupCollapseToggle.get(groups[0]));
expect(ui.groupTable.get()).toBeDefined();
@@ -21,8 +21,9 @@ import {
import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockSearchApi } from './mockApi';
import { mockApi, mockSearchApi } from './mockApi';
import {
labelsPluginMetaMock,
mockDataSource,
MockDataSourceSrv,
mockRulerAlertingRule,
@@ -138,6 +139,7 @@ const amConfig: AlertManagerCortexConfig = {
template_files: {},
};
mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false });
describe('CloneRuleEditor', function () {
describe('Grafana-managed rules', function () {
it('should populate form values from the existing alert rule', async function () {
@@ -174,8 +176,16 @@ describe('CloneRuleEditor', function () {
expect(ui.inputs.name.get()).toHaveValue('First Grafana Rule (copy)');
expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one');
expect(ui.inputs.group.get()).toHaveTextContent('group1');
expect(ui.inputs.labelValue(0).get()).toHaveTextContent('critical');
expect(ui.inputs.labelValue(1).get()).toHaveTextContent('nasa');
expect(
byRole('listitem', {
name: 'severity: critical',
}).get()
).toBeInTheDocument();
expect(
byRole('listitem', {
name: 'region: nasa',
}).get()
).toBeInTheDocument();
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule');
});
});
@@ -244,8 +254,16 @@ describe('CloneRuleEditor', function () {
expect(ui.inputs.expr.get()).toHaveValue('vector(1) > 0');
expect(ui.inputs.namespace.get()).toHaveTextContent('namespace-one');
expect(ui.inputs.group.get()).toHaveTextContent('group1');
expect(ui.inputs.labelValue(0).get()).toHaveTextContent('critical');
expect(ui.inputs.labelValue(1).get()).toHaveTextContent('nasa');
expect(
byRole('listitem', {
name: 'severity: critical',
}).get()
).toBeInTheDocument();
expect(
byRole('listitem', {
name: 'region: nasa',
}).get()
).toBeInTheDocument();
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule');
});
});
@@ -1,4 +1,4 @@
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
@@ -13,7 +13,7 @@ import { searchFolders } from '../../manage-dashboards/state/actions';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockApi, mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource } from './mocks';
import { grantUserPermissions, labelsPluginMetaMock, mockDataSource } from './mocks';
import {
defaultAlertmanagerChoiceResponse,
emptyExternalAlertmanagersResponse,
@@ -58,6 +58,7 @@ mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInf
mockAlertmanagerChoiceResponse(server, defaultAlertmanagerChoiceResponse);
mockAlertmanagersResponse(server, emptyExternalAlertmanagersResponse);
mockApi(server).eval({ results: {} });
mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false });
// these tests are rather slow because we have to wait for various API calls and mocks to be called
// and wait for the UI to be in particular states, drone seems to time out quite often so
@@ -76,8 +77,6 @@ const mocks = {
},
};
const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox');
describe('RuleEditor cloud', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -158,9 +157,6 @@ describe('RuleEditor cloud', () => {
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
await user.click(ui.buttons.addLabel.get());
await user.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}');
await user.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}');
// save and check what was sent to backend
await user.click(ui.buttons.saveAndExit.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
@@ -173,9 +169,10 @@ describe('RuleEditor cloud', () => {
{
alert: 'my great new rule',
annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn' },
expr: 'up == 1',
for: '1m',
labels: {},
keep_firing_for: undefined,
},
],
}
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Route } from 'react-router-dom';
@@ -18,7 +18,7 @@ import RuleEditor from './RuleEditor';
import { discoverFeatures } from './api/buildInfo';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource, mockFolder } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
@@ -73,7 +73,6 @@ function renderRuleEditor(identifier?: string) {
);
}
const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox');
describe('RuleEditor grafana managed rules', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -187,10 +186,6 @@ describe('RuleEditor grafana managed rules', () => {
await userEvent.type(screen.getByPlaceholderText('Enter custom annotation name...'), 'custom');
await userEvent.type(screen.getByPlaceholderText('Enter custom annotation content...'), 'value');
//add a label
await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}');
await userEvent.type(getLabelInput(ui.inputs.labelValue(2).get()), 'value{enter}');
// save and check what was sent to backend
await userEvent.click(ui.buttons.save.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
@@ -207,13 +202,14 @@ describe('RuleEditor grafana managed rules', () => {
rules: [
{
annotations: { description: 'some description', summary: 'some summary', custom: 'value' },
labels: { severity: 'warn', team: 'the a-team', custom: 'value' },
labels: { severity: 'warn', team: 'the a-team' },
for: '1m',
grafana_alert: {
uid,
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
notification_settings: undefined,
is_paused: false,
no_data_state: 'NoData',
title: 'my great new rule',
@@ -1,9 +1,10 @@
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byRole } from 'testing-library-selector';
import 'whatwg-fetch';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
@@ -68,7 +69,6 @@ const mocks = {
const server = setupMswServer();
const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox');
describe('RuleEditor grafana managed rules', () => {
beforeEach(() => {
mockApi(server).eval({ results: {} });
@@ -196,13 +196,6 @@ describe('RuleEditor grafana managed rules', () => {
await clickSelectOption(groupInput, 'group1');
await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never });
await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}');
await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}');
//8 segons
// save and check what was sent to backend
await userEvent.click(ui.buttons.saveAndExit.get());
// 9seg
@@ -217,7 +210,7 @@ describe('RuleEditor grafana managed rules', () => {
rules: [
{
annotations: { description: 'some description' },
labels: { severity: 'warn' },
labels: {},
for: '1m',
grafana_alert: {
condition: 'B',
@@ -226,6 +219,7 @@ describe('RuleEditor grafana managed rules', () => {
is_paused: false,
no_data_state: 'NoData',
title: 'my great new rule',
notification_settings: undefined,
},
},
],
@@ -1,9 +1,10 @@
import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byText } from 'testing-library-selector';
import 'whatwg-fetch';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
@@ -16,7 +17,7 @@ import { searchFolders } from '../../manage-dashboards/state/actions';
import { discoverFeatures } from './api/buildInfo';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { RecordingRuleEditorProps } from './components/rule-editor/RecordingRuleEditor';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks';
import { MockDataSourceSrv, grantUserPermissions, labelsPluginMetaMock, mockDataSource } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
@@ -92,9 +93,9 @@ const mocks = {
},
};
const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox');
const server = setupMswServer();
mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false });
mockApi(server).eval({ results: { A: { frames: [] } } });
describe('RuleEditor recording rules', () => {
beforeEach(() => {
@@ -162,12 +163,6 @@ describe('RuleEditor recording rules', () => {
await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never });
await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}');
await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}');
// try to save, find out that recording rule name is invalid
await userEvent.click(ui.buttons.saveAndExit.get());
await waitFor(() =>
@@ -194,7 +189,7 @@ describe('RuleEditor recording rules', () => {
rules: [
{
record: 'my:great:new:recording:rule',
labels: { team: 'the a-team' },
labels: {},
expr: 'up == 1',
},
],
@@ -440,8 +440,10 @@ describe('RuleList', () => {
await userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1]));
const ruleDetails = ui.expandedContent.get(ruleRows[1]);
const labels = byTestId('label-value').getAll(ruleDetails);
expect(labels[0]).toHaveTextContent('severitywarning');
expect(labels[1]).toHaveTextContent('foobar');
expect(ruleDetails).toHaveTextContent('Labels severitywarning foobar');
expect(ruleDetails).toHaveTextContent('Expressiontopk ( 5 , foo ) [ 5m ]');
expect(ruleDetails).toHaveTextContent('messagegreat alert');
expect(ruleDetails).toHaveTextContent('Matching instances');
@@ -452,8 +454,8 @@ describe('RuleList', () => {
const instanceRows = byTestId('row').getAll(instancesTable);
expect(instanceRows).toHaveLength(2);
expect(instanceRows![0]).toHaveTextContent('Firing foobar severitywarning2021-03-18 08:47:05');
expect(instanceRows![1]).toHaveTextContent('Firing foobaz severityerror2021-03-18 08:47:05');
expect(instanceRows![0]).toHaveTextContent('Firingfoobarseveritywarning2021-03-18 08:47:05');
expect(instanceRows![1]).toHaveTextContent('Firingfoobazseverityerror2021-03-18 08:47:05');
// expand details of an instance
await userEvent.click(ui.ruleCollapseToggle.get(instanceRows![0]));
@@ -593,8 +595,9 @@ describe('RuleList', () => {
await userEvent.click(ui.ruleCollapseToggle.get(ruleRows[0]));
const ruleDetails = ui.expandedContent.get(ruleRows[0]);
expect(ruleDetails).toHaveTextContent('Labels severitywarning foobar');
const labels = byTestId('label-value').getAll(ruleDetails);
expect(labels[0]).toHaveTextContent('severitywarning');
expect(labels[1]).toHaveTextContent('foobar');
// Check for different label matchers
await userEvent.clear(filterInput);
@@ -38,6 +38,7 @@ export const alertingApi = createApi({
'OnCallIntegrations',
'OrgMigrationState',
'DataSourceSettings',
'GrafanaLabels',
'CombinedAlertRule',
],
endpoints: () => ({}),
@@ -0,0 +1,31 @@
import { SupportedPlugin } from '../types/pluginBridges';
import { alertingApi } from './alertingApi';
export interface LabelItem {
id: string;
name: string;
prescribed: boolean;
}
export interface LabelKeyAndValues {
labelKey: LabelItem;
values: LabelItem[];
}
export const labelsApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getLabels: build.query<LabelItem[], void>({
query: () => ({
url: `/api/plugins/${SupportedPlugin.Labels}/resources/v1/labels/keys`,
}),
providesTags: ['GrafanaLabels'],
}),
getLabelValues: build.query<LabelKeyAndValues, { key: string }>({
query: ({ key }) => ({
url: `/api/plugins/${SupportedPlugin.Labels}/resources/v1/labels/name/${key}`,
}),
providesTags: ['GrafanaLabels'],
}),
}),
});
@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { createFilter, GroupBase, OptionsOrGroups } from 'react-select';
import { SelectableValue } from '@grafana/data';
import { Field, Select } from '@grafana/ui';
import { Field, Select, useStyles2 } from '@grafana/ui';
export interface AlertLabelDropdownProps {
onChange: (newValue: SelectableValue<string>) => void;
@@ -25,7 +26,7 @@ function customFilter(opt: SelectableValue, searchQuery: string) {
const handleIsValidNewOption = (
inputValue: string,
value: SelectableValue<string> | null,
_: SelectableValue<string> | null,
options: OptionsOrGroups<SelectableValue<string>, GroupBase<SelectableValue<string>>>
) => {
const exactValueExists = options.some((el) => el.label === inputValue);
@@ -34,10 +35,12 @@ const handleIsValidNewOption = (
};
const AlertLabelDropdown: FC<AlertLabelDropdownProps> = React.forwardRef<HTMLDivElement, AlertLabelDropdownProps>(
function labelPicker({ onChange, options, defaultValue, type, onOpenMenu = () => {} }, ref) {
function LabelPicker({ onChange, options, defaultValue, type, onOpenMenu = () => {} }, ref) {
const styles = useStyles2(getStyles);
return (
<div ref={ref}>
<Field disabled={false} data-testid={`alertlabel-${type}-picker`}>
<Field disabled={false} data-testid={`alertlabel-${type}-picker`} className={styles.resetMargin}>
<Select<string>
placeholder={`Choose ${type}`}
width={29}
@@ -59,4 +62,8 @@ const AlertLabelDropdown: FC<AlertLabelDropdownProps> = React.forwardRef<HTMLDiv
}
);
const getStyles = () => ({
resetMargin: css({ marginBottom: 0 }),
});
export default AlertLabelDropdown;
@@ -3,7 +3,7 @@ import React, { ReactNode } from 'react';
import tinycolor2 from 'tinycolor2';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { Icon, useStyles2, Stack } from '@grafana/ui';
import { Icon, Stack, useStyles2 } from '@grafana/ui';
export type LabelSize = 'md' | 'sm';
@@ -21,14 +21,23 @@ const Label = ({ label, value, icon, color, size = 'md' }: Props) => {
const ariaLabel = `${label}: ${value}`;
return (
<div className={styles.wrapper} role="listitem" aria-label={ariaLabel}>
<div className={styles.wrapper} role="listitem" aria-label={ariaLabel} data-testid="label-value">
<Stack direction="row" gap={0} alignItems="stretch">
<div className={styles.label}>
<Stack direction="row" gap={0.5} alignItems="center">
{icon && <Icon name={icon} />} {label ?? ''}
{icon && <Icon name={icon} />}
{label && (
<span className={styles.labelText} title={label.toString()}>
{label ?? ''}
</span>
)}
</Stack>
</div>
<div className={styles.value}>{value}</div>
{value && (
<div className={styles.value} title={value.toString()}>
{value}
</div>
)}
</Stack>
</div>
);
@@ -59,6 +68,12 @@ const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => {
border-radius: ${theme.shape.borderRadius(2)};
`,
labelText: css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px',
}),
label: css`
display: flex;
align-items: center;
@@ -80,6 +95,11 @@ const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => {
border-left: none;
border-top-right-radius: ${theme.shape.borderRadius(2)};
border-bottom-right-radius: ${theme.shape.borderRadius(2)};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
`,
};
};
@@ -5,12 +5,12 @@ import React, { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, Modal, RadioButtonGroup, useStyles2, Stack } from '@grafana/ui';
import { Button, Card, Modal, RadioButtonGroup, Stack, useStyles2 } from '@grafana/ui';
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { KeyValueField } from '../../../api/templateApi';
import AnnotationsStep from '../../rule-editor/AnnotationsStep';
import LabelsField from '../../rule-editor/LabelsField';
import LabelsField from '../../rule-editor/labels/LabelsField';
interface Props {
isOpen: boolean;
@@ -9,7 +9,7 @@ import { Annotations, Labels } from 'app/types/unified-alerting-dto';
import { defaultAnnotations } from '../../../utils/constants';
import AnnotationsStep from '../../rule-editor/AnnotationsStep';
import LabelsField from '../../rule-editor/LabelsField';
import LabelsField from '../../rule-editor/labels/LabelsField';
interface Props {
isOpen: boolean;
@@ -11,4 +11,5 @@ export const GRAFANA_APP_RECEIVERS_SOURCE_IMAGE: Record<SupportedPlugin, string>
[SupportedPlugin.Incident]: '',
[SupportedPlugin.MachineLearning]: '',
[SupportedPlugin.Labels]: '',
};
@@ -1,130 +0,0 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { configureStore } from 'app/store/configureStore';
import LabelsField from './LabelsField';
const labels = [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
];
const FormProviderWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const methods = useForm({ defaultValues: { labels } });
return <FormProvider {...methods}>{children}</FormProvider>;
};
function renderAlertLabels(dataSourceName?: string) {
const store = configureStore({});
render(
<Provider store={store}>
{dataSourceName ? <LabelsField dataSourceName={dataSourceName} /> : <LabelsField />}
</Provider>,
{ wrapper: FormProviderWrapper }
);
}
describe('LabelsField with suggestions', () => {
it('Should display two dropdowns with the existing labels', async () => {
renderAlertLabels('grafana');
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getByTestId('label-key-0').textContent).toBe('key1');
expect(screen.getByTestId('label-key-1').textContent).toBe('key2');
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2);
expect(screen.getByTestId('label-value-0').textContent).toBe('value1');
expect(screen.getByTestId('label-value-1').textContent).toBe('value2');
});
it('Should delete a key-value combination', async () => {
renderAlertLabels('grafana');
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2);
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2);
await userEvent.click(screen.getByTestId('delete-label-1'));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1);
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(1);
});
it('Should add new key-value dropdowns', async () => {
renderAlertLabels('grafana');
await waitFor(() => expect(screen.getByText('Add label')).toBeVisible());
await userEvent.click(screen.getByText('Add label'));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(3);
expect(screen.getByTestId('label-key-0').textContent).toBe('key1');
expect(screen.getByTestId('label-key-1').textContent).toBe('key2');
expect(screen.getByTestId('label-key-2').textContent).toBe('Choose key');
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(3);
expect(screen.getByTestId('label-value-0').textContent).toBe('value1');
expect(screen.getByTestId('label-value-1').textContent).toBe('value2');
expect(screen.getByTestId('label-value-2').textContent).toBe('Choose value');
});
it('Should be able to write new keys and values using the dropdowns', async () => {
renderAlertLabels('grafana');
await waitFor(() => expect(screen.getByText('Add label')).toBeVisible());
await userEvent.click(screen.getByText('Add label'));
const LastKeyDropdown = within(screen.getByTestId('label-key-2'));
const LastValueDropdown = within(screen.getByTestId('label-value-2'));
await userEvent.type(LastKeyDropdown.getByRole('combobox'), 'key3{enter}');
await userEvent.type(LastValueDropdown.getByRole('combobox'), 'value3{enter}');
expect(screen.getByTestId('label-key-2').textContent).toBe('key3');
expect(screen.getByTestId('label-value-2').textContent).toBe('value3');
});
it('Should be able to write new keys and values using the dropdowns, case sensitive', async () => {
renderAlertLabels('grafana');
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getByTestId('label-key-0').textContent).toBe('key1');
expect(screen.getByTestId('label-key-1').textContent).toBe('key2');
expect(screen.getByTestId('label-value-0').textContent).toBe('value1');
expect(screen.getByTestId('label-value-1').textContent).toBe('value2');
const LastKeyDropdown = within(screen.getByTestId('label-key-1'));
const LastValueDropdown = within(screen.getByTestId('label-value-1'));
await userEvent.type(LastKeyDropdown.getByRole('combobox'), 'KEY2{enter}');
expect(screen.getByTestId('label-key-0').textContent).toBe('key1');
expect(screen.getByTestId('label-key-1').textContent).toBe('KEY2');
await userEvent.type(LastValueDropdown.getByRole('combobox'), 'VALUE2{enter}');
expect(screen.getByTestId('label-value-0').textContent).toBe('value1');
expect(screen.getByTestId('label-value-1').textContent).toBe('VALUE2');
});
});
describe('LabelsField without suggestions', () => {
it('Should display two inputs without label suggestions', async () => {
renderAlertLabels();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-input-wrapper')).toHaveLength(2));
expect(screen.queryAllByTestId('alertlabel-key-picker')).toHaveLength(0);
expect(screen.getByTestId('label-key-0')).toHaveValue('key1');
expect(screen.getByTestId('label-key-1')).toHaveValue('key2');
expect(screen.getByTestId('label-value-0')).toHaveValue('value1');
expect(screen.getByTestId('label-value-1')).toHaveValue('value2');
});
});
@@ -1,339 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { useFieldArray, UseFieldArrayAppend, useFormContext, Controller } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Field, InlineLabel, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
import { RuleFormValues } from '../../types/rule-form';
import AlertLabelDropdown from '../AlertLabelDropdown';
import { NeedHelpInfo } from './NeedHelpInfo';
interface Props {
className?: string;
dataSourceName?: string | null;
}
const useGetCustomLabels = (dataSourceName: string): { loading: boolean; labelsByKey: Record<string, Set<string>> } => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchRulerRulesIfNotFetchedYet(dataSourceName));
}, [dispatch, dataSourceName]);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const rulerRequest = rulerRuleRequests[dataSourceName];
const labelsByKeyResult = useMemo<Record<string, Set<string>>>(() => {
const labelsByKey: Record<string, Set<string>> = {};
const rulerRulesConfig = rulerRequest?.result;
if (!rulerRulesConfig) {
return labelsByKey;
}
const allRules = Object.values(rulerRulesConfig)
.flatMap((groups) => groups)
.flatMap((group) => group.rules);
allRules.forEach((rule) => {
if (rule.labels) {
Object.entries(rule.labels).forEach(([key, value]) => {
if (!value) {
return;
}
const labelEntry = labelsByKey[key];
if (labelEntry) {
labelEntry.add(value);
} else {
labelsByKey[key] = new Set([value]);
}
});
}
});
return labelsByKey;
}, [rulerRequest]);
return { loading: rulerRequest?.loading, labelsByKey: labelsByKeyResult };
};
function mapLabelsToOptions(items: Iterable<string> = []): Array<SelectableValue<string>> {
return Array.from(items, (item) => ({ label: item, value: item }));
}
const RemoveButton: FC<{
remove: (index?: number | number[] | undefined) => void;
className: string;
index: number;
}> = ({ remove, className, index }) => (
<Button
className={className}
aria-label="delete label"
icon="trash-alt"
data-testid={`delete-label-${index}`}
variant="secondary"
onClick={() => {
remove(index);
}}
/>
);
const AddButton: FC<{
append: UseFieldArrayAppend<RuleFormValues, 'labels'>;
className: string;
}> = ({ append, className }) => (
<Button
className={className}
icon="plus-circle"
type="button"
variant="secondary"
onClick={() => {
append({ key: '', value: '' });
}}
>
Add label
</Button>
);
const LabelsWithSuggestions: FC<{ dataSourceName: string }> = ({ dataSourceName }) => {
const styles = useStyles2(getStyles);
const {
control,
watch,
formState: { errors },
} = useFormContext<RuleFormValues>();
const labels = watch('labels');
const { fields, remove, append } = useFieldArray({ control, name: 'labels' });
const { loading, labelsByKey } = useGetCustomLabels(dataSourceName);
const [selectedKey, setSelectedKey] = useState('');
const keys = useMemo(() => {
return mapLabelsToOptions(Object.keys(labelsByKey));
}, [labelsByKey]);
const getValuesForLabel = useCallback(
(key: string) => {
return mapLabelsToOptions(labelsByKey[key]);
},
[labelsByKey]
);
const values = useMemo(() => {
return getValuesForLabel(selectedKey);
}, [selectedKey, getValuesForLabel]);
return (
<>
{loading && <LoadingPlaceholder text="Loading" />}
{!loading && (
<Stack direction="column" gap={0.5}>
{fields.map((field, index) => {
return (
<div key={field.id}>
<div className={cx(styles.flexRow, styles.centerAlignRow)}>
<Field
className={styles.labelInput}
invalid={Boolean(errors.labels?.[index]?.key?.message)}
error={errors.labels?.[index]?.key?.message}
data-testid={`label-key-${index}`}
>
<Controller
name={`labels.${index}.key`}
control={control}
rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }}
render={({ field: { onChange, ref, ...rest } }) => {
return (
<AlertLabelDropdown
{...rest}
defaultValue={field.key ? { label: field.key, value: field.key } : undefined}
options={keys}
onChange={(newValue: SelectableValue) => {
onChange(newValue.value);
setSelectedKey(newValue.value);
}}
type="key"
/>
);
}}
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={Boolean(errors.labels?.[index]?.value?.message)}
error={errors.labels?.[index]?.value?.message}
data-testid={`label-value-${index}`}
>
<Controller
control={control}
name={`labels.${index}.value`}
rules={{ required: Boolean(labels[index]?.value) ? 'Required.' : false }}
render={({ field: { onChange, ref, ...rest } }) => {
return (
<AlertLabelDropdown
{...rest}
defaultValue={field.value ? { label: field.value, value: field.value } : undefined}
options={values}
onChange={(newValue: SelectableValue) => {
onChange(newValue.value);
}}
onOpenMenu={() => {
setSelectedKey(labels[index].key);
}}
type="value"
/>
);
}}
/>
</Field>
<RemoveButton className={styles.deleteLabelButton} index={index} remove={remove} />
</div>
</div>
);
})}
<AddButton className={styles.addLabelButton} append={append} />
</Stack>
)}
</>
);
};
const LabelsWithoutSuggestions: FC = () => {
const styles = useStyles2(getStyles);
const {
register,
control,
watch,
formState: { errors },
} = useFormContext<RuleFormValues>();
const labels = watch('labels');
const { fields, remove, append } = useFieldArray({ control, name: 'labels' });
return (
<>
{fields.map((field, index) => {
return (
<div key={field.id}>
<div className={cx(styles.flexRow, styles.centerAlignRow)} data-testid="alertlabel-input-wrapper">
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.key?.message}
error={errors.labels?.[index]?.key?.message}
>
<Input
{...register(`labels.${index}.key`, {
required: { value: !!labels[index]?.value, message: 'Required.' },
})}
placeholder="key"
data-testid={`label-key-${index}`}
defaultValue={field.key}
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.value?.message}
error={errors.labels?.[index]?.value?.message}
>
<Input
{...register(`labels.${index}.value`, {
required: { value: !!labels[index]?.key, message: 'Required.' },
})}
placeholder="value"
data-testid={`label-value-${index}`}
defaultValue={field.value}
/>
</Field>
<RemoveButton className={styles.deleteLabelButton} index={index} remove={remove} />
</div>
</div>
);
})}
<AddButton className={styles.addLabelButton} append={append} />
</>
);
};
const LabelsField: FC<Props> = ({ dataSourceName }) => {
const styles = useStyles2(getStyles);
return (
<div>
<Stack direction="column" gap={1}>
<Text element="h5">Labels</Text>
<Stack direction={'row'} gap={1}>
<Text variant="bodySmall" color="secondary">
Add labels to your rule for searching, silencing, or routing to a notification policy.
</Text>
<NeedHelpInfo
contentText="The dropdown only displays labels that you have previously used for alerts.
Select a label from the options below or type in a new one."
title="Labels"
/>
</Stack>
</Stack>
<div className={styles.labelsContainer}></div>
{dataSourceName ? <LabelsWithSuggestions dataSourceName={dataSourceName} /> : <LabelsWithoutSuggestions />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
icon: css({
marginRight: theme.spacing(0.5),
}),
flexColumn: css({
display: 'flex',
flexDirection: 'column',
}),
flexRow: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
'& + button': {
marginLeft: theme.spacing(0.5),
},
}),
deleteLabelButton: css({
marginLeft: theme.spacing(0.5),
alignSelf: 'flex-start',
}),
addLabelButton: css({
flexGrow: 0,
alignSelf: 'flex-start',
}),
centerAlignRow: css({
alignItems: 'baseline',
}),
equalSign: css({
alignSelf: 'flex-start',
width: '28px',
justifyContent: 'center',
marginLeft: theme.spacing(0.5),
}),
labelInput: css({
width: '175px',
marginBottom: `-${theme.spacing(1)}`,
'& + &': {
marginLeft: theme.spacing(1),
},
}),
labelsContainer: css({
marginBottom: theme.spacing(3),
}),
};
};
export default LabelsField;
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React from 'react';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
@@ -9,10 +9,11 @@ import { Icon, RadioButtonGroup, Stack, Text, useStyles2 } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import LabelsField from './LabelsField';
import { NeedHelpInfo } from './NeedHelpInfo';
import { RuleEditorSection } from './RuleEditorSection';
import { SimplifiedRouting } from './alert-rule-form/simplifiedRouting/SimplifiedRouting';
import { LabelsEditorModal } from './labels/LabelsEditorModal';
import { LabelsFieldInForm } from './labels/LabelsFieldInForm';
import { NotificationPreview } from './notificaton-preview/NotificationPreview';
type NotificationsStepProps = {
@@ -25,16 +26,29 @@ enum RoutingOptions {
}
export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
const { watch } = useFormContext<RuleFormValues>();
const { watch, getValues, setValue } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const [type] = watch(['type', 'labels', 'queries', 'condition', 'folder', 'name', 'manualRouting']);
const [showLabelsEditor, setShowLabelsEditor] = useState(false);
const dataSourceName = watch('dataSourceName') ?? GRAFANA_RULES_SOURCE_NAME;
const simplifiedRoutingToggleEnabled = config.featureToggles.alertingSimplifiedRouting ?? false;
const shouldRenderpreview = type === RuleFormType.grafana;
const shouldAllowSimplifiedRouting = type === RuleFormType.grafana && simplifiedRoutingToggleEnabled;
function onCloseLabelsEditor(
labelsToUpdate?: Array<{
key: string;
value: string;
}>
) {
if (labelsToUpdate) {
setValue('labels', labelsToUpdate);
}
setShowLabelsEditor(false);
}
return (
<RuleEditorSection
stepNo={4}
@@ -56,7 +70,13 @@ export const NotificationsStep = ({ alertUid }: NotificationsStepProps) => {
}
fullWidth
>
<LabelsField dataSourceName={dataSourceName} />
<LabelsFieldInForm onEditClick={() => setShowLabelsEditor(true)} />
<LabelsEditorModal
isOpen={showLabelsEditor}
onClose={onCloseLabelsEditor}
dataSourceName={dataSourceName}
initialLabels={getValues('labels')}
/>
{shouldAllowSimplifiedRouting && (
<div className={styles.configureNotifications}>
<Text element="h5">Notifications</Text>
@@ -0,0 +1,33 @@
import React from 'react';
import { UseFieldArrayRemove } from 'react-hook-form';
import { Button } from '@grafana/ui';
interface RemoveButtonProps {
remove: UseFieldArrayRemove;
index: number;
}
export function RemoveButton({ remove, index }: RemoveButtonProps) {
return (
<Button
aria-label="delete label"
icon="trash-alt"
data-testid={`delete-label-${index}`}
variant="secondary"
onClick={() => {
remove(index);
}}
/>
);
}
interface AddButtonProps {
append: () => void;
}
export function AddButton({ append }: AddButtonProps) {
return (
<Button icon="plus" type="button" variant="secondary" onClick={append}>
Add more
</Button>
);
}
@@ -0,0 +1,27 @@
import React from 'react';
import { Modal } from '@grafana/ui';
import { LabelsSubForm } from './LabelsField';
export interface LabelsEditorModalProps {
isOpen: boolean;
initialLabels: Array<{
key: string;
value: string;
}>;
onClose: (
labelsToUodate?: Array<{
key: string;
value: string;
}>
) => void;
dataSourceName: string;
}
export function LabelsEditorModal({ isOpen, onClose, dataSourceName, initialLabels }: LabelsEditorModalProps) {
return (
<Modal title="Edit labels" closeOnEscape isOpen={isOpen} onDismiss={() => onClose()}>
<LabelsSubForm dataSourceName={dataSourceName} onClose={onClose} initialLabels={initialLabels} />
</Modal>
);
}
@@ -0,0 +1,176 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { TestProvider } from 'test/helpers/TestProvider';
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
import { mockAlertRuleApi, mockApi, setupMswServer } from '../../../mockApi';
import { getGrafanaRule, labelsPluginMetaMock } from '../../../mocks';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import LabelsField, { LabelsWithSuggestions } from './LabelsField';
const labels = [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
];
const FormProviderWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const methods = useForm({ defaultValues: { labels } });
return <FormProvider {...methods}>{children}</FormProvider>;
};
const SubFormProviderWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const methods = useForm({ defaultValues: { labelsInSubform: labels } });
return <FormProvider {...methods}>{children}</FormProvider>;
};
function renderAlertLabels() {
render(
<FormProviderWrapper>
<LabelsField />
</FormProviderWrapper>,
{ wrapper: TestProvider }
);
}
function renderLabelsWithSuggestions() {
render(
<SubFormProviderWrapper>
<LabelsWithSuggestions dataSourceName="grafana" />
</SubFormProviderWrapper>,
{ wrapper: TestProvider }
);
}
const grafanaRule = getGrafanaRule(undefined, {
uid: 'test-rule-uid',
title: 'cpu-usage',
namespace_uid: 'folderUID1',
data: [
{
refId: 'A',
datasourceUid: 'uid1',
queryType: 'alerting',
relativeTimeRange: { from: 1000, to: 2000 },
model: {
refId: 'A',
expression: 'vector(1)',
queryType: 'alerting',
datasource: { uid: 'uid1', type: 'prometheus' },
},
},
],
});
const server = setupMswServer();
describe('LabelsField with suggestions', () => {
afterEach(() => {
server.resetHandlers();
clearPluginSettingsCache();
});
beforeEach(() => {
mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false });
mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, {
[grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }],
});
});
it('Should display two dropdowns with the existing labels', async () => {
renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getByTestId('labelsInSubform-key-0').textContent).toBe('key1');
expect(screen.getByTestId('labelsInSubform-key-1').textContent).toBe('key2');
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2);
expect(screen.getByTestId('labelsInSubform-value-0').textContent).toBe('value1');
expect(screen.getByTestId('labelsInSubform-value-1').textContent).toBe('value2');
});
it('Should delete a key-value combination', async () => {
renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2);
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2);
await userEvent.click(screen.getByTestId('delete-label-1'));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1);
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(1);
});
it('Should add new key-value dropdowns', async () => {
renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
await userEvent.click(screen.getByText('Add more'));
expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(3);
expect(screen.getByTestId('labelsInSubform-key-0').textContent).toBe('key1');
expect(screen.getByTestId('labelsInSubform-key-1').textContent).toBe('key2');
expect(screen.getByTestId('labelsInSubform-key-2').textContent).toBe('Choose key');
expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(3);
expect(screen.getByTestId('labelsInSubform-value-0').textContent).toBe('value1');
expect(screen.getByTestId('labelsInSubform-value-1').textContent).toBe('value2');
expect(screen.getByTestId('labelsInSubform-value-2').textContent).toBe('Choose value');
});
it('Should be able to write new keys and values using the dropdowns', async () => {
renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getByText('Add more')).toBeVisible());
await userEvent.click(screen.getByText('Add more'));
const LastKeyDropdown = within(screen.getByTestId('labelsInSubform-key-2'));
const LastValueDropdown = within(screen.getByTestId('labelsInSubform-value-2'));
await userEvent.type(LastKeyDropdown.getByRole('combobox'), 'key3{enter}');
await userEvent.type(LastValueDropdown.getByRole('combobox'), 'value3{enter}');
expect(screen.getByTestId('labelsInSubform-key-2').textContent).toBe('key3');
expect(screen.getByTestId('labelsInSubform-value-2').textContent).toBe('value3');
});
it('Should be able to write new keys and values using the dropdowns, case sensitive', async () => {
renderLabelsWithSuggestions();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2));
expect(screen.getByTestId('labelsInSubform-key-0').textContent).toBe('key1');
expect(screen.getByTestId('labelsInSubform-key-1').textContent).toBe('key2');
expect(screen.getByTestId('labelsInSubform-value-0').textContent).toBe('value1');
expect(screen.getByTestId('labelsInSubform-value-1').textContent).toBe('value2');
const LastKeyDropdown = within(screen.getByTestId('labelsInSubform-key-1'));
const LastValueDropdown = within(screen.getByTestId('labelsInSubform-value-1'));
await userEvent.type(LastKeyDropdown.getByRole('combobox'), 'KEY2{enter}');
expect(screen.getByTestId('labelsInSubform-key-0').textContent).toBe('key1');
expect(screen.getByTestId('labelsInSubform-key-1').textContent).toBe('KEY2');
await userEvent.type(LastValueDropdown.getByRole('combobox'), 'VALUE2{enter}');
expect(screen.getByTestId('labelsInSubform-value-0').textContent).toBe('value1');
expect(screen.getByTestId('labelsInSubform-value-1').textContent).toBe('VALUE2');
});
});
describe('LabelsField without suggestions', () => {
it('Should display two inputs without label suggestions', async () => {
renderAlertLabels();
await waitFor(() => expect(screen.getAllByTestId('alertlabel-input-wrapper')).toHaveLength(2));
expect(screen.queryAllByTestId('alertlabel-key-picker')).toHaveLength(0);
expect(screen.getByTestId('label-key-0')).toHaveValue('key1');
expect(screen.getByTestId('label-key-1')).toHaveValue('key2');
expect(screen.getByTestId('label-value-0')).toHaveValue('value1');
expect(screen.getByTestId('label-value-1')).toHaveValue('value2');
});
});
@@ -0,0 +1,489 @@
import { css, cx } from '@emotion/css';
import React, { FC, useCallback, useEffect, 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 { useDispatch } from 'app/types';
import { labelsApi } from '../../../api/labelsApi';
import { usePluginBridge } from '../../../hooks/usePluginBridge';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../../state/actions';
import { SupportedPlugin } from '../../../types/pluginBridges';
import { RuleFormValues } from '../../../types/rule-form';
import AlertLabelDropdown from '../../AlertLabelDropdown';
import { AlertLabels } from '../../AlertLabels';
import { NeedHelpInfo } from '../NeedHelpInfo';
import { AddButton, RemoveButton } from './LabelsButtons';
const useGetOpsLabelsKeys = (skip: boolean) => {
const { currentData, isLoading: isloadingLabels } = labelsApi.endpoints.getLabels.useQuery(undefined, {
skip,
});
return { loading: isloadingLabels, labelsOpsKeys: currentData };
};
const useGetAlertRulesLabels = (
dataSourceName: string
): { loading: boolean; labelsByKey: Record<string, Set<string>> } => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchRulerRulesIfNotFetchedYet(dataSourceName));
}, [dispatch, dataSourceName]);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const rulerRequest = rulerRuleRequests[dataSourceName];
const labelsByKeyResult = useMemo<Record<string, Set<string>>>(() => {
const labelsByKey: Record<string, Set<string>> = {};
const rulerRulesConfig = rulerRequest?.result;
if (!rulerRulesConfig) {
return labelsByKey;
}
const allRules = Object.values(rulerRulesConfig)
.flatMap((groups) => groups)
.flatMap((group) => group.rules);
allRules.forEach((rule) => {
if (rule.labels) {
Object.entries(rule.labels).forEach(([key, value]) => {
if (!value) {
return;
}
const labelEntry = labelsByKey[key];
if (labelEntry) {
labelEntry.add(value);
} else {
labelsByKey[key] = new Set([value]);
}
});
}
});
return labelsByKey;
}, [rulerRequest]);
return { loading: rulerRequest?.loading, labelsByKey: labelsByKeyResult };
};
function mapLabelsToOptions(
items: Iterable<string> = [],
labelsInSubForm?: Array<{ key: string; value: string }>
): Array<SelectableValue<string>> {
const existingKeys = new Set(labelsInSubForm ? labelsInSubForm.map((label) => label.key) : []);
return Array.from(items, (item) => ({ label: item, value: item, isDisabled: existingKeys.has(item) }));
}
export interface LabelsInRuleProps {
labels: Array<{ key: string; value: string }>;
}
export const LabelsInRule = ({ labels }: LabelsInRuleProps) => {
const labelsObj: Record<string, string> = labels.reduce((acc: Record<string, string>, label) => {
if (label.key) {
acc[label.key] = label.value;
}
return acc;
}, {});
return <AlertLabels labels={labelsObj} />;
};
export type LabelsSubformValues = {
labelsInSubform: Array<{ key: string; value: string }>;
};
export interface LabelsSubFormProps {
dataSourceName: string;
initialLabels: Array<{ key: string; value: string }>;
onClose: (
labelsToUodate?: Array<{
key: string;
value: string;
}>
) => void;
}
export function LabelsSubForm({ dataSourceName, onClose, initialLabels }: LabelsSubFormProps) {
const styles = useStyles2(getStyles);
const onSave = (labels: LabelsSubformValues) => {
onClose(labels.labelsInSubform);
};
const onCancel = () => {
onClose();
};
// default values for the subform are the initial labels
const defaultValues: LabelsSubformValues = useMemo(() => {
return { labelsInSubform: initialLabels };
}, [initialLabels]);
const formAPI = useForm<LabelsSubformValues>({ defaultValues });
return (
<FormProvider {...formAPI}>
<form onSubmit={formAPI.handleSubmit(onSave)}>
<Stack direction="column" gap={4}>
<Text>Add labels to your rule for searching, silencing, or routing to a notification policy.</Text>
<Stack direction="column" gap={1}>
<LabelsWithSuggestions dataSourceName={dataSourceName} />
<Space v={2} />
<LabelsInRule labels={formAPI.watch('labelsInSubform')} />
<Space v={1} />
<div className={styles.confirmButton}>
<Button type="button" variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save</Button>
</div>
</Stack>
</Stack>
</form>
</FormProvider>
);
}
export function useCombinedLabels(
dataSourceName: string,
labelsPluginInstalled: boolean,
loadingLabelsPlugin: boolean,
labelsInSubform: Array<{ key: string; value: string }>,
selectedKey: string
) {
// ------- Get labels keys and their values from existing alerts
const { loading, labelsByKey: labelsByKeyFromExisingAlerts } = useGetAlertRulesLabels(dataSourceName);
// ------- Get only the keys from the ops labels, as we will fetch the values for the keys once the key is selected.
const { loading: isLoadingLabels, labelsOpsKeys = [] } = useGetOpsLabelsKeys(
!labelsPluginInstalled || loadingLabelsPlugin
);
//------ Convert the labelsOpsKeys to the same format as the labelsByKeyFromExisingAlerts
const labelsByKeyOps = useMemo(() => {
return labelsOpsKeys.reduce((acc: Record<string, Set<string>>, label) => {
acc[label.name] = new Set();
return acc;
}, {});
}, [labelsOpsKeys]);
//------- Convert the keys from the ops labels to options for the dropdown
const keysFromGopsLabels = useMemo(() => {
return mapLabelsToOptions(Object.keys(labelsByKeyOps), labelsInSubform);
}, [labelsByKeyOps, labelsInSubform]);
//------- Convert the keys from the existing alerts to options for the dropdown
const keysFromExistingAlerts = useMemo(() => {
return mapLabelsToOptions(Object.keys(labelsByKeyFromExisingAlerts), labelsInSubform);
}, [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 selectedKeyIsFromAlerts =
labelsByKeyFromExisingAlerts[selectedKey] !== undefined && labelsByKeyFromExisingAlerts[selectedKey]?.size > 0;
const selectedKeyIsFromOps = labelsByKeyOps[selectedKey] !== undefined && labelsByKeyOps[selectedKey]?.size > 0;
const selectedKeyDoesNotExist = !selectedKeyIsFromAlerts && !selectedKeyIsFromOps;
const valuesAlreadyFetched = !selectedKeyIsFromAlerts && labelsByKeyOps[selectedKey]?.size > 0;
// Only fetch the values for the selected key if it is from ops and the values are not already fetched (the selected key is not in the labelsByKeyOps object)
const {
currentData: valuesData,
isLoading: isLoadingValues = false,
error,
} = labelsApi.endpoints.getLabelValues.useQuery(
{ key: selectedKey },
{
skip:
!labelsPluginInstalled ||
!selectedKey ||
selectedKeyIsFromAlerts ||
valuesAlreadyFetched ||
selectedKeyDoesNotExist,
}
);
// these are the values for the selected key in case it is from ops
const valuesFromSelectedGopsKey = useMemo(() => {
// if it is from alerts, we need to fetch the values from the existing alerts
if (selectedKeyIsFromAlerts) {
return [];
}
// in case of a label from ops, we need to fetch the values from the plugin
// fetch values from ops only if there is no value for the key
const valuesForSelectedKey = labelsByKeyOps[selectedKey];
const valuesAlreadyFetched = valuesForSelectedKey?.size > 0;
if (valuesAlreadyFetched) {
return mapLabelsToOptions(valuesForSelectedKey);
}
if (!isLoadingValues && valuesData?.values?.length && !error) {
const values = valuesData?.values.map((value) => value.name);
labelsByKeyOps[selectedKey] = new Set(values);
return mapLabelsToOptions(values);
}
return [];
}, [selectedKeyIsFromAlerts, labelsByKeyOps, selectedKey, isLoadingValues, valuesData, error]);
const getValuesForLabel = useCallback(
(key: string) => {
// values from existing alerts will take precedence over values from ops
if (selectedKeyIsFromAlerts || !labelsPluginInstalled) {
return mapLabelsToOptions(labelsByKeyFromExisingAlerts[key]);
}
return valuesFromSelectedGopsKey;
},
[labelsByKeyFromExisingAlerts, labelsPluginInstalled, valuesFromSelectedGopsKey, selectedKeyIsFromAlerts]
);
return {
loading: loading || isLoadingLabels,
keysFromExistingAlerts,
groupedOptions,
getValuesForLabel,
};
}
/*
We will suggest labels from two sources: existing alerts and ops labels.
We only will suggest labels from ops if the grafana-labels-app plugin is installed
This component is only used by the alert rule form.
*/
export interface LabelsWithSuggestionsProps {
dataSourceName: string;
}
export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsProps) {
const styles = useStyles2(getStyles);
const {
control,
watch,
formState: { errors },
} = useFormContext<LabelsSubformValues>();
const labelsInSubform = watch('labelsInSubform');
const { fields, remove, append } = useFieldArray({ control, name: 'labelsInSubform' });
const appendLabel = useCallback(() => {
append({ key: '', value: '' });
}, [append]);
// check if the labels plugin is installed
const { installed: labelsPluginInstalled = false, loading: loadingLabelsPlugin } = usePluginBridge(
SupportedPlugin.Labels
);
const [selectedKey, setSelectedKey] = useState('');
const { loading, keysFromExistingAlerts, groupedOptions, getValuesForLabel } = useCombinedLabels(
dataSourceName,
labelsPluginInstalled,
loadingLabelsPlugin,
labelsInSubform,
selectedKey
);
const values = useMemo(() => {
return getValuesForLabel(selectedKey);
}, [selectedKey, getValuesForLabel]);
const isLoading = loading || loadingLabelsPlugin;
return (
<>
{isLoading && <LoadingPlaceholder text="Loading existing labels" />}
{!isLoading && (
<Stack direction="column" gap={1} alignItems="flex-start">
{fields.map((field, index) => {
return (
<div key={field.id} className={cx(styles.flexRow, styles.centerAlignRow)}>
<Field
className={styles.labelInput}
invalid={Boolean(errors.labelsInSubform?.[index]?.key?.message)}
error={errors.labelsInSubform?.[index]?.key?.message}
data-testid={`labelsInSubform-key-${index}`}
>
<Controller
name={`labelsInSubform.${index}.key`}
control={control}
rules={{ required: Boolean(labelsInSubform[index]?.value) ? 'Required.' : false }}
render={({ field: { onChange, ref, ...rest } }) => {
return (
<AlertLabelDropdown
{...rest}
defaultValue={field.key ? { label: field.key, value: field.key } : undefined}
options={labelsPluginInstalled ? groupedOptions : keysFromExistingAlerts}
onChange={(newValue: SelectableValue) => {
onChange(newValue.value);
setSelectedKey(newValue.value);
}}
type="key"
/>
);
}}
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={Boolean(errors.labelsInSubform?.[index]?.value?.message)}
error={errors.labelsInSubform?.[index]?.value?.message}
data-testid={`labelsInSubform-value-${index}`}
>
<Controller
control={control}
name={`labelsInSubform.${index}.value`}
rules={{ required: Boolean(labelsInSubform[index]?.value) ? 'Required.' : false }}
render={({ field: { onChange, ref, ...rest } }) => {
return (
<AlertLabelDropdown
{...rest}
defaultValue={field.value ? { label: field.value, value: field.value } : undefined}
options={values}
onChange={(newValue: SelectableValue) => {
onChange(newValue.value);
}}
onOpenMenu={() => {
setSelectedKey(labelsInSubform[index].key);
}}
type="value"
/>
);
}}
/>
</Field>
<RemoveButton index={index} remove={remove} />
</div>
);
})}
<AddButton append={appendLabel} />
</Stack>
)}
</>
);
}
export const LabelsWithoutSuggestions: FC = () => {
const styles = useStyles2(getStyles);
const {
register,
control,
watch,
formState: { errors },
} = useFormContext<RuleFormValues>();
const labels = watch('labels');
const { fields, remove, append } = useFieldArray({ control, name: 'labels' });
const appendLabel = useCallback(() => {
append({ key: '', value: '' });
}, [append]);
return (
<>
{fields.map((field, index) => {
return (
<div key={field.id}>
<div className={cx(styles.flexRow, styles.centerAlignRow)} data-testid="alertlabel-input-wrapper">
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.key?.message}
error={errors.labels?.[index]?.key?.message}
>
<Input
{...register(`labels.${index}.key`, {
required: { value: !!labels[index]?.value, message: 'Required.' },
})}
placeholder="key"
data-testid={`label-key-${index}`}
defaultValue={field.key}
/>
</Field>
<InlineLabel className={styles.equalSign}>=</InlineLabel>
<Field
className={styles.labelInput}
invalid={!!errors.labels?.[index]?.value?.message}
error={errors.labels?.[index]?.value?.message}
>
<Input
{...register(`labels.${index}.value`, {
required: { value: !!labels[index]?.key, message: 'Required.' },
})}
placeholder="value"
data-testid={`label-value-${index}`}
defaultValue={field.value}
/>
</Field>
<RemoveButton index={index} remove={remove} />
</div>
</div>
);
})}
<AddButton append={appendLabel} />
</>
);
};
function LabelsField() {
return (
<div>
<Stack direction="column" gap={1}>
<Text element="h5">Labels</Text>
<Stack direction={'row'} gap={1}>
<Text variant="bodySmall" color="secondary">
Add labels to your rule for searching, silencing, or routing to a notification policy.
</Text>
<NeedHelpInfo
contentText="The dropdown only displays labels that you have previously used for alerts.
Select a label from the options below or type in a new one."
title="Labels"
/>
</Stack>
</Stack>
<LabelsWithoutSuggestions />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
flexColumn: css({
display: 'flex',
flexDirection: 'column',
}),
flexRow: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
}),
centerAlignRow: css({
alignItems: 'center',
gap: theme.spacing(0.5),
}),
equalSign: css({
alignSelf: 'flex-start',
width: '28px',
justifyContent: 'center',
margin: 0,
}),
labelInput: css({
width: '175px',
margin: 0,
}),
confirmButton: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
marginLeft: 'auto',
}),
};
};
export default LabelsField;
@@ -0,0 +1,51 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { Button, Stack, Text } from '@grafana/ui';
import { RuleFormValues } from '../../../types/rule-form';
import { NeedHelpInfo } from '../NeedHelpInfo';
import { LabelsInRule } from './LabelsField';
interface LabelsFieldInFormProps {
onEditClick: () => void;
}
export function LabelsFieldInForm({ onEditClick }: LabelsFieldInFormProps) {
const { watch } = useFormContext<RuleFormValues>();
const labels = watch('labels');
const hasLabels = Object.keys(labels).length > 0 && labels.some((label) => label.key || label.value);
return (
<Stack direction="column" gap={2}>
<Stack direction="column" gap={1}>
<Text element="h5">Labels</Text>
<Stack direction={'row'} gap={1}>
<Text variant="bodySmall" color="secondary">
Add labels to your rule for searching, silencing, or routing to a notification policy.
</Text>
<NeedHelpInfo
contentText="The dropdown only displays labels that you have previously used for alerts.
Select a label from the options below or type in a new one."
title="Labels"
/>
</Stack>
</Stack>
<Stack direction="row" gap={1} alignItems="center">
<LabelsInRule labels={labels} />
{hasLabels ? (
<Button variant="secondary" type="button" onClick={onEditClick} size="sm">
Edit labels
</Button>
) : (
<Stack direction="row" gap={2} alignItems="center">
<Text>No labels selected</Text>
<Button icon="plus" type="button" variant="secondary" onClick={onEditClick} size="sm">
Add labels
</Button>
</Stack>
)}
</Stack>
</Stack>
);
}
@@ -747,3 +747,23 @@ export const onCallPluginMetaMock: PluginMeta = {
screenshots: [],
},
};
export const labelsPluginMetaMock: PluginMeta = {
name: 'Grafana IRM Labels',
id: 'grafana-labels-app',
type: PluginType.app,
module: 'plugins/grafana-labels-app/module',
baseUrl: 'public/plugins/grafana-labels-app',
info: {
author: { name: 'Grafana Labs' },
description: '',
updated: '',
version: '',
links: [],
logos: {
small: '',
large: '',
},
screenshots: [],
},
};
@@ -2,4 +2,5 @@ export enum SupportedPlugin {
Incident = 'grafana-incident-app',
OnCall = 'grafana-oncall-app',
MachineLearning = 'grafana-ml-app',
Labels = 'grafana-labels-app',
}