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:
@@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+27
@@ -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>
|
||||
);
|
||||
}
|
||||
+176
@@ -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;
|
||||
+51
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user