[Actionable Observability] Expose ObservabilityAlertSearchBar from Observability plugin (#146401)

Resolves #146286

## 📝 Summary

In this PR, I exposed ObservabilityAlertSearchBar from the Observability
plugin to be used in other plugins such as APM.
I've added `ObservabilityAlertSearchBarProvider` in order for other
plugins to provide Kibana dependencies to the shared component.

## 🧪 How to test

For testing the implementation, I imported this component in the APM
plugin and used it in the alerts tab, you can do the same locally by
following these steps:
1. Import `ObservabilityAlertSearchBar` in
[APM](https://github.com/elastic/kibana/blob/main/x-pack/plugins/apm/public/components/app/alerts_overview/index.tsx)
and define related hook:
```
import {
  ObservabilityAlertSearchBar,
  ObservabilityAlertSearchBarProvider,
} from '@kbn/observability-plugin/public';

export const useToasts = () =>
  useKibana<ApmPluginStartDeps>().services.notifications!.toasts;
```

2. Replace
[AlertsTableStatusFilter](https://github.com/elastic/kibana/blob/main/x-pack/plugins/apm/public/components/app/alerts_overview/index.tsx#L74)
with the `ObservabilityAlertSearchBar` component:
```
<ObservabilityAlertSearchBarProvider
  {...services}
  useToasts={useToasts}
>
  <ObservabilityAlertSearchBar
    appName={'apmApp'}
    kuery={''}
    onRangeFromChange={(input) => console.log(input)}
    onRangeToChange={(input) => console.log(input)}
    onKueryChange={(input) => console.log(input)}
    onStatusChange={(input) => console.log(input)}
    onEsQueryChange={(input) => console.log(input)}
    rangeTo={'now'}
    rangeFrom={'now-15m'}
    status={'all'}
  />
</ObservabilityAlertSearchBarProvider>
```
You should see the new search bar in APM alerts tab:


![image](https://user-images.githubusercontent.com/12370520/204302146-c0ff4658-67ab-4639-a955-b75a647f57da.png)
This commit is contained in:
Maryam Saeidi 2022-12-07 10:20:01 +01:00 committed by GitHub
parent 98412f795e
commit 9b828e3c4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 264 additions and 125 deletions

View file

@ -5,57 +5,51 @@
* 2.0.
*/
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import React from 'react';
import { act, waitFor } from '@testing-library/react';
import { AlertSearchBarProps } from './types';
import { waitFor } from '@testing-library/react';
import { timefilterServiceMock } from '@kbn/data-plugin/public/query/timefilter/timefilter_service.mock';
import { useServices } from './services';
import { ObservabilityAlertSearchBarProps } from './types';
import { ObservabilityAlertSearchBar } from './alert_search_bar';
import { observabilityAlertFeatureIds } from '../../../config';
import { useKibana } from '../../../utils/kibana_react';
import { kibanaStartMock } from '../../../utils/kibana_react.mock';
import { render } from '../../../utils/test_helper';
const useKibanaMock = useKibana as jest.Mock;
const useServicesMock = useServices as jest.Mock;
const getAlertsSearchBarMock = jest.fn();
const ALERT_SEARCH_BAR_DATA_TEST_SUBJ = 'alerts-search-bar';
const ACTIVE_BUTTON_DATA_TEST_SUBJ = 'alert-status-filter-active-button';
jest.mock('../../../utils/kibana_react');
jest.mock('./services');
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
...kibanaStartMock.startContract().services,
triggersActionsUi: {
...triggersActionsUiMock.createStart(),
getAlertsSearchBar: getAlertsSearchBarMock.mockReturnValue(
<div data-test-subj={ALERT_SEARCH_BAR_DATA_TEST_SUBJ} />
),
},
},
const mockServices = () => {
useServicesMock.mockReturnValue({
timeFilterService: timefilterServiceMock,
AlertsSearchBar: getAlertsSearchBarMock.mockReturnValue(
<div data-test-subj={ALERT_SEARCH_BAR_DATA_TEST_SUBJ} />
),
useToasts: jest.fn(),
});
};
describe('ObservabilityAlertSearchBar', () => {
const renderComponent = (props: Partial<AlertSearchBarProps> = {}) => {
const alertSearchBarProps: AlertSearchBarProps = {
const renderComponent = (props: Partial<ObservabilityAlertSearchBarProps> = {}) => {
const observabilityAlertSearchBarProps: ObservabilityAlertSearchBarProps = {
appName: 'testAppName',
rangeFrom: 'now-15m',
setRangeFrom: jest.fn(),
rangeTo: 'now',
setRangeTo: jest.fn(),
kuery: '',
setKuery: jest.fn(),
status: 'active',
setStatus: jest.fn(),
setEsQuery: jest.fn(),
onRangeFromChange: jest.fn(),
onRangeToChange: jest.fn(),
onKueryChange: jest.fn(),
onStatusChange: jest.fn(),
onEsQueryChange: jest.fn(),
rangeTo: 'now',
rangeFrom: 'now-15m',
status: 'all',
...props,
};
return render(<ObservabilityAlertSearchBar {...alertSearchBarProps} />);
return render(<ObservabilityAlertSearchBar {...observabilityAlertSearchBarProps} />);
};
beforeAll(() => {
mockKibana();
mockServices();
});
beforeEach(() => {
@ -71,9 +65,7 @@ describe('ObservabilityAlertSearchBar', () => {
});
it('should call alert search bar with correct props', () => {
act(() => {
renderComponent();
});
renderComponent();
expect(getAlertsSearchBarMock).toHaveBeenCalledWith(
expect.objectContaining({
@ -88,21 +80,18 @@ describe('ObservabilityAlertSearchBar', () => {
});
it('should filter active alerts', async () => {
const mockedSetEsQuery = jest.fn();
const mockedOnEsQueryChange = jest.fn();
const mockedFrom = '2022-11-15T09:38:13.604Z';
const mockedTo = '2022-11-15T09:53:13.604Z';
const { getByTestId } = renderComponent({
setEsQuery: mockedSetEsQuery,
renderComponent({
onEsQueryChange: mockedOnEsQueryChange,
rangeFrom: mockedFrom,
rangeTo: mockedTo,
status: 'active',
});
await act(async () => {
const activeButton = getByTestId(ACTIVE_BUTTON_DATA_TEST_SUBJ);
activeButton.click();
});
expect(mockedSetEsQuery).toHaveBeenCalledWith({
expect(mockedOnEsQueryChange).toHaveBeenCalledWith({
bool: {
filter: [
{
@ -127,4 +116,51 @@ describe('ObservabilityAlertSearchBar', () => {
},
});
});
it('should include defaultSearchQueries in es query', async () => {
const mockedOnEsQueryChange = jest.fn();
const mockedFrom = '2022-11-15T09:38:13.604Z';
const mockedTo = '2022-11-15T09:53:13.604Z';
const defaultSearchQueries = [
{
query: 'kibana.alert.rule.uuid: 413a9631-1a29-4344-a8b4-9a1dc23421ee',
language: 'kuery',
},
];
renderComponent({
onEsQueryChange: mockedOnEsQueryChange,
rangeFrom: mockedFrom,
rangeTo: mockedTo,
defaultSearchQueries,
status: 'all',
});
expect(mockedOnEsQueryChange).toHaveBeenCalledWith({
bool: {
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{ match: { 'kibana.alert.rule.uuid': '413a9631-1a29-4344-a8b4-9a1dc23421ee' } },
],
},
},
{
range: {
'@timestamp': expect.objectContaining({
format: 'strict_date_optional_time',
gte: mockedFrom,
lte: mockedTo,
}),
},
},
],
must: [],
must_not: [],
should: [],
},
});
});
});

View file

@ -6,16 +6,15 @@
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { Query } from '@kbn/es-query';
import { useKibana } from '../../../utils/kibana_react';
import { observabilityAlertFeatureIds } from '../../../config';
import { ObservabilityAppServices } from '../../../application/types';
import { useServices } from './services';
import { AlertsStatusFilter } from './components';
import { observabilityAlertFeatureIds } from '../../../config';
import { ALERT_STATUS_QUERY, DEFAULT_QUERIES, DEFAULT_QUERY_STRING } from './constants';
import { AlertSearchBarProps } from './types';
import { ObservabilityAlertSearchBarProps } from './types';
import { buildEsQuery } from '../../../utils/build_es_query';
import { AlertStatus } from '../../../../common/typings';
@ -27,83 +26,79 @@ const getAlertStatusQuery = (status: string): Query[] => {
export function ObservabilityAlertSearchBar({
appName,
rangeFrom,
setRangeFrom,
rangeTo,
setRangeTo,
defaultSearchQueries = DEFAULT_QUERIES,
onEsQueryChange,
onKueryChange,
onRangeFromChange,
onRangeToChange,
onStatusChange,
kuery,
setKuery,
rangeFrom,
rangeTo,
status,
setStatus,
setEsQuery,
queries = DEFAULT_QUERIES,
}: AlertSearchBarProps) {
const {
data: {
query: {
timefilter: { timefilter: timeFilterService },
},
},
notifications: { toasts },
triggersActionsUi: { getAlertsSearchBar: AlertsSearchBar },
} = useKibana<ObservabilityAppServices>().services;
}: ObservabilityAlertSearchBarProps) {
const { AlertsSearchBar, timeFilterService, useToasts } = useServices();
const toasts = useToasts();
const onStatusChange = useCallback(
const onAlertStatusChange = useCallback(
(alertStatus: AlertStatus) => {
setEsQuery(
onEsQueryChange(
buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
},
kuery,
[...getAlertStatusQuery(alertStatus), ...queries]
[...getAlertStatusQuery(alertStatus), ...defaultSearchQueries]
)
);
},
[kuery, queries, rangeFrom, rangeTo, setEsQuery]
[kuery, defaultSearchQueries, rangeFrom, rangeTo, onEsQueryChange]
);
useEffect(() => {
onStatusChange(status);
}, [onStatusChange, status]);
onAlertStatusChange(status);
}, [onAlertStatusChange, status]);
const onSearchBarParamsChange = useCallback(
const onSearchBarParamsChange = useCallback<
(query: {
dateRange: { from: string; to: string; mode?: 'absolute' | 'relative' };
query: string;
}) => void
>(
({ dateRange, query }) => {
try {
// First try to create es query to make sure query is valid, then save it in state
const esQuery = buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
to: dateRange.to,
from: dateRange.from,
},
query,
[...getAlertStatusQuery(status), ...queries]
[...getAlertStatusQuery(status), ...defaultSearchQueries]
);
setKuery(query);
onKueryChange(query);
timeFilterService.setTime(dateRange);
setRangeFrom(dateRange.from);
setRangeTo(dateRange.to);
setEsQuery(esQuery);
onRangeFromChange(dateRange.from);
onRangeToChange(dateRange.to);
onEsQueryChange(esQuery);
} catch (error) {
toasts.addError(error, {
title: i18n.translate('xpack.observability.alerts.searchBar.invalidQueryTitle', {
defaultMessage: 'Invalid query string',
}),
});
setKuery(DEFAULT_QUERY_STRING);
onKueryChange(DEFAULT_QUERY_STRING);
}
},
[
defaultSearchQueries,
timeFilterService,
setRangeFrom,
setRangeTo,
setKuery,
setEsQuery,
rangeTo,
rangeFrom,
onRangeFromChange,
onRangeToChange,
onKueryChange,
onEsQueryChange,
status,
queries,
toasts,
]
);
@ -124,15 +119,13 @@ export function ObservabilityAlertSearchBar({
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<AlertsStatusFilter
status={status}
onChange={(id) => {
setStatus(id as AlertStatus);
}}
/>
<AlertsStatusFilter status={status} onChange={onStatusChange} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
// eslint-disable-next-line import/no-default-export
export default ObservabilityAlertSearchBar;

View file

@ -12,13 +12,26 @@ import {
useAlertSearchBarStateContainer,
} from './containers';
import { ObservabilityAlertSearchBar } from './alert_search_bar';
import { ObservabilityAlertSearchBarProvider } from './services';
import { AlertSearchBarWithUrlSyncProps } from './types';
import { useKibana } from '../../../utils/kibana_react';
import { ObservabilityAppServices } from '../../../application/types';
import { useToasts } from '../../../hooks/use_toast';
function AlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) {
const { urlStorageKey, ...searchBarProps } = props;
const stateProps = useAlertSearchBarStateContainer(urlStorageKey);
const { data, triggersActionsUi } = useKibana<ObservabilityAppServices>().services;
return <ObservabilityAlertSearchBar {...stateProps} {...searchBarProps} />;
return (
<ObservabilityAlertSearchBarProvider
data={data}
triggersActionsUi={triggersActionsUi}
useToasts={useToasts}
>
<ObservabilityAlertSearchBar {...stateProps} {...searchBarProps} />
</ObservabilityAlertSearchBarProvider>
);
}
export function ObservabilityAlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) {

View file

@ -6,9 +6,11 @@
*/
import { EuiButtonGroup, EuiButtonGroupOptionProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ALL_ALERTS, ACTIVE_ALERTS, RECOVERED_ALERTS } from '../constants';
import { AlertStatusFilterProps } from '../types';
import { AlertStatus } from '../../../../../common/typings';
const options: EuiButtonGroupOptionProps[] = [
{
@ -34,11 +36,13 @@ const options: EuiButtonGroupOptionProps[] = [
export function AlertsStatusFilter({ status, onChange }: AlertStatusFilterProps) {
return (
<EuiButtonGroup
legend="Filter by"
legend={i18n.translate('xpack.observability.alerts.alertStatusFilter.legend', {
defaultMessage: 'Filter by',
})}
color="primary"
options={options}
idSelected={status}
onChange={onChange}
onChange={(id) => onChange(id as AlertStatus)}
/>
);
}

View file

@ -52,14 +52,14 @@ export function useAlertSearchBarStateContainer(urlStorageKey: string) {
);
return {
rangeFrom,
setRangeFrom,
rangeTo,
setRangeTo,
kuery,
setKuery,
onKueryChange: setKuery,
onRangeFromChange: setRangeFrom,
onRangeToChange: setRangeTo,
onStatusChange: setStatus,
rangeFrom,
rangeTo,
status,
setStatus,
};
}

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useContext } from 'react';
import { ObservabilityAlertSearchBarDependencies, Services } from './types';
const ObservabilityAlertSearchBarContext = React.createContext<Services | null>(null);
export const ObservabilityAlertSearchBarProvider: FC<ObservabilityAlertSearchBarDependencies> = ({
children,
data: {
query: {
timefilter: { timefilter: timeFilterService },
},
},
useToasts,
triggersActionsUi: { getAlertsSearchBar: AlertsSearchBar },
}) => {
const services = {
timeFilterService,
useToasts,
AlertsSearchBar,
};
return (
<ObservabilityAlertSearchBarContext.Provider value={services}>
{children}
</ObservabilityAlertSearchBarContext.Provider>
);
};
export function useServices() {
const context = useContext(ObservabilityAlertSearchBarContext);
if (!context) {
throw new Error(
'ObservabilityAlertSearchBarContext is missing. Ensure your component or React root is wrapped with ObservabilityAlertSearchBarProvider.'
);
}
return context;
}

View file

@ -5,14 +5,46 @@
* 2.0.
*/
import { ReactElement } from 'react';
import { ToastsStart } from '@kbn/core-notifications-browser';
import { TimefilterContract } from '@kbn/data-plugin/public';
import { AlertsSearchBarProps } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alerts_search_bar';
import { BoolQuery, Query } from '@kbn/es-query';
import { AlertStatus } from '../../../../common/typings';
export interface AlertStatusFilterProps {
status: AlertStatus;
onChange: (id: string, value: string) => void;
onChange: (id: AlertStatus) => void;
}
export interface AlertSearchBarWithUrlSyncProps extends CommonAlertSearchBarProps {
urlStorageKey: string;
}
export interface Dependencies {
data: {
query: {
timefilter: { timefilter: TimefilterContract };
};
};
triggersActionsUi: {
getAlertsSearchBar: (props: AlertsSearchBarProps) => ReactElement<AlertsSearchBarProps>;
};
useToasts: () => ToastsStart;
}
export type ObservabilityAlertSearchBarDependencies = Dependencies;
export interface Services {
timeFilterService: TimefilterContract;
AlertsSearchBar: (props: AlertsSearchBarProps) => ReactElement<AlertsSearchBarProps>;
useToasts: () => ToastsStart;
}
export type ObservabilityAlertSearchBarProps = AlertSearchBarContainerState &
AlertSearchBarStateTransitions &
CommonAlertSearchBarProps;
interface AlertSearchBarContainerState {
rangeFrom: string;
rangeTo: string;
@ -21,23 +53,14 @@ interface AlertSearchBarContainerState {
}
interface AlertSearchBarStateTransitions {
setRangeFrom: (rangeFrom: string) => void;
setRangeTo: (rangeTo: string) => void;
setKuery: (kuery: string) => void;
setStatus: (status: AlertStatus) => void;
onRangeFromChange: (rangeFrom: string) => void;
onRangeToChange: (rangeTo: string) => void;
onKueryChange: (kuery: string) => void;
onStatusChange: (status: AlertStatus) => void;
}
export interface CommonAlertSearchBarProps {
interface CommonAlertSearchBarProps {
appName: string;
setEsQuery: (query: { bool: BoolQuery }) => void;
queries?: Query[];
onEsQueryChange: (query: { bool: BoolQuery }) => void;
defaultSearchQueries?: Query[];
}
export interface AlertSearchBarWithUrlSyncProps extends CommonAlertSearchBarProps {
urlStorageKey: string;
}
export interface AlertSearchBarProps
extends AlertSearchBarContainerState,
AlertSearchBarStateTransitions,
CommonAlertSearchBarProps {}

View file

@ -8,6 +8,7 @@
import React, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { LoadWhenInViewProps } from './load_when_in_view/load_when_in_view';
import { ObservabilityAlertSearchBarProps } from './alert_search_bar/types';
import type { CoreVitalProps, HeaderMenuPortalProps } from './types';
import type {
FieldValueSuggestionsProps,
@ -113,3 +114,13 @@ export function LoadWhenInView(props: LoadWhenInViewProps) {
</Suspense>
);
}
const ObservabilityAlertSearchBarLazy = lazy(() => import('./alert_search_bar/alert_search_bar'));
export function ObservabilityAlertSearchBar(props: ObservabilityAlertSearchBarProps) {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<ObservabilityAlertSearchBarLazy {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ObservabilityAppServices } from '../application/types';
export const useToasts = () => useKibana<ObservabilityAppServices>().services.notifications.toasts;

View file

@ -47,6 +47,8 @@ export * from './components/shared/action_menu';
export type { UXMetrics } from './components/shared/core_web_vitals';
export { DatePickerContextProvider } from './context/date_picker_context';
export { ObservabilityAlertSearchBarProvider } from './components/shared/alert_search_bar/services';
export {
getCoreVitalsComponent,
HeaderMenuPortal,
@ -57,6 +59,7 @@ export {
ExploratoryView,
DatePicker,
LoadWhenInView,
ObservabilityAlertSearchBar,
} from './components/shared';
export type { LazyObservabilityPageTemplateProps } from './components/shared';

View file

@ -136,7 +136,7 @@ export function AlertsPage() {
<EuiFlexItem>
<ObservabilityAlertSearchbarWithUrlSync
appName={ALERTS_SEARCH_BAR_ID}
setEsQuery={setEsQuery}
onEsQueryChange={setEsQuery}
urlStorageKey={URL_STORAGE_KEY}
/>
</EuiFlexItem>

View file

@ -223,9 +223,9 @@ export function RuleDetailsPage() {
<EuiSpacer size="m" />
<ObservabilityAlertSearchbarWithUrlSync
appName={RULE_DETAILS_ALERTS_SEARCH_BAR_ID}
setEsQuery={setEsQuery}
onEsQueryChange={setEsQuery}
urlStorageKey={URL_STORAGE_KEY}
queries={ruleQuery.current}
defaultSearchQueries={ruleQuery.current}
/>
<EuiSpacer size="s" />
<EuiFlexGroup style={{ minHeight: 450 }} direction={'column'}>

View file

@ -15,8 +15,8 @@ export interface AlertsSearchBarProps {
rangeFrom?: string;
rangeTo?: string;
query?: string;
onQueryChange: ({}: {
onQueryChange: (query: {
dateRange: { from: string; to: string; mode?: 'absolute' | 'relative' };
query?: string;
query: string;
}) => void;
}