Open/Closed filter for observability alerts page (#99217)

This commit is contained in:
Nathan L Smith 2021-05-25 12:40:14 -05:00 committed by GitHub
parent 5dc85c69b7
commit e61c6660f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 276 additions and 27 deletions

View file

@ -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

View file

@ -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

View file

@ -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> = T | null | undefined;
export const alertStatusRt = t.union([t.literal('all'), t.literal('open'), t.literal('closed')]);
export type AlertStatus = t.TypeOf<typeof alertStatusRt>;

View file

@ -66,16 +66,16 @@ export function AlertsTable(props: AlertsTableProps) {
return active ? (
<EuiIconTip
content={i18n.translate('xpack.observability.alertsTable.statusActiveDescription', {
defaultMessage: 'Active',
content={i18n.translate('xpack.observability.alertsTable.statusOpenDescription', {
defaultMessage: 'Open',
})}
color="danger"
type="alert"
/>
) : (
<EuiIconTip
content={i18n.translate('xpack.observability.alertsTable.statusRecoveredDescription', {
defaultMessage: 'Recovered',
content={i18n.translate('xpack.observability.alertsTable.statusClosedDescription', {
defaultMessage: 'Closed',
})}
type="check"
/>

View file

@ -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 (
<EuiPageTemplate
pageHeader={{
@ -179,9 +192,19 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<AlertsTable items={topAlerts ?? []} />
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<StatusFilter status={status} onChange={setStatusFilter} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<AlertsTable items={topAlerts ?? []} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiPageTemplate>
);

View file

@ -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<typeof StatusFilter>;
export default {
title: 'app/Alerts/StatusFilter',
component: StatusFilter,
argTypes: {
onChange: { action: 'change' },
},
};
export function Example({ onChange }: Args) {
const [status, setStatus] = useState<AlertStatus>('open');
return (
<StatusFilter
status={status}
onChange={(value) => {
setStatus(value);
onChange(value);
}}
/>
);
}

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 { 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(<StatusFilter {...props} />)).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(<StatusFilter {...props} />);
const button = getByTestId(`StatusFilter ${status} button`);
button.click();
expect(onChange).toHaveBeenCalledWith(status);
});
});
});
});
});

View file

@ -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 (
<EuiFilterGroup
aria-label={i18n.translate('xpack.observability.alerts.statusFilterAriaLabel', {
defaultMessage: 'Filter alerts by open and closed status',
})}
>
<EuiFilterButton
data-test-subj="StatusFilter open button"
hasActiveFilters={status === 'open'}
onClick={() => onChange('open')}
withNext={true}
>
{i18n.translate('xpack.observability.alerts.statusFilter.openButtonLabel', {
defaultMessage: 'Open',
})}
</EuiFilterButton>
<EuiFilterButton
data-test-subj="StatusFilter closed button"
hasActiveFilters={status === 'closed'}
onClick={() => onChange('closed')}
withNext={true}
>
{i18n.translate('xpack.observability.alerts.statusFilter.closedButtonLabel', {
defaultMessage: 'Closed',
})}
</EuiFilterButton>
<EuiFilterButton
data-test-subj="StatusFilter all button"
hasActiveFilters={status === 'all'}
onClick={() => onChange('all')}
>
{i18n.translate('xpack.observability.alerts.statusFilter.allButtonLabel', {
defaultMessage: 'All',
})}
</EuiFilterButton>
</EuiFilterGroup>
);
}

View file

@ -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<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
@ -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),
}),

View file

@ -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: ['*'],

View file

@ -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,
});
},
});

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 { 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' },
},
]);
});
});
});
});

View file

@ -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 [

View file

@ -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": "アラート",

View file

@ -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": "告警",

View file

@ -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',
},
})
)