From e61c6660f7de8251323cf43b7b0df9e699a5d11b Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 25 May 2021 12:40:14 -0500 Subject: [PATCH] Open/Closed filter for observability alerts page (#99217) --- .../resources/base/bin/kibana-docker | 2 +- x-pack/plugins/observability/README.md | 40 ++++++++++++- .../plugins/observability/common/typings.ts | 4 ++ .../public/pages/alerts/alerts_table.tsx | 8 +-- .../public/pages/alerts/index.tsx | 47 ++++++++++++---- .../pages/alerts/status_filter.stories.tsx | 34 +++++++++++ .../pages/alerts/status_filter.test.tsx | 39 +++++++++++++ .../public/pages/alerts/status_filter.tsx | 56 +++++++++++++++++++ .../observability/public/routes/index.tsx | 2 + .../server/lib/rules/get_top_alerts.ts | 7 ++- .../observability/server/routes/rules.ts | 9 ++- .../server/utils/queries.test.ts | 39 +++++++++++++ .../observability/server/utils/queries.ts | 10 ++++ .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../tests/alerts/rule_registry.ts | 2 + 16 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/alerts/status_filter.stories.tsx create mode 100644 x-pack/plugins/observability/public/pages/alerts/status_filter.test.tsx create mode 100644 x-pack/plugins/observability/public/pages/alerts/status_filter.tsx create mode 100644 x-pack/plugins/observability/server/utils/queries.test.ts diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 3b2feeecabb7..2f54bd1d818b 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -276,7 +276,7 @@ kibana_vars=( xpack.reporting.roles.allow xpack.reporting.roles.enabled xpack.rollup.enabled - xpack.ruleRegistry.unsafe.write.enabled + xpack.ruleRegistry.write.enabled xpack.searchprofiler.enabled xpack.security.audit.enabled xpack.security.audit.appender.type diff --git a/x-pack/plugins/observability/README.md b/x-pack/plugins/observability/README.md index b882891921cd..943a7482a25e 100644 --- a/x-pack/plugins/observability/README.md +++ b/x-pack/plugins/observability/README.md @@ -19,7 +19,7 @@ This will only enable the UI for these pages. In order to have alert data indexe you'll need to enable writing in the [Rule Registry plugin](../rule_registry/README.md): ```yaml -xpack.ruleRegistry.unsafe.write.enabled: true +xpack.ruleRegistry.write.enabled: true ``` When both of the these are set to `true`, your alerts should show on the alerts page. @@ -47,3 +47,41 @@ HTML coverage report can be found in target/coverage/jest after tests have run. ```bash open target/coverage/jest/index.html ``` + +## API integration testing + +API tests are separated in two suites: + +- a basic license test suite +- a trial license test suite (the equivalent of gold+) + +This requires separate test servers and test runners. + +### Basic + +``` +# Start server +node scripts/functional_tests_server --config x-pack/test/observability_api_integration/basic/config.ts + +# Run tests +node scripts/functional_test_runner --config x-pack/test/observability_api_integration/basic/config.ts +``` + +The API tests for "basic" are located in `x-pack/test/observability_api_integration/basic/tests`. + +### Trial + +``` +# Start server +node scripts/functional_tests_server --config x-pack/test/observability_api_integration/trial/config.ts + +# Run tests +node scripts/functional_test_runner --config x-pack/test/observability_api_integration/trial/config.ts +``` + +The API tests for "trial" are located in `x-pack/test/observability_api_integration/trial/tests`. + +### API test tips + +- For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) +- To update snapshots append `--updateSnapshots` to the functional_test_runner command diff --git a/x-pack/plugins/observability/common/typings.ts b/x-pack/plugins/observability/common/typings.ts index 2a7f9edffc4a..bd10543ef389 100644 --- a/x-pack/plugins/observability/common/typings.ts +++ b/x-pack/plugins/observability/common/typings.ts @@ -4,5 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import * as t from 'io-ts'; export type Maybe = T | null | undefined; + +export const alertStatusRt = t.union([t.literal('all'), t.literal('open'), t.literal('closed')]); +export type AlertStatus = t.TypeOf; diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx index f377186623a0..31e59679854b 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table.tsx @@ -66,16 +66,16 @@ export function AlertsTable(props: AlertsTableProps) { return active ? ( ) : ( diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index 1f468a70d097..7b8055c82f07 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -12,21 +12,23 @@ import { EuiFlexItem, EuiLink, EuiPageTemplate, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { + ALERT_START, + ALERT_STATUS, + RULE_ID, + RULE_NAME, +} from '@kbn/rule-data-utils/target/technical_field_names'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { format, parse } from 'url'; -import { - ALERT_START, - EVENT_ACTION, - RULE_ID, - RULE_NAME, -} from '@kbn/rule-data-utils/target/technical_field_names'; import { ParsedTechnicalFields, parseTechnicalFields, } from '../../../../rule_registry/common/parse_technical_fields'; +import type { AlertStatus } from '../../../common/typings'; import { asDuration, asPercent } from '../../../common/utils/formatters'; import { ExperimentalBadge } from '../../components/shared/experimental_badge'; import { useFetcher } from '../../hooks/use_fetcher'; @@ -37,6 +39,7 @@ import type { ObservabilityAPIReturnType } from '../../services/call_observabili import { getAbsoluteDateRange } from '../../utils/date'; import { AlertsSearchBar } from './alerts_search_bar'; import { AlertsTable } from './alerts_table'; +import { StatusFilter } from './status_filter'; export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number]; @@ -57,7 +60,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { const { prepend } = core.http.basePath; const history = useHistory(); const { - query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '' }, + query: { rangeFrom = 'now-15m', rangeTo = 'now', kuery = '', status = 'open' }, } = routeParams; // In a future milestone we'll have a page dedicated to rule management in @@ -81,6 +84,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { start, end, kuery, + status, }, }, }).then((alerts) => { @@ -108,15 +112,24 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { }, }) : undefined, - active: parsedFields[EVENT_ACTION] !== 'close', + active: parsedFields[ALERT_STATUS] !== 'closed', start: new Date(parsedFields[ALERT_START]!).getTime(), }; }); }); }, - [kuery, observabilityRuleTypeRegistry, rangeFrom, rangeTo] + [kuery, observabilityRuleTypeRegistry, rangeFrom, rangeTo, status] ); + function setStatusFilter(value: AlertStatus) { + const nextSearchParams = new URLSearchParams(history.location.search); + nextSearchParams.set('status', value); + history.push({ + ...history.location, + search: nextSearchParams.toString(), + }); + } + return ( - - - + + + + + + + + + + + + + ); diff --git a/x-pack/plugins/observability/public/pages/alerts/status_filter.stories.tsx b/x-pack/plugins/observability/public/pages/alerts/status_filter.stories.tsx new file mode 100644 index 000000000000..851e0cb6c3dd --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/status_filter.stories.tsx @@ -0,0 +1,34 @@ +/* + * 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, { ComponentProps, useState } from 'react'; +import type { AlertStatus } from '../../../common/typings'; +import { StatusFilter } from './status_filter'; + +type Args = ComponentProps; + +export default { + title: 'app/Alerts/StatusFilter', + component: StatusFilter, + argTypes: { + onChange: { action: 'change' }, + }, +}; + +export function Example({ onChange }: Args) { + const [status, setStatus] = useState('open'); + + return ( + { + setStatus(value); + onChange(value); + }} + /> + ); +} diff --git a/x-pack/plugins/observability/public/pages/alerts/status_filter.test.tsx b/x-pack/plugins/observability/public/pages/alerts/status_filter.test.tsx new file mode 100644 index 000000000000..72e07ebb8cad --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/status_filter.test.tsx @@ -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 { render } from '@testing-library/react'; +import React from 'react'; +import type { AlertStatus } from '../../../common/typings'; +import { StatusFilter } from './status_filter'; + +describe('StatusFilter', () => { + describe('render', () => { + it('renders', () => { + const onChange = jest.fn(); + const status: AlertStatus = 'all'; + const props = { onChange, status }; + + expect(() => render()).not.toThrowError(); + }); + + (['all', 'open', 'closed'] as AlertStatus[]).map((status) => { + describe(`when clicking the ${status} button`, () => { + it('calls the onChange callback with "${status}"', () => { + const onChange = jest.fn(); + const props = { onChange, status }; + + const { getByTestId } = render(); + const button = getByTestId(`StatusFilter ${status} button`); + + button.click(); + + expect(onChange).toHaveBeenCalledWith(status); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/alerts/status_filter.tsx b/x-pack/plugins/observability/public/pages/alerts/status_filter.tsx new file mode 100644 index 000000000000..26169717d296 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/status_filter.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EuiFilterButton, EuiFilterGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import type { AlertStatus } from '../../../common/typings'; + +export interface StatusFilterProps { + status: AlertStatus; + onChange: (value: AlertStatus) => void; +} + +export function StatusFilter({ status = 'open', onChange }: StatusFilterProps) { + return ( + + onChange('open')} + withNext={true} + > + {i18n.translate('xpack.observability.alerts.statusFilter.openButtonLabel', { + defaultMessage: 'Open', + })} + + onChange('closed')} + withNext={true} + > + {i18n.translate('xpack.observability.alerts.statusFilter.closedButtonLabel', { + defaultMessage: 'Closed', + })} + + onChange('all')} + > + {i18n.translate('xpack.observability.alerts.statusFilter.allButtonLabel', { + defaultMessage: 'All', + })} + + + ); +} diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 0bdb03995ad4..6e180347106d 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -15,6 +15,7 @@ import { jsonRt } from './json_rt'; import { AlertsPage } from '../pages/alerts'; import { CasesPage } from '../pages/cases'; import { ExploratoryViewPage } from '../components/shared/exploratory_view'; +import { alertStatusRt } from '../../common/typings'; export type RouteParams = DecodeParams; @@ -105,6 +106,7 @@ export const routes = { rangeFrom: t.string, rangeTo: t.string, kuery: t.string, + status: alertStatusRt, refreshPaused: jsonRt.pipe(t.boolean), refreshInterval: jsonRt.pipe(t.number), }), diff --git a/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts b/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts index ddfc112ab145..9560de6ec00f 100644 --- a/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts +++ b/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts @@ -6,7 +6,8 @@ */ import { ALERT_UUID, TIMESTAMP } from '@kbn/rule-data-utils/target/technical_field_names'; import { RuleDataClient } from '../../../../rule_registry/server'; -import { kqlQuery, rangeQuery } from '../../utils/queries'; +import type { AlertStatus } from '../../../common/typings'; +import { kqlQuery, rangeQuery, alertStatusQuery } from '../../utils/queries'; export async function getTopAlerts({ ruleDataClient, @@ -14,18 +15,20 @@ export async function getTopAlerts({ end, kuery, size, + status, }: { ruleDataClient: RuleDataClient; start: number; end: number; kuery?: string; size: number; + status: AlertStatus; }) { const response = await ruleDataClient.getReader().search({ body: { query: { bool: { - filter: [...rangeQuery(start, end), ...kqlQuery(kuery)], + filter: [...rangeQuery(start, end), ...kqlQuery(kuery), ...alertStatusQuery(status)], }, }, fields: ['*'], diff --git a/x-pack/plugins/observability/server/routes/rules.ts b/x-pack/plugins/observability/server/routes/rules.ts index 1f500adff5dc..8b33f5ff8d86 100644 --- a/x-pack/plugins/observability/server/routes/rules.ts +++ b/x-pack/plugins/observability/server/routes/rules.ts @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import * as t from 'io-ts'; import { isoToEpochRt, toNumberRt } from '@kbn/io-ts-utils'; +import * as t from 'io-ts'; +import { alertStatusRt } from '../../common/typings'; +import { getTopAlerts } from '../lib/rules/get_top_alerts'; import { createObservabilityServerRoute } from './create_observability_server_route'; import { createObservabilityServerRouteRepository } from './create_observability_server_route_repository'; -import { getTopAlerts } from '../lib/rules/get_top_alerts'; const alertsListRoute = createObservabilityServerRoute({ endpoint: 'GET /api/observability/rules/alerts/top', @@ -20,6 +21,7 @@ const alertsListRoute = createObservabilityServerRoute({ t.type({ start: isoToEpochRt, end: isoToEpochRt, + status: alertStatusRt, }), t.partial({ kuery: t.string, @@ -29,7 +31,7 @@ const alertsListRoute = createObservabilityServerRoute({ }), handler: async ({ ruleDataClient, context, params }) => { const { - query: { start, end, kuery, size = 100 }, + query: { start, end, kuery, size = 100, status }, } = params; return getTopAlerts({ @@ -38,6 +40,7 @@ const alertsListRoute = createObservabilityServerRoute({ end, kuery, size, + status, }); }, }); diff --git a/x-pack/plugins/observability/server/utils/queries.test.ts b/x-pack/plugins/observability/server/utils/queries.test.ts new file mode 100644 index 000000000000..a0a63b73d717 --- /dev/null +++ b/x-pack/plugins/observability/server/utils/queries.test.ts @@ -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 { ALERT_STATUS } from '@kbn/rule-data-utils/target/technical_field_names'; +import * as queries from './queries'; + +describe('queries', () => { + describe('alertStatusQuery', () => { + describe('given "all"', () => { + it('returns an empty array', () => { + expect(queries.alertStatusQuery('all')).toEqual([]); + }); + }); + + describe('given "open"', () => { + it('returns a query for open', () => { + expect(queries.alertStatusQuery('open')).toEqual([ + { + term: { [ALERT_STATUS]: 'open' }, + }, + ]); + }); + }); + + describe('given "closed"', () => { + it('returns a query for closed', () => { + expect(queries.alertStatusQuery('closed')).toEqual([ + { + term: { [ALERT_STATUS]: 'closed' }, + }, + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/server/utils/queries.ts b/x-pack/plugins/observability/server/utils/queries.ts index 9e1c110e7758..b7412120365c 100644 --- a/x-pack/plugins/observability/server/utils/queries.ts +++ b/x-pack/plugins/observability/server/utils/queries.ts @@ -6,7 +6,17 @@ */ import { QueryContainer } from '@elastic/elasticsearch/api/types'; +import { ALERT_STATUS } from '@kbn/rule-data-utils/target/technical_field_names'; import { esKuery } from '../../../../../src/plugins/data/server'; +import { AlertStatus } from '../../common/typings'; + +export function alertStatusQuery(status: AlertStatus) { + if (status === 'all') { + return []; + } + + return [{ term: { [ALERT_STATUS]: status } }]; +} export function rangeQuery(start?: number, end?: number, field = '@timestamp'): QueryContainer[] { return [ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index af6cdd1d672a..4353099af06f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17748,9 +17748,7 @@ "xpack.observability.alertsTable.durationColumnDescription": "期間", "xpack.observability.alertsTable.reasonColumnDescription": "理由", "xpack.observability.alertsTable.severityColumnDescription": "深刻度", - "xpack.observability.alertsTable.statusActiveDescription": "アクティブ", "xpack.observability.alertsTable.statusColumnDescription": "ステータス", - "xpack.observability.alertsTable.statusRecoveredDescription": "回復済み", "xpack.observability.alertsTable.triggeredColumnDescription": "実行済み", "xpack.observability.alertsTable.viewInAppButtonLabel": "アプリで表示", "xpack.observability.alertsTitle": "アラート", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c8376b72daef..f4de4a8faad5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17989,9 +17989,7 @@ "xpack.observability.alertsTable.durationColumnDescription": "持续时间", "xpack.observability.alertsTable.reasonColumnDescription": "原因", "xpack.observability.alertsTable.severityColumnDescription": "严重性", - "xpack.observability.alertsTable.statusActiveDescription": "活动", "xpack.observability.alertsTable.statusColumnDescription": "状态", - "xpack.observability.alertsTable.statusRecoveredDescription": "已恢复", "xpack.observability.alertsTable.triggeredColumnDescription": "已触发", "xpack.observability.alertsTable.viewInAppButtonLabel": "在应用中查看", "xpack.observability.alertsTitle": "告警", diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts index e0a3e4d3a3f8..e9392a611b5b 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts @@ -400,6 +400,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { query: { start: new Date(now - 30 * 60 * 1000).toISOString(), end: new Date(now).toISOString(), + status: 'all', }, }) ) @@ -572,6 +573,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { query: { start: new Date(now - 30 * 60 * 1000).toISOString(), end: new Date().toISOString(), + status: 'all', }, }) )