Open/Closed filter for observability alerts page (#99217)
This commit is contained in:
parent
5dc85c69b7
commit
e61c6660f7
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
@ -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: ['*'],
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
39
x-pack/plugins/observability/server/utils/queries.test.ts
Normal file
39
x-pack/plugins/observability/server/utils/queries.test.ts
Normal 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' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 [
|
||||
|
|
|
@ -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": "アラート",
|
||||
|
|
|
@ -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": "告警",
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue