[Actionable Observability] Integrate alert search bar on rule details page (#144718)

Resolves #143962

## 📝 Summary
In this PR, an alerts search bar was added to the rule details page by
syncing its state to the URL. This will enable navigating to the alerts
table for a specific rule with a filtered state based on active or
recovered.
### Notes
- Renamed alert page container to alert search bar container and used it
both in alerts and rule details page (it will be responsible to sync
search bar params to the URL) --> moved to a shared component
- Moved AlertsStatusFilter to be a sub-component of the shared
observability search bar
- Allowed ObservabilityAlertSearchBar to be used both as a stand-alone
component and as a wired component with syncing params to the URL
(ObservabilityAlertSearchBar, ObservabilityAlertSearchbarWithUrlSync)
- Set a minHeight for the Alerts and Execution tab, otherwise, the page
will have extra scroll on the tab change while content is loading (very
annoying!)

## 🎨 Preview

![image](https://user-images.githubusercontent.com/12370520/200547324-d9c4ef3c-8a82-4c16-88bd-f1d4b2bc8006.png)

## 🧪 How to test
- Create a rule and go to the rule details page
- Click on the alerts tab and change the search criteria, you should be
able to see the criteria in the query parameter
- Refresh the page, alerts tab should be selected and you should be able
to see the filters that you applied in the previous step
- As a side test, check alert search bar on alerts page as well, it
should work as before

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Maryam Saeidi 2022-11-09 16:18:16 +01:00 committed by GitHub
parent b1179e72ff
commit ef7c1a689b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 549 additions and 292 deletions

View file

@ -0,0 +1,124 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback, useEffect } from 'react';
import { Query } from '@kbn/es-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { observabilityAlertFeatureIds } from '../../../config';
import { ObservabilityAppServices } from '../../../application/types';
import { AlertsStatusFilter } from './components';
import { ALERT_STATUS_QUERY, DEFAULT_QUERIES } from './constants';
import { AlertSearchBarProps } from './types';
import { buildEsQuery } from '../../../utils/build_es_query';
import { AlertStatus } from '../../../../common/typings';
const getAlertStatusQuery = (status: string): Query[] => {
return status ? [{ query: ALERT_STATUS_QUERY[status], language: 'kuery' }] : [];
};
export function AlertSearchBar({
appName,
rangeFrom,
setRangeFrom,
rangeTo,
setRangeTo,
kuery,
setKuery,
status,
setStatus,
setEsQuery,
queries = DEFAULT_QUERIES,
}: AlertSearchBarProps) {
const {
data: {
query: {
timefilter: { timefilter: timeFilterService },
},
},
triggersActionsUi: { getAlertsSearchBar: AlertsSearchBar },
} = useKibana<ObservabilityAppServices>().services;
const onStatusChange = useCallback(
(alertStatus: AlertStatus) => {
setEsQuery(
buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
},
kuery,
[...getAlertStatusQuery(alertStatus), ...queries]
)
);
},
[kuery, queries, rangeFrom, rangeTo, setEsQuery]
);
useEffect(() => {
onStatusChange(status);
}, [onStatusChange, status]);
const onSearchBarParamsChange = useCallback(
({ dateRange, query }) => {
timeFilterService.setTime(dateRange);
setRangeFrom(dateRange.from);
setRangeTo(dateRange.to);
setKuery(query);
setEsQuery(
buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
},
query,
[...getAlertStatusQuery(status), ...queries]
)
);
},
[
timeFilterService,
setRangeFrom,
setRangeTo,
setKuery,
setEsQuery,
rangeTo,
rangeFrom,
status,
queries,
]
);
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<AlertsSearchBar
appName={appName}
featureIds={observabilityAlertFeatureIds}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
query={kuery}
onQueryChange={onSearchBarParamsChange}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<AlertsStatusFilter
status={status}
onChange={(id) => {
setStatus(id as AlertStatus);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,29 @@
/*
* 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 from 'react';
import {
alertSearchBarStateContainer,
Provider,
useAlertSearchBarStateContainer,
} from './containers';
import { AlertSearchBar } from './alert_search_bar';
import { AlertSearchBarWithUrlSyncProps } from './types';
function InternalAlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) {
const stateProps = useAlertSearchBarStateContainer();
return <AlertSearchBar {...props} {...stateProps} />;
}
export function AlertSearchbarWithUrlSync(props: AlertSearchBarWithUrlSyncProps) {
return (
<Provider value={alertSearchBarStateContainer}>
<InternalAlertSearchbarWithUrlSync {...props} />
</Provider>
);
}

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiButtonGroup, EuiButtonGroupOptionProps } from '@elastic/eui';
import React from 'react';
import { ALL_ALERTS, ACTIVE_ALERTS, RECOVERED_ALERTS } from '../constants';
import { AlertStatusFilterProps } from '../types';
const options: EuiButtonGroupOptionProps[] = [
{
id: ALL_ALERTS.status,
label: ALL_ALERTS.label,
value: ALL_ALERTS.query,
'data-test-subj': 'alert-status-filter-show-all-button',
},
{
id: ACTIVE_ALERTS.status,
label: ACTIVE_ALERTS.label,
value: ACTIVE_ALERTS.query,
'data-test-subj': 'alert-status-filter-active-button',
},
{
id: RECOVERED_ALERTS.status,
label: RECOVERED_ALERTS.label,
value: RECOVERED_ALERTS.query,
'data-test-subj': 'alert-status-filter-recovered-button',
},
];
export function AlertsStatusFilter({ status, onChange }: AlertStatusFilterProps) {
return (
<EuiButtonGroup
legend="Filter by"
color="primary"
options={options}
idSelected={status}
onChange={onChange}
/>
);
}

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export { Provider, alertsPageStateContainer } from './state_container';
export { useAlertsPageStateContainer } from './use_alerts_page_state_container';
export { AlertsStatusFilter } from './alerts_status_filter';

View file

@ -5,17 +5,12 @@
* 2.0.
*/
import { EuiButtonGroup, EuiButtonGroupOptionProps } from '@elastic/eui';
import { Query } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, ALERT_STATUS } from '@kbn/rule-data-utils';
import { AlertStatus } from '../../../../common/typings';
import { AlertStatusFilter } from '../../../../common/typings';
export interface AlertStatusFilterProps {
status: AlertStatus;
onChange: (id: string, value: string) => void;
}
export const DEFAULT_QUERIES: Query[] = [];
export const ALL_ALERTS: AlertStatusFilter = {
status: '',
@ -45,36 +40,3 @@ export const ALERT_STATUS_QUERY = {
[ACTIVE_ALERTS.status]: ACTIVE_ALERTS.query,
[RECOVERED_ALERTS.status]: RECOVERED_ALERTS.query,
};
const options: EuiButtonGroupOptionProps[] = [
{
id: ALL_ALERTS.status,
label: ALL_ALERTS.label,
value: ALL_ALERTS.query,
'data-test-subj': 'alert-status-filter-show-all-button',
},
{
id: ACTIVE_ALERTS.status,
label: ACTIVE_ALERTS.label,
value: ACTIVE_ALERTS.query,
'data-test-subj': 'alert-status-filter-active-button',
},
{
id: RECOVERED_ALERTS.status,
label: RECOVERED_ALERTS.label,
value: RECOVERED_ALERTS.query,
'data-test-subj': 'alert-status-filter-recovered-button',
},
];
export function AlertsStatusFilter({ status, onChange }: AlertStatusFilterProps) {
return (
<EuiButtonGroup
legend="Filter by"
color="primary"
options={options}
idSelected={status}
onChange={onChange}
/>
);
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { Provider, alertSearchBarStateContainer } from './state_container';
export { useAlertSearchBarStateContainer } from './use_alert_search_bar_state_container';

View file

@ -0,0 +1,62 @@
/*
* 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 {
createStateContainer,
createStateContainerReactHelpers,
} from '@kbn/kibana-utils-plugin/public';
import { AlertStatus } from '../../../../../common/typings';
import { ALL_ALERTS } from '../constants';
interface AlertSearchBarContainerState {
rangeFrom: string;
rangeTo: string;
kuery: string;
status: AlertStatus;
}
interface AlertSearchBarStateTransitions {
setRangeFrom: (
state: AlertSearchBarContainerState
) => (rangeFrom: string) => AlertSearchBarContainerState;
setRangeTo: (
state: AlertSearchBarContainerState
) => (rangeTo: string) => AlertSearchBarContainerState;
setKuery: (
state: AlertSearchBarContainerState
) => (kuery: string) => AlertSearchBarContainerState;
setStatus: (
state: AlertSearchBarContainerState
) => (status: AlertStatus) => AlertSearchBarContainerState;
}
const defaultState: AlertSearchBarContainerState = {
rangeFrom: 'now-15m',
rangeTo: 'now',
kuery: '',
status: ALL_ALERTS.status as AlertStatus,
};
const transitions: AlertSearchBarStateTransitions = {
setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }),
setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }),
setKuery: (state) => (kuery) => ({ ...state, kuery }),
setStatus: (state) => (status) => ({ ...state, status }),
};
const alertSearchBarStateContainer = createStateContainer(defaultState, transitions);
type AlertSearchBarStateContainer = typeof alertSearchBarStateContainer;
const { Provider, useContainer } = createStateContainerReactHelpers<AlertSearchBarStateContainer>();
export { Provider, alertSearchBarStateContainer, useContainer, defaultState };
export type {
AlertSearchBarStateContainer,
AlertSearchBarContainerState,
AlertSearchBarStateTransitions,
};

View file

@ -20,11 +20,11 @@ import { useTimefilterService } from '../../../../hooks/use_timefilter_service';
import {
useContainer,
defaultState,
AlertsPageStateContainer,
AlertsPageContainerState,
AlertSearchBarStateContainer,
AlertSearchBarContainerState,
} from './state_container';
export function useAlertsPageStateContainer() {
export function useAlertSearchBarStateContainer() {
const stateContainer = useContainer();
useUrlStateSyncEffect(stateContainer);
@ -47,7 +47,7 @@ export function useAlertsPageStateContainer() {
};
}
function useUrlStateSyncEffect(stateContainer: AlertsPageStateContainer) {
function useUrlStateSyncEffect(stateContainer: AlertSearchBarStateContainer) {
const history = useHistory();
const timefilterService = useTimefilterService();
@ -68,11 +68,11 @@ function useUrlStateSyncEffect(stateContainer: AlertsPageStateContainer) {
}
function setupUrlStateSync(
stateContainer: AlertsPageStateContainer,
stateContainer: AlertSearchBarStateContainer,
stateStorage: IKbnUrlStateStorage
) {
// This handles filling the state when an incomplete URL set is provided
const setWithDefaults = (changedState: Partial<AlertsPageContainerState> | null) => {
const setWithDefaults = (changedState: Partial<AlertSearchBarContainerState> | null) => {
stateContainer.set({ ...defaultState, ...changedState });
};
@ -88,10 +88,10 @@ function setupUrlStateSync(
function syncUrlStateWithInitialContainerState(
timefilterService: TimefilterContract,
stateContainer: AlertsPageStateContainer,
stateContainer: AlertSearchBarStateContainer,
urlStateStorage: IKbnUrlStateStorage
) {
const urlState = urlStateStorage.get<Partial<AlertsPageContainerState>>('_a');
const urlState = urlStateStorage.get<Partial<AlertSearchBarContainerState>>('_a');
if (urlState) {
const newState = {

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { AlertSearchBar as ObservabilityAlertSearchBar } from './alert_search_bar';
export { AlertSearchbarWithUrlSync as ObservabilityAlertSearchbarWithUrlSync } from './alert_search_bar_with_url_sync';

View file

@ -0,0 +1,39 @@
/*
* 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 { BoolQuery, Query } from '@kbn/es-query';
import { AlertStatus } from '../../../../common/typings';
export interface AlertStatusFilterProps {
status: AlertStatus;
onChange: (id: string, value: string) => void;
}
interface AlertSearchBarContainerState {
rangeFrom: string;
rangeTo: string;
kuery: string;
status: AlertStatus;
}
interface AlertSearchBarStateTransitions {
setRangeFrom: (rangeFrom: string) => AlertSearchBarContainerState;
setRangeTo: (rangeTo: string) => AlertSearchBarContainerState;
setKuery: (kuery: string) => AlertSearchBarContainerState;
setStatus: (status: AlertStatus) => AlertSearchBarContainerState;
}
export interface AlertSearchBarWithUrlSyncProps {
appName: string;
setEsQuery: (query: { bool: BoolQuery }) => void;
queries?: Query[];
}
export interface AlertSearchBarProps
extends AlertSearchBarContainerState,
AlertSearchBarStateTransitions,
AlertSearchBarWithUrlSyncProps {}

View file

@ -11,7 +11,7 @@ import { createObservabilityRuleTypeRegistryMock } from '../../../../rules/obser
import AlertsFlyoutBody from './alerts_flyout_body';
import { inventoryThresholdAlert } from '../../../../rules/fixtures/example_alerts';
import { parseAlert } from '../parse_alert';
import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/types';
import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/constants';
describe('AlertsFlyoutBody', () => {
jest

View file

@ -26,7 +26,7 @@ import {
} from '@kbn/rule-data-utils';
import moment from 'moment-timezone';
import { useKibana, useUiSetting } from '@kbn/kibana-react-plugin/public';
import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/types';
import { RULE_DETAILS_PAGE_ID } from '../../../rule_details/constants';
import { asDuration } from '../../../../../common/utils/formatters';
import { translations, paths } from '../../../../config';
import { AlertStatusIndicator } from '../../../../components/shared/alert_status_indicator';

View file

@ -0,0 +1,6 @@
/*
* 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.
*/

View file

@ -11,4 +11,3 @@ export * from './severity_badge';
export * from './workflow_status_filter';
export * from './filter_for_value';
export * from './parse_alert';
export * from './alerts_status_filter';

View file

@ -10,7 +10,7 @@ import React from 'react';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { ObservabilityActions, ObservabilityActionsProps } from './observability_actions';
import { inventoryThresholdAlert } from '../../../rules/fixtures/example_alerts';
import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types';
import { RULE_DETAILS_PAGE_ID } from '../../rule_details/constants';
import { createObservabilityRuleTypeRegistryMock } from '../../../rules/observability_rule_type_registry_mock';
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import * as pluginContext from '../../../hooks/use_plugin_context';

View file

@ -26,7 +26,7 @@ import { parseAlert } from './parse_alert';
import { translations, paths } from '../../../config';
import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../containers/alerts_table/translations';
import { ObservabilityAppServices } from '../../../application/types';
import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types';
import { RULE_DETAILS_PAGE_ID } from '../../rule_details/constants';
import type { TopAlert } from '../containers/alerts_page/types';
import { ObservabilityRuleTypeRegistry } from '../../..';
import { ALERT_DETAILS_PAGE_ID } from '../../alert_details/types';

View file

@ -7,13 +7,13 @@
import { EuiFlexGroup, EuiFlexItem, EuiFlyoutSize } from '@elastic/eui';
import React, { useCallback, useEffect, useState } from 'react';
import { Query, BoolQuery } from '@kbn/es-query';
import React, { useEffect, useState } from 'react';
import { BoolQuery } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { AlertStatus } from '../../../../../common/typings';
import { ObservabilityAlertSearchbarWithUrlSync } from '../../../../components/shared/alert_search_bar';
import { observabilityAlertFeatureIds } from '../../../../config';
import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions';
import { observabilityFeatureId } from '../../../../../common';
@ -21,42 +21,23 @@ import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs';
import { useHasData } from '../../../../hooks/use_has_data';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { getNoDataConfig } from '../../../../utils/no_data_config';
import { buildEsQuery } from '../../../../utils/build_es_query';
import { LoadingObservability } from '../../../overview';
import {
Provider,
alertsPageStateContainer,
useAlertsPageStateContainer,
} from '../state_container';
import './styles.scss';
import { AlertsStatusFilter, ALERT_STATUS_QUERY } from '../../components';
import { renderRuleStats } from '../../components/rule_stats';
import { ObservabilityAppServices } from '../../../../application/types';
import { ALERTS_PER_PAGE, ALERTS_TABLE_ID } from './constants';
import { ALERTS_PER_PAGE, ALERTS_SEARCH_BAR_ID, ALERTS_TABLE_ID } from './constants';
import { RuleStatsState } from './types';
const getAlertStatusQuery = (status: string): Query[] => {
return status ? [{ query: ALERT_STATUS_QUERY[status], language: 'kuery' }] : [];
};
function AlertsPage() {
export function AlertsPage() {
const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext();
const {
cases,
docLinks,
http,
notifications: { toasts },
triggersActionsUi: {
alertsTableConfigurationRegistry,
getAlertsStateTable: AlertsStateTable,
getAlertsSearchBar: AlertsSearchBar,
},
data: {
query: {
timefilter: { timefilter: timeFilterService },
},
},
triggersActionsUi: { alertsTableConfigurationRegistry, getAlertsStateTable: AlertsStateTable },
} = useKibana<ObservabilityAppServices>().services;
const [ruleStatsLoading, setRuleStatsLoading] = useState<boolean>(false);
const [ruleStats, setRuleStats] = useState<RuleStatsState>({
total: 0,
@ -66,18 +47,7 @@ function AlertsPage() {
snoozed: 0,
});
const { hasAnyData, isAllRequestsComplete } = useHasData();
const { rangeFrom, setRangeFrom, rangeTo, setRangeTo, kuery, setKuery, status, setStatus } =
useAlertsPageStateContainer();
const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>(
buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
},
kuery,
getAlertStatusQuery(status)
)
);
const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>();
useBreadcrumbs([
{
@ -129,46 +99,6 @@ function AlertsPage() {
const manageRulesHref = http.basePath.prepend('/app/observability/alerts/rules');
const onStatusChange = useCallback(
(alertStatus: AlertStatus) => {
setEsQuery(
buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
},
kuery,
getAlertStatusQuery(alertStatus)
)
);
},
[kuery, rangeFrom, rangeTo]
);
useEffect(() => {
onStatusChange(status);
}, [onStatusChange, status]);
const onSearchBarParamsChange = useCallback(
({ dateRange, query }) => {
timeFilterService.setTime(dateRange);
setRangeFrom(dateRange.from);
setRangeTo(dateRange.to);
setKuery(query);
setEsQuery(
buildEsQuery(
{
to: rangeTo,
from: rangeFrom,
},
query,
getAlertStatusQuery(status)
)
);
},
[rangeFrom, setRangeFrom, rangeTo, status, setRangeTo, setKuery, timeFilterService]
);
// If there is any data, set hasData to true otherwise we need to wait till all the data is loaded before setting hasData to true or false; undefined indicates the data is still loading.
const hasData = hasAnyData === true || (isAllRequestsComplete === false ? undefined : false);
@ -199,56 +129,33 @@ function AlertsPage() {
>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<AlertsSearchBar
appName={'observability-alerts'}
featureIds={observabilityAlertFeatureIds}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
query={kuery}
onQueryChange={onSearchBarParamsChange}
<ObservabilityAlertSearchbarWithUrlSync
appName={ALERTS_SEARCH_BAR_ID}
setEsQuery={setEsQuery}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<AlertsStatusFilter
status={status}
onChange={(id) => {
setStatus(id as AlertStatus);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<CasesContext
owner={[observabilityFeatureId]}
permissions={userCasesPermissions}
features={{ alerts: { sync: false } }}
>
<AlertsStateTable
alertsTableConfigurationRegistry={alertsTableConfigurationRegistry}
configurationId={AlertConsumers.OBSERVABILITY}
id={ALERTS_TABLE_ID}
flyoutSize={'s' as EuiFlyoutSize}
featureIds={observabilityAlertFeatureIds}
query={esQuery}
showExpandToDetails={false}
pageSize={ALERTS_PER_PAGE}
/>
{esQuery && (
<AlertsStateTable
alertsTableConfigurationRegistry={alertsTableConfigurationRegistry}
configurationId={AlertConsumers.OBSERVABILITY}
id={ALERTS_TABLE_ID}
flyoutSize={'s' as EuiFlyoutSize}
featureIds={observabilityAlertFeatureIds}
query={esQuery}
showExpandToDetails={false}
pageSize={ALERTS_PER_PAGE}
/>
)}
</CasesContext>
</EuiFlexItem>
</EuiFlexGroup>
</ObservabilityPageTemplate>
);
}
export function WrappedAlertsPage() {
return (
<Provider value={alertsPageStateContainer}>
<AlertsPage />
</Provider>
);
}

View file

@ -6,5 +6,6 @@
*/
export const ALERTS_PAGE_ID = 'alerts-o11y';
export const ALERTS_SEARCH_BAR_ID = 'alerts-search-bar-o11y';
export const ALERTS_PER_PAGE = 50;
export const ALERTS_TABLE_ID = 'xpack.observability.alerts.alert.table';

View file

@ -5,5 +5,5 @@
* 2.0.
*/
export { WrappedAlertsPage as AlertsPage } from './alerts_page';
export { AlertsPage } from './alerts_page';
export type { TopAlert } from './types';

View file

@ -6,4 +6,3 @@
*/
export * from './alerts_page';
export * from './state_container';

View file

@ -1,52 +0,0 @@
/*
* 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 {
createStateContainer,
createStateContainerReactHelpers,
} from '@kbn/kibana-utils-plugin/public';
import { AlertStatus } from '../../../../../common/typings';
import { ALL_ALERTS } from '../..';
interface AlertsPageContainerState {
rangeFrom: string;
rangeTo: string;
kuery: string;
status: AlertStatus;
}
interface AlertsPageStateTransitions {
setRangeFrom: (
state: AlertsPageContainerState
) => (rangeFrom: string) => AlertsPageContainerState;
setRangeTo: (state: AlertsPageContainerState) => (rangeTo: string) => AlertsPageContainerState;
setKuery: (state: AlertsPageContainerState) => (kuery: string) => AlertsPageContainerState;
setStatus: (state: AlertsPageContainerState) => (status: AlertStatus) => AlertsPageContainerState;
}
const defaultState: AlertsPageContainerState = {
rangeFrom: 'now-15m',
rangeTo: 'now',
kuery: '',
status: ALL_ALERTS.status as AlertStatus,
};
const transitions: AlertsPageStateTransitions = {
setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }),
setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }),
setKuery: (state) => (kuery) => ({ ...state, kuery }),
setStatus: (state) => (status) => ({ ...state, status }),
};
const alertsPageStateContainer = createStateContainer(defaultState, transitions);
type AlertsPageStateContainer = typeof alertsPageStateContainer;
const { Provider, useContainer } = createStateContainerReactHelpers<AlertsPageStateContainer>();
export { Provider, alertsPageStateContainer, useContainer, defaultState };
export type { AlertsPageStateContainer, AlertsPageContainerState, AlertsPageStateTransitions };

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export const EXECUTION_TAB = 'execution';
export const ALERTS_TAB = 'alerts';
export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list';
export const RULE_DETAILS_PAGE_ID = 'rule-details-alerts-o11y';
export const RULE_DETAILS_ALERTS_SEARCH_BAR_ID = 'rule-details-alerts-search-bar-o11y';

View file

@ -6,7 +6,7 @@
*/
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useHistory, useParams, useLocation } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import {
EuiText,
@ -14,13 +14,14 @@ import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutSize,
EuiPanel,
EuiPopover,
EuiTabbedContent,
EuiEmptyPrompt,
EuiSuperSelectOption,
EuiButton,
EuiFlyoutSize,
EuiTabbedContentTab,
} from '@elastic/eui';
import {
@ -32,17 +33,21 @@ import {
} from '@kbn/triggers-actions-ui-plugin/public';
// TODO: use a Delete modal from triggersActionUI when it's sharable
import { ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons } from '@kbn/alerting-plugin/common';
import { Query, BoolQuery } from '@kbn/es-query';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { RuleDefinitionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { fromQuery, toQuery } from '../../utils/url';
import { ObservabilityAlertSearchbarWithUrlSync } from '../../components/shared/alert_search_bar';
import { DeleteModalConfirmation } from './components/delete_modal_confirmation';
import { CenterJustifiedSpinner } from './components/center_justified_spinner';
import {
RuleDetailsPathParams,
EVENT_LOG_LIST_TAB,
ALERT_LIST_TAB,
EXECUTION_TAB,
ALERTS_TAB,
RULE_DETAILS_PAGE_ID,
} from './types';
RULE_DETAILS_ALERTS_SEARCH_BAR_ID,
} from './constants';
import { RuleDetailsPathParams, TabId } from './types';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useFetchRule } from '../../hooks/use_fetch_rule';
@ -63,7 +68,7 @@ export function RuleDetailsPage() {
ruleTypeRegistry,
getEditAlertFlyout,
getRuleEventLogList,
getAlertsStateTable,
getAlertsStateTable: AlertsStateTable,
getRuleAlertsSummary,
getRuleStatusPanel,
getRuleDefinition,
@ -74,6 +79,8 @@ export function RuleDetailsPage() {
const { ruleId } = useParams<RuleDetailsPathParams>();
const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext();
const history = useHistory();
const location = useLocation();
const filteredRuleTypes = useMemo(
() => observabilityRuleTypeRegistry.list(),
@ -84,12 +91,34 @@ export function RuleDetailsPage() {
const { ruleTypes } = useLoadRuleTypes({
filteredRuleTypes,
});
const [tabId, setTabId] = useState<TabId>(
(toQuery(location.search)?.tabId as TabId) || EXECUTION_TAB
);
const [features, setFeatures] = useState<string>('');
const [ruleType, setRuleType] = useState<RuleType<string, string>>();
const [ruleToDelete, setRuleToDelete] = useState<string[]>([]);
const [isPageLoading, setIsPageLoading] = useState(false);
const [editFlyoutVisible, setEditFlyoutVisible] = useState<boolean>(false);
const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false);
const [esQuery, setEsQuery] = useState<{ bool: BoolQuery }>();
const ruleQuery = useRef([
{ query: `kibana.alert.rule.uuid: ${ruleId}`, language: 'kuery' },
] as Query[]);
const updateUrl = (nextQuery: { tabId: TabId }) => {
history.push({
...location,
search: fromQuery({
...toQuery(location.search),
...nextQuery,
}),
});
};
const onTabIdChange = (newTabId: TabId) => {
setTabId(newTabId);
updateUrl({ tabId: newTabId });
};
const NOTIFY_WHEN_OPTIONS = useRef<Array<EuiSuperSelectOption<unknown>>>([]);
useEffect(() => {
@ -157,40 +186,26 @@ export function RuleDetailsPage() {
? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext
: false);
const alertStateProps = {
alertsTableConfigurationRegistry,
configurationId: observabilityFeatureId,
id: RULE_DETAILS_PAGE_ID,
flyoutSize: 's' as EuiFlyoutSize,
featureIds: [features] as AlertConsumers[],
query: {
bool: {
filter: [
{
term: {
'kibana.alert.rule.uuid': ruleId,
},
},
],
},
},
showExpandToDetails: false,
};
const tabs = [
const tabs: EuiTabbedContentTab[] = [
{
id: EVENT_LOG_LIST_TAB,
id: EXECUTION_TAB,
name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', {
defaultMessage: 'Execution history',
}),
'data-test-subj': 'eventLogListTab',
content: getRuleEventLogList<'default'>({
ruleId: rule?.id,
ruleType,
} as RuleEventLogListProps),
content: (
<EuiFlexGroup style={{ minHeight: 600 }} direction={'column'}>
<EuiFlexItem>
{getRuleEventLogList<'default'>({
ruleId: rule?.id,
ruleType,
} as RuleEventLogListProps)}
</EuiFlexItem>
</EuiFlexGroup>
),
},
{
id: ALERT_LIST_TAB,
id: ALERTS_TAB,
name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', {
defaultMessage: 'Alerts',
}),
@ -198,7 +213,27 @@ export function RuleDetailsPage() {
content: (
<>
<EuiSpacer size="m" />
{getAlertsStateTable(alertStateProps)}
<ObservabilityAlertSearchbarWithUrlSync
appName={RULE_DETAILS_ALERTS_SEARCH_BAR_ID}
setEsQuery={setEsQuery}
queries={ruleQuery.current}
/>
<EuiSpacer size="s" />
<EuiFlexGroup style={{ minHeight: 450 }} direction={'column'}>
<EuiFlexItem>
{esQuery && (
<AlertsStateTable
alertsTableConfigurationRegistry={alertsTableConfigurationRegistry}
configurationId={observabilityFeatureId}
id={RULE_DETAILS_PAGE_ID}
flyoutSize={'s' as EuiFlyoutSize}
featureIds={[features] as AlertConsumers[]}
query={esQuery}
showExpandToDetails={false}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
),
},
@ -324,7 +359,14 @@ export function RuleDetailsPage() {
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiTabbedContent data-test-subj="ruleDetailsTabbedContent" tabs={tabs} />
<EuiTabbedContent
data-test-subj="ruleDetailsTabbedContent"
tabs={tabs}
selectedTab={tabs.find((tab) => tab.id === tabId)}
onTabClick={(tab) => {
onTabIdChange(tab.id as TabId);
}}
/>
{editFlyoutVisible &&
getEditAlertFlyout({
initialRule: rule,

View file

@ -6,12 +6,10 @@
*/
import { HttpSetup } from '@kbn/core/public';
import {
Rule,
RuleSummary,
RuleType,
ActionTypeRegistryContract,
} from '@kbn/triggers-actions-ui-plugin/public';
import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public';
import { ALERTS_TAB, EXECUTION_TAB } from './constants';
export type TabId = typeof ALERTS_TAB | typeof EXECUTION_TAB;
export interface RuleDetailsPathParams {
ruleId: string;
@ -36,15 +34,6 @@ export interface FetchRuleSummaryProps {
ruleId: string;
http: HttpSetup;
}
export interface FetchRuleActionConnectorsProps {
http: HttpSetup;
ruleActions: any[];
}
export interface FetchRuleExecutionLogProps {
http: HttpSetup;
ruleId: string;
}
export interface FetchRuleSummary {
isLoadingRuleSummary: boolean;
@ -65,19 +54,3 @@ export interface AlertListItem {
isMuted: boolean;
sortPriority: number;
}
export interface ItemTitleRuleSummaryProps {
children: string;
}
export interface ItemValueRuleSummaryProps {
itemValue: string;
extraSpace?: boolean;
}
export interface ActionsProps {
ruleActions: any[];
actionTypeRegistry: ActionTypeRegistryContract;
}
export const EVENT_LOG_LIST_TAB = 'rule_event_log_list';
export const ALERT_LIST_TAB = 'rule_alert_list';
export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list';
export const RULE_DETAILS_PAGE_ID = 'rule-details-alerts-o11y';

View file

@ -0,0 +1,93 @@
/*
* 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 { toQuery, fromQuery } from './url';
describe('toQuery', () => {
it('should parse string to object', () => {
expect(toQuery('?foo=bar&name=john%20doe')).toEqual({
foo: 'bar',
name: 'john doe',
});
});
});
describe('fromQuery', () => {
it('should not encode the following characters', () => {
expect(
fromQuery({
a: true,
b: 5000,
c: ':',
})
).toEqual('a=true&b=5000&c=:');
});
it('should encode the following characters', () => {
expect(
fromQuery({
a: '@',
b: '.',
c: ';',
d: ' ',
})
).toEqual('a=%40&b=.&c=%3B&d=%20');
});
it('should handle null and undefined', () => {
expect(
fromQuery({
a: undefined,
b: null,
})
).toEqual('a=&b=');
});
it('should handle arrays', () => {
expect(
fromQuery({
arr: ['a', 'b'],
})
).toEqual('arr=a%2Cb');
});
it('should parse object to string', () => {
expect(
fromQuery({
traceId: 'bar',
transactionId: 'john doe',
})
).toEqual('traceId=bar&transactionId=john%20doe');
});
it('should not encode range params', () => {
expect(
fromQuery({
rangeFrom: '2019-03-03T12:00:00.000Z',
rangeTo: '2019-03-05T12:00:00.000Z',
})
).toEqual('rangeFrom=2019-03-03T12:00:00.000Z&rangeTo=2019-03-05T12:00:00.000Z');
});
it('should handle undefined, boolean, and number values without throwing errors', () => {
expect(
fromQuery({
flyoutDetailTab: undefined,
refreshPaused: true,
refreshInterval: 5000,
})
).toEqual('flyoutDetailTab=&refreshPaused=true&refreshInterval=5000');
});
});
describe('fromQuery and toQuery', () => {
it('should encode and decode correctly', () => {
expect(
fromQuery(toQuery('?name=john%20doe&path=a%2Fb&rangeFrom=2019-03-03T12:00:00.000Z'))
).toEqual('name=john%20doe&path=a%2Fb&rangeFrom=2019-03-03T12:00:00.000Z');
});
});