[ES Query] Make rule created in Discover visible in Observability (#171364)

## Summary

Closes #170497 

<img width="483" alt="Screenshot 2023-11-16 at 1 25 18 PM"
src="4d974eab-9641-4618-b52a-2facf4c07667">

Adds scope dropdown to ES Query rules created from Discovery. If Logs or
Metrics are selected, rules created here will be visible in
Observability.

Also makes `Logs` the default consumer when creating a rule from either
Discovery and Observability.


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Zacqary Adam Xeper 2023-12-04 10:36:23 -06:00 committed by GitHub
parent c07b501e54
commit 8d1cafff0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 150 additions and 137 deletions

View file

@ -5,6 +5,14 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { AlertConsumers } from '../alerts_as_data_rbac';
import { STACK_ALERTS_FEATURE_ID } from './stack_rules';
export * from './stack_rules';
export * from './o11y_rules';
export type RuleCreationValidConsumer =
| typeof AlertConsumers.LOGS
| typeof AlertConsumers.INFRASTRUCTURE
| typeof AlertConsumers.OBSERVABILITY
| typeof STACK_ALERTS_FEATURE_ID;

View file

@ -13,14 +13,24 @@ import { FormattedMessage } from '@kbn/i18n-react';
import type { DataView } from '@kbn/data-plugin/common';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { STACK_ALERTS_FEATURE_ID } from '@kbn/rule-data-utils';
import {
AlertConsumers,
ES_QUERY_ID,
RuleCreationValidConsumer,
STACK_ALERTS_FEATURE_ID,
} from '@kbn/rule-data-utils';
import { DiscoverStateContainer } from '../../services/discover_state';
import { DiscoverServices } from '../../../../build_services';
const container = document.createElement('div');
let isOpen = false;
const ALERT_TYPE_ID = '.es-query';
const EsQueryValidConsumer: RuleCreationValidConsumer[] = [
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.LOGS,
AlertConsumers.OBSERVABILITY,
STACK_ALERTS_FEATURE_ID,
];
interface AlertsPopoverProps {
onClose: () => void;
@ -98,7 +108,7 @@ export function AlertsPopover({
return triggersActionsUi?.getAddRuleFlyout({
metadata: discoverMetadata,
consumer: STACK_ALERTS_FEATURE_ID,
consumer: 'alerts',
onClose: (_, metadata) => {
onFinishFlyoutInteraction(metadata as EsQueryAlertMetaData);
onClose();
@ -107,8 +117,12 @@ export function AlertsPopover({
onFinishFlyoutInteraction(metadata as EsQueryAlertMetaData);
},
canChangeTrigger: false,
ruleTypeId: ALERT_TYPE_ID,
ruleTypeId: ES_QUERY_ID,
initialValues: { params: getParams() },
validConsumers: EsQueryValidConsumer,
useRuleProducer: true,
// Default to the Logs consumer if it's available. This should fall back to Stack Alerts if it's not.
initialSelectedConsumer: AlertConsumers.LOGS,
});
}, [alertFlyoutVisible, triggersActionsUi, discoverMetadata, getParams, onClose, stateContainer]);

View file

@ -71,14 +71,12 @@
"@kbn/react-kibana-context-render",
"@kbn/unified-data-table",
"@kbn/no-data-page-plugin",
"@kbn/rule-data-utils",
"@kbn/global-search-plugin",
"@kbn/resizable-layout",
"@kbn/unsaved-changes-badge",
"@kbn/rule-data-utils",
"@kbn/core-chrome-browser",
"@kbn/core-plugins-server"
],
"exclude": [
"target/**/*"
]
"exclude": ["target/**/*"]
}

View file

@ -14,6 +14,7 @@ import { useLoadRuleTypes } from '@kbn/triggers-actions-ui-plugin/public';
import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { RULES_LOGS_PATH, RULES_PATH } from '../../../common/locators/paths';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
@ -144,6 +145,7 @@ export function RulesPage({ activeTab = RULES_TAB_NAME }: RulesPageProps) {
consumer={ALERTS_FEATURE_ID}
filteredRuleTypes={filteredRuleTypes}
validConsumers={observabilityRuleCreationValidConsumers}
initialSelectedConsumer={AlertConsumers.LOGS}
onClose={() => {
setAddRuleFlyoutVisibility(false);
}}

View file

@ -75,4 +75,12 @@ function registerNavigation(alerting: AlertingSetup) {
return;
}
);
alerting.registerNavigation(
'observability',
ES_QUERY_ID,
(rule: SanitizedRule<EsQueryRuleParams<SearchType.searchSource>>) => {
if (isSearchSourceRule(rule.params)) return `/app/discover#/viewAlert/${rule.id}`;
return;
}
);
}

View file

@ -340,87 +340,6 @@ describe('rule_add', () => {
});
});
it('should NOT allow to save the rule if the consumer is not set', async () => {
(triggersActionsUiConfig as jest.Mock).mockResolvedValue({
minimumScheduleInterval: { value: '1m', enforce: false },
});
const onClose = jest.fn();
await setup({
initialValues: {
name: 'Simple rule',
consumer: 'alerts',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
tags: ['uptime', 'logs'],
schedule: {
interval: '1h',
},
},
onClose,
ruleTypesOverwrite: [
{
id: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
name: 'Threshold Rule',
actionGroups: [
{
id: 'testActionGroup',
name: 'Test Action Group',
},
],
enabledInLicense: true,
defaultActionGroupId: 'threshold.fired',
minimumLicenseRequired: 'basic',
recoveryActionGroup: { id: 'recovered', name: 'Recovered' },
producer: ALERTS_FEATURE_ID,
authorizedConsumers: {
alerts: { read: true, all: true },
apm: { read: true, all: true },
discover: { read: true, all: true },
infrastructure: { read: true, all: true },
logs: { read: true, all: true },
ml: { read: true, all: true },
monitoring: { read: true, all: true },
siem: { read: true, all: true },
// Setting SLO all to false, this shouldn't show up
slo: { read: true, all: false },
stackAlerts: { read: true, all: true },
uptime: { read: true, all: true },
},
actionVariables: {
context: [],
state: [],
params: [],
},
},
],
ruleTypeModelOverwrite: {
id: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
iconClass: 'test',
description: 'test',
documentationUrl: null,
validate: (): ValidationResult => {
return { errors: {} };
},
ruleParamsExpression: TestExpression,
requiresAppContext: false,
},
validConsumers: [AlertConsumers.INFRASTRUCTURE, AlertConsumers.LOGS],
});
await act(async () => {
await nextTick();
wrapper.update();
});
wrapper.find('[data-test-subj="saveRuleButton"]').last().simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(createRule).toBeCalledTimes(0);
});
it('should set consumer automatically if only 1 authorized consumer exists', async () => {
(triggersActionsUiConfig as jest.Mock).mockResolvedValue({
minimumScheduleInterval: { value: '1m', enforce: false },

View file

@ -37,7 +37,7 @@ import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana';
import { hasRuleChanged, haveRuleParamsChanged } from './has_rule_changed';
import { getRuleWithInvalidatedFields } from '../../lib/value_validators';
import { DEFAULT_RULE_INTERVAL } from '../../constants';
import { DEFAULT_RULE_INTERVAL, MULTI_CONSUMER_RULE_TYPE_IDS } from '../../constants';
import { triggersActionsUiConfig } from '../../../common/lib/config_api';
import { getInitialInterval } from './get_initial_interval';
import { ToastWithCircuitBreakerContent } from '../../components/toast_with_circuit_breaker_content';
@ -65,6 +65,7 @@ const RuleAdd = ({
filteredRuleTypes,
validConsumers,
useRuleProducer,
initialSelectedConsumer,
...props
}: RuleAddProps) => {
const onSaveHandler = onSave ?? reloadRules;
@ -84,7 +85,6 @@ const RuleAdd = ({
...(initialValues ? initialValues : {}),
};
}, [ruleTypeId, consumer, initialValues]);
const [{ rule }, dispatch] = useReducer(ruleReducer as InitialRuleReducer, {
rule: initialRule,
});
@ -97,9 +97,14 @@ const RuleAdd = ({
props.ruleTypeIndex
);
const [changedFromDefaultInterval, setChangedFromDefaultInterval] = useState<boolean>(false);
const selectableConsumer = useMemo(
() => rule.ruleTypeId && MULTI_CONSUMER_RULE_TYPE_IDS.includes(rule.ruleTypeId),
[rule]
);
const [selectedConsumer, setSelectedConsumer] = useState<
RuleCreationValidConsumer | null | undefined
>();
>(selectableConsumer ? initialSelectedConsumer : null);
const setRule = (value: InitialRule) => {
dispatch({ command: { type: 'setRule' }, payload: { key: 'rule', value } });
@ -218,12 +223,14 @@ const RuleAdd = ({
getRuleErrors(
{
...rule,
...(selectedConsumer !== undefined ? { consumer: selectedConsumer } : {}),
...(selectableConsumer && selectedConsumer !== undefined
? { consumer: selectedConsumer }
: {}),
} as Rule,
ruleType,
config
),
[rule, selectedConsumer, ruleType, config]
[rule, selectedConsumer, selectableConsumer, ruleType, config]
);
// Confirm before saving if user is able to add actions but hasn't added any to this rule
@ -235,7 +242,7 @@ const RuleAdd = ({
http,
rule: {
...rule,
...(selectedConsumer ? { consumer: selectedConsumer } : {}),
...(selectableConsumer && selectedConsumer ? { consumer: selectedConsumer } : {}),
} as RuleUpdates,
});
toasts.addSuccess(
@ -298,6 +305,7 @@ const RuleAdd = ({
}
)}
validConsumers={validConsumers}
selectedConsumer={selectedConsumer}
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
metadata={metadata}
@ -305,7 +313,6 @@ const RuleAdd = ({
hideGrouping={hideGrouping}
hideInterval={hideInterval}
onChangeMetaData={onChangeMetaData}
selectedConsumer={selectedConsumer}
setConsumer={setSelectedConsumer}
useRuleProducer={useRuleProducer}
/>

View file

@ -596,7 +596,7 @@ describe('rule_form', () => {
expect(mockSetConsumer).toHaveBeenLastCalledWith('infrastructure');
});
it('should be able to select multiple consumer', async () => {
it('should render multiple consumers in the dropdown and select the first one in the list if no default is specified', async () => {
await setup({
initialRuleOverwrite: {
name: 'Simple rule',
@ -660,7 +660,7 @@ describe('rule_form', () => {
wrapper.update();
});
expect(mockSetConsumer).toHaveBeenLastCalledWith(null);
expect(mockSetConsumer).toHaveBeenLastCalledWith('infrastructure');
});
it('should not display the consumer select for invalid rule types', async () => {

View file

@ -169,6 +169,7 @@ export const RuleForm = ({
setHasActionsDisabled,
setHasActionsWithBrokenConnector,
setConsumer = NOOP,
selectedConsumer,
operation,
ruleTypeRegistry,
actionTypeRegistry,
@ -177,7 +178,6 @@ export const RuleForm = ({
hideGrouping = false,
hideInterval,
connectorFeatureId = AlertingConnectorFeatureId,
selectedConsumer,
validConsumers,
onChangeMetaData,
useRuleProducer,
@ -253,11 +253,11 @@ export const RuleForm = ({
validConsumers,
})
)
.filter((item) => {
return rule.consumer === ALERTS_FEATURE_ID
.filter((item) =>
rule.consumer === ALERTS_FEATURE_ID
? !item.ruleTypeModel.requiresAppContext
: item.ruleType!.producer === rule.consumer;
});
: item.ruleType!.producer === rule.consumer
);
const availableRuleTypesResult = getAvailableRuleTypes(ruleTypes);
setAvailableRuleTypes(availableRuleTypesResult);
@ -427,6 +427,7 @@ export const RuleForm = ({
const selectedRuleType = availableRuleTypes.find(
({ ruleType: availableRuleType }) => availableRuleType.id === rule.ruleTypeId
);
if (!selectedRuleType?.ruleType?.authorizedConsumers) {
return [];
}
@ -812,6 +813,7 @@ export const RuleForm = ({
consumers={authorizedConsumers}
onChange={setConsumer}
errors={errors}
selectedConsumer={selectedConsumer}
/>
</EuiFlexItem>
</>

View file

@ -21,29 +21,30 @@ describe('RuleFormConsumerSelectionModal', () => {
it('renders correctly', async () => {
render(
<RuleFormConsumerSelection consumers={mockConsumers} onChange={mockOnChange} errors={{}} />
<RuleFormConsumerSelection
selectedConsumer={null}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{}}
/>
);
expect(screen.getByTestId('ruleFormConsumerSelect')).toBeInTheDocument();
expect(screen.getByText('Select a scope')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
expect(screen.getByText('Logs')).toBeInTheDocument();
expect(screen.getByText('Metrics')).toBeInTheDocument();
expect(screen.getByText('Stack Rules')).toBeInTheDocument();
});
it('should initialize dropdown to null', () => {
render(
<RuleFormConsumerSelection consumers={mockConsumers} onChange={mockOnChange} errors={{}} />
);
// Selects first option if no initial value is provided
expect(mockOnChange).toHaveBeenLastCalledWith(null);
mockOnChange.mockClear();
});
it('should be able to select infrastructure and call onChange', () => {
render(
<RuleFormConsumerSelection consumers={mockConsumers} onChange={mockOnChange} errors={{}} />
<RuleFormConsumerSelection
selectedConsumer={null}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{}}
/>
);
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
@ -53,7 +54,12 @@ describe('RuleFormConsumerSelectionModal', () => {
it('should be able to select logs and call onChange', () => {
render(
<RuleFormConsumerSelection consumers={mockConsumers} onChange={mockOnChange} errors={{}} />
<RuleFormConsumerSelection
selectedConsumer={null}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{}}
/>
);
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
@ -64,6 +70,7 @@ describe('RuleFormConsumerSelectionModal', () => {
it('should be able to show errors when there is one', () => {
render(
<RuleFormConsumerSelection
selectedConsumer={null}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{ consumer: ['Scope is required'] }}
@ -74,23 +81,55 @@ describe('RuleFormConsumerSelectionModal', () => {
it('should display nothing if there is only 1 consumer to select', () => {
render(
<RuleFormConsumerSelection consumers={['stackAlerts']} onChange={mockOnChange} errors={{}} />
<RuleFormConsumerSelection
selectedConsumer={null}
consumers={['stackAlerts']}
onChange={mockOnChange}
errors={{}}
/>
);
expect(mockOnChange).toHaveBeenLastCalledWith('stackAlerts');
expect(screen.queryByTestId('ruleFormConsumerSelect')).not.toBeInTheDocument();
});
it('should display nothing if observability is one of the consumer', () => {
it('should display nothing if observability is one of the consumers', () => {
render(
<RuleFormConsumerSelection
selectedConsumer={null}
consumers={['logs', 'observability']}
onChange={mockOnChange}
errors={{}}
/>
);
expect(mockOnChange).toHaveBeenLastCalledWith('observability');
expect(screen.queryByTestId('ruleFormConsumerSelect')).not.toBeInTheDocument();
});
it('should display the initial selected consumer', () => {
render(
<RuleFormConsumerSelection
selectedConsumer={'logs'}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{}}
/>
);
expect(screen.getByText('Logs')).toBeInTheDocument();
expect(() => screen.getByText('Select a scope')).toThrow();
});
it('should not display the initial selected consumer if it is not a selectable option', () => {
render(
<RuleFormConsumerSelection
selectedConsumer={'logs'}
consumers={['stackAlerts', 'infrastructure']}
onChange={mockOnChange}
errors={{}}
/>
);
expect(() => screen.getByText('Logs')).toThrow();
expect(screen.getByText('Select a scope')).toBeInTheDocument();
});
});

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import React, { useMemo, useCallback, useEffect } from 'react';
import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { AlertConsumers, STACK_ALERTS_FEATURE_ID } from '@kbn/rule-data-utils';
import { IErrorObject, RuleCreationValidConsumer } from '../../../types';
const SELECT_LABEL: string = i18n.translate(
@ -67,33 +67,41 @@ export interface RuleFormConsumerSelectionProps {
consumers: RuleCreationValidConsumer[];
onChange: (consumer: RuleCreationValidConsumer | null) => void;
errors: IErrorObject;
selectedConsumer: RuleCreationValidConsumer | null | undefined;
}
const SINGLE_SELECTION = { asPlainText: true };
export const RuleFormConsumerSelection = (props: RuleFormConsumerSelectionProps) => {
const { consumers, errors, onChange } = props;
const [selectedConsumer, setSelectedConsumer] = useState<RuleCreationValidConsumer | undefined>();
const { consumers, errors, onChange, selectedConsumer } = props;
const isInvalid = errors?.consumer?.length > 0;
const handleOnChange = useCallback(
(selected: Array<EuiComboBoxOptionOption<RuleCreationValidConsumer>>) => {
if (selected.length > 0) {
const newSelectedConsumer = selected[0];
setSelectedConsumer(newSelectedConsumer.value);
onChange(newSelectedConsumer.value!);
} else {
setSelectedConsumer(undefined);
onChange(null);
}
},
[onChange]
);
const validatedSelectedConsumer = useMemo(() => {
if (
selectedConsumer &&
consumers.includes(selectedConsumer) &&
featureNameMap[selectedConsumer]
) {
return selectedConsumer;
}
return null;
}, [selectedConsumer, consumers]);
const selectedOptions = useMemo(
() =>
selectedConsumer
? [{ value: selectedConsumer, label: featureNameMap[selectedConsumer] }]
validatedSelectedConsumer
? [{ value: validatedSelectedConsumer, label: featureNameMap[validatedSelectedConsumer] }]
: [],
[selectedConsumer]
[validatedSelectedConsumer]
);
const formattedSelectOptions: Array<EuiComboBoxOptionOption<RuleCreationValidConsumer>> =
@ -115,8 +123,14 @@ export const RuleFormConsumerSelection = (props: RuleFormConsumerSelectionProps)
}, [consumers]);
useEffect(() => {
// At initialization we set to NULL to know that nobody selected anything
onChange(null);
// At initialization, select Stack Alerts, or the first value
if (!validatedSelectedConsumer) {
if (consumers.includes(STACK_ALERTS_FEATURE_ID)) {
onChange(STACK_ALERTS_FEATURE_ID);
return;
}
onChange(consumers[0] as RuleCreationValidConsumer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View file

@ -40,7 +40,10 @@ import {
RuleExecutionStatusErrorReasons,
RuleLastRunOutcomeValues,
} from '@kbn/alerting-plugin/common';
import { ruleDetailsRoute as commonRuleDetailsRoute } from '@kbn/rule-data-utils';
import {
ruleDetailsRoute as commonRuleDetailsRoute,
STACK_ALERTS_FEATURE_ID,
} from '@kbn/rule-data-utils';
import { MaintenanceWindowCallout } from '@kbn/alerts-ui-shared';
import {
Rule,
@ -1004,6 +1007,7 @@ export const RulesList = ({
ruleTypeRegistry={ruleTypeRegistry}
ruleTypeIndex={ruleTypesState.data}
onSave={refreshRules}
initialSelectedConsumer={STACK_ALERTS_FEATURE_ID}
/>
</Suspense>
)}

View file

@ -25,7 +25,7 @@ import type {
EuiSuperSelectOption,
EuiDataGridOnColumnResizeHandler,
} from '@elastic/eui';
import type { AlertConsumers, STACK_ALERTS_FEATURE_ID, ValidFeatureId } from '@kbn/rule-data-utils';
import type { RuleCreationValidConsumer, ValidFeatureId } from '@kbn/rule-data-utils';
import { EuiDataGridColumn, EuiDataGridControlColumn, EuiDataGridSorting } from '@elastic/eui';
import { HttpSetup } from '@kbn/core/public';
import { KueryNode } from '@kbn/es-query';
@ -468,6 +468,7 @@ export interface RuleAddProps<MetaData = Record<string, any>> {
filteredRuleTypes?: string[];
validConsumers?: RuleCreationValidConsumer[];
useRuleProducer?: boolean;
initialSelectedConsumer?: RuleCreationValidConsumer | null;
}
export interface RuleDefinitionProps {
rule: Rule;
@ -852,8 +853,4 @@ export interface NotifyWhenSelectOptions {
value: EuiSuperSelectOption<RuleNotifyWhenType>;
}
export type RuleCreationValidConsumer =
| typeof AlertConsumers.LOGS
| typeof AlertConsumers.INFRASTRUCTURE
| typeof AlertConsumers.OBSERVABILITY
| typeof STACK_ALERTS_FEATURE_ID;
export type { RuleCreationValidConsumer } from '@kbn/rule-data-utils';

View file

@ -143,6 +143,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
feature: {
actions: ['all'],
stackAlerts: ['all'],
logs: ['all'],
discover: ['all'],
advancedSettings: ['all'],
indexPatterns: ['all'],