Flyout and alerts table improvements (#96976)

* Extend the TopAlert interface to include all the properties of the alert as well as the computed properties we add. Use these in the table and flyout.
* Create a severity badge and use it in the table and flyout

![image](https://user-images.githubusercontent.com/9912/114796499-bc7d2b00-9d56-11eb-89fa-17c0240819ee.png)

* Fix the query language toggle in the search bar

![image](https://user-images.githubusercontent.com/9912/114796507-c69f2980-9d56-11eb-8cfc-8432e4a71e77.png)

# Table

* Update status badges to match design

![image](https://user-images.githubusercontent.com/9912/114796518-ce5ece00-9d56-11eb-80a7-b8c8aa63d6b5.png)

* Remove checkbox column
* Make flyout open when clicking the reason
* Change alert details link to a view in app link

![image](https://user-images.githubusercontent.com/9912/114796530-d74f9f80-9d56-11eb-951c-91e544c6e1d5.png)

# Flyout

* Add action button to flyout
* Replace table on flyout with description list
* Remove unused tabs on flyout
* Add rule type to flyout heading
* Add expected and actual value to the flyout

![image](https://user-images.githubusercontent.com/9912/114796550-e59dbb80-9d56-11eb-9825-25da979c3b22.png)

Fixes #96907.
This commit is contained in:
Nathan L Smith 2021-04-19 15:12:05 -05:00 committed by GitHub
parent a90afbf1ec
commit c67cda194f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 339 additions and 191 deletions

View file

@ -16,18 +16,12 @@ import { PluginContext, PluginContextValue } from '../../context/plugin_context'
import { createObservabilityRuleRegistryMock } from '../../rules/observability_rule_registry_mock';
import { createCallObservabilityApi } from '../../services/call_observability_api';
import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types';
import { AlertsFlyout } from './alerts_flyout';
import { TopAlert } from './alerts_table';
import { apmAlertResponseExample, dynamicIndexPattern, flyoutItemExample } from './example_data';
import { apmAlertResponseExample, dynamicIndexPattern } from './example_data';
interface PageArgs {
items: ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>;
}
interface FlyoutArgs {
alert: TopAlert;
}
export default {
title: 'app/Alerts',
component: AlertsPage,
@ -95,8 +89,3 @@ export function EmptyState(_args: PageArgs) {
return <AlertsPage routeParams={{ query: {} }} />;
}
EmptyState.args = { items: [] } as PageArgs;
export function Flyout({ alert }: FlyoutArgs) {
return <AlertsFlyout alert={alert} onClose={() => {}} />;
}
Flyout.args = { alert: flyoutItemExample } as FlyoutArgs;

View file

@ -1,120 +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 {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutProps,
EuiInMemoryTable,
EuiSpacer,
EuiTabbedContent,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { asDuration } from '../../../common/utils/formatters';
import { TopAlert } from './alerts_table';
type AlertsFlyoutProps = { alert: TopAlert } & EuiFlyoutProps;
export function AlertsFlyout(props: AlertsFlyoutProps) {
const { onClose, alert } = props;
const overviewListItems = [
{
title: 'Status',
description: alert.active ? 'Active' : 'Recovered',
},
{
title: 'Severity',
description: alert.severityLevel || '-', // TODO: badge and "(changed 2 min ago)"
},
// {
// title: 'Affected entity',
// description: affectedEntity || '-', // TODO: link to entity
// },
{
title: 'Triggered',
description: alert.start, // TODO: format date
},
{
title: 'Duration',
description: asDuration(alert.duration, { extended: true }) || '-', // TODO: format duration
},
// {
// title: 'Expected value',
// description: expectedValue || '-',
// },
// {
// title: 'Actual value',
// description: actualValue || '-',
// },
{
title: 'Rule type',
description: alert.ruleCategory || '-',
},
];
const tabs = [
{
id: 'overview',
name: i18n.translate('xpack.observability.alerts.flyoutOverviewTabTitle', {
defaultMessage: 'Overview',
}),
content: (
<>
<EuiSpacer />
<EuiInMemoryTable
columns={[
{ field: 'title', name: '' },
{ field: 'description', name: '' },
]}
items={overviewListItems}
/>
{/* <EuiSpacer />
<EuiTitle size="xs">
<h4>Severity log</h4>
</EuiTitle>
<EuiInMemoryTable
columns={[
{ field: '@timestamp', name: 'Timestamp', dataType: 'date' },
{
field: 'severity',
name: 'Severity',
render: (_, item) => (
<>
<EuiBadge>{item.severity}</EuiBadge> {item.message}
</>
),
},
]}
items={severityLog ?? []}
/> */}
</>
),
},
{
id: 'metadata',
name: i18n.translate('xpack.observability.alerts.flyoutMetadataTabTitle', {
defaultMessage: 'Metadata',
}),
disabled: true,
content: <></>,
},
];
return (
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle size="xs">
<h2>{alert.ruleName}</h2>
</EuiTitle>
<EuiTabbedContent size="s" tabs={tabs} />
</EuiFlyoutHeader>
</EuiFlyout>
);
}

View file

@ -0,0 +1,86 @@
/*
* 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, { ComponentType } from 'react';
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
import { PluginContext, PluginContextValue } from '../../../context/plugin_context';
import { TopAlert } from '../';
import { AlertsFlyout } from './';
interface Args {
alert: TopAlert;
}
export default {
title: 'app/Alerts/AlertsFlyout',
component: AlertsFlyout,
decorators: [
(Story: ComponentType) => {
return (
<KibanaContextProvider
services={{
docLinks: { links: { query: {} } },
storage: { get: () => {} },
uiSettings: {
get: (setting: string) => {
if (setting === 'dateFormat') {
return 'MMM D, YYYY @ HH:mm:ss.SSS';
}
},
},
}}
>
{' '}
<PluginContext.Provider
value={
({
core: {
http: { basePath: { prepend: (_: string) => '' } },
},
} as unknown) as PluginContextValue
}
>
<Story />
</PluginContext.Provider>
</KibanaContextProvider>
//
);
},
],
};
export function Example({ alert }: Args) {
return <AlertsFlyout alert={alert} onClose={() => {}} />;
}
Example.args = {
alert: {
link: '/app/apm/services/opbeans-java?rangeFrom=now-15m&rangeTo=now',
reason: 'Error count for opbeans-java was above the threshold',
active: true,
start: 1618235449493,
'rule.id': 'apm.error_rate',
'service.environment': 'production',
'service.name': 'opbeans-java',
'rule.name': 'Error count threshold | opbeans-java (smith test)',
'kibana.rac.alert.duration.us': 61787000,
'kibana.observability.evaluation.threshold': 0,
'kibana.rac.alert.status': 'open',
tags: ['apm', 'service.name:opbeans-java'],
'kibana.rac.alert.uuid': 'c50fbc70-0d77-462d-ac0a-f2bd0b8512e4',
'rule.uuid': '474920d0-93e9-11eb-ac86-0b455460de81',
'event.action': 'active',
'@timestamp': '2021-04-14T21:43:42.966Z',
'kibana.rac.alert.id': 'apm.error_rate_opbeans-java_production',
'processor.event': 'error',
'kibana.rac.alert.start': '2021-04-14T21:42:41.179Z',
'kibana.rac.producer': 'apm',
'event.kind': 'state',
'rule.category': 'Error count threshold',
'kibana.observability.evaluation.value': 1,
},
} as Args;

View file

@ -0,0 +1,126 @@
/*
* 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 {
EuiButton,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFlyoutProps,
EuiSpacer,
EuiTabbedContent,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import React from 'react';
import { useUiSetting } from '../../../../../../../src/plugins/kibana_react/public';
import { asDuration } from '../../../../common/utils/formatters';
import { usePluginContext } from '../../../hooks/use_plugin_context';
import { TopAlert } from '../';
import { SeverityBadge } from '../severity_badge';
type AlertsFlyoutProps = { alert: TopAlert } & EuiFlyoutProps;
export function AlertsFlyout({ onClose, alert }: AlertsFlyoutProps) {
const dateFormat = useUiSetting<string>('dateFormat');
const { core } = usePluginContext();
const { prepend } = core.http.basePath;
const overviewListItems = [
{
title: i18n.translate('xpack.observability.alertsFlyout.statusLabel', {
defaultMessage: 'Status',
}),
description: alert.active ? 'Active' : 'Recovered',
},
{
title: i18n.translate('xpack.observability.alertsFlyout.severityLabel', {
defaultMessage: 'Severity',
}),
description: <SeverityBadge severityLevel={alert['kibana.rac.alert.severity.level']} />,
},
{
title: i18n.translate('xpack.observability.alertsFlyout.triggeredLabel', {
defaultMessage: 'Triggered',
}),
description: (
<span title={alert.start.toString()}>{moment(alert.start).format(dateFormat)}</span>
),
},
{
title: i18n.translate('xpack.observability.alertsFlyout.durationLabel', {
defaultMessage: 'Duration',
}),
description: asDuration(alert['kibana.rac.alert.duration.us'], { extended: true }),
},
{
title: i18n.translate('xpack.observability.alertsFlyout.expectedValueLabel', {
defaultMessage: 'Expected value',
}),
description: alert['kibana.observability.evaluation.threshold'] ?? '-',
},
{
title: i18n.translate('xpack.observability.alertsFlyout.actualValueLabel', {
defaultMessage: 'Actual value',
}),
description: alert['kibana.observability.evaluation.value'] ?? '-',
},
{
title: i18n.translate('xpack.observability.alertsFlyout.ruleTypeLabel', {
defaultMessage: 'Rule type',
}),
description: alert['rule.category'] ?? '-',
},
];
const tabs = [
{
id: 'overview',
name: i18n.translate('xpack.observability.alerts.flyoutOverviewTabTitle', {
defaultMessage: 'Overview',
}),
content: (
<>
<EuiSpacer />
<EuiDescriptionList type="responsiveColumn" listItems={overviewListItems} />
</>
),
},
];
return (
<EuiFlyout onClose={onClose} size="m">
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2>{alert['rule.name']}</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s">{alert.reason}</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiTabbedContent size="s" tabs={tabs} />
</EuiFlyoutBody>
{alert.link && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton href={prepend(alert.link)} fill>
View in app
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
</EuiFlyout>
);
}

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { SearchBar, TimeHistory } from '../../../../../../src/plugins/data/public';
import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
import { useFetcher } from '../../hooks/use_fetcher';
@ -29,6 +29,7 @@ export function AlertsSearchBar({
const timeHistory = useMemo(() => {
return new TimeHistory(new Storage(localStorage));
}, []);
const [queryLanguage, setQueryLanguage] = useState<'lucene' | 'kuery'>('kuery');
const { data: dynamicIndexPattern } = useFetcher(({ signal }) => {
return callObservabilityApi({
@ -43,7 +44,7 @@ export function AlertsSearchBar({
placeholder={i18n.translate('xpack.observability.alerts.searchBarPlaceholder', {
defaultMessage: '"domain": "ecommerce" AND ("service.name": "ProductCatalogService" …)',
})}
query={{ query: query ?? '', language: 'kuery' }}
query={{ query: query ?? '', language: queryLanguage }}
timeHistory={timeHistory}
dateRangeFrom={rangeFrom}
dateRangeTo={rangeTo}
@ -55,6 +56,7 @@ export function AlertsSearchBar({
dateRange,
query: typeof nextQuery?.query === 'string' ? nextQuery.query : '',
});
setQueryLanguage((nextQuery?.language || 'kuery') as 'kuery' | 'lucene');
}}
/>
);

View file

@ -6,31 +6,22 @@
*/
import {
CustomItemAction,
EuiBasicTable,
EuiBasicTableColumn,
EuiBasicTableProps,
DefaultItemAction,
EuiTableSelectionType,
EuiButton,
EuiIconTip,
EuiLink,
EuiBadge,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { asDuration } from '../../../common/utils/formatters';
import { TimestampTooltip } from '../../components/shared/timestamp_tooltip';
import { usePluginContext } from '../../hooks/use_plugin_context';
import type { TopAlert } from './';
import { AlertsFlyout } from './alerts_flyout';
export interface TopAlert {
start: number;
duration: number;
reason: string;
link?: string;
severityLevel?: string;
active: boolean;
ruleName: string;
ruleCategory: string;
}
import { SeverityBadge } from './severity_badge';
type AlertsTableProps = Omit<
EuiBasicTableProps<TopAlert>,
@ -43,13 +34,18 @@ export function AlertsTable(props: AlertsTableProps) {
const { core } = usePluginContext();
const { prepend } = core.http.basePath;
const actions: Array<DefaultItemAction<TopAlert>> = [
const actions: Array<CustomItemAction<TopAlert>> = [
{
name: 'Alert details',
description: 'Alert details',
onClick: (item) => {
setFlyoutAlert(item);
},
render: (alert) =>
alert.link ? (
<EuiButton href={prepend(alert.link)} size="s">
{i18n.translate('xpack.observability.alertsTable.viewInAppButtonLabel', {
defaultMessage: 'View in app',
})}
</EuiButton>
) : (
<></>
),
isPrimary: true,
},
];
@ -57,54 +53,76 @@ export function AlertsTable(props: AlertsTableProps) {
const columns: Array<EuiBasicTableColumn<TopAlert>> = [
{
field: 'active',
name: 'Status',
width: '112px',
render: (_, { active }) => {
const style = {
width: '96px',
textAlign: 'center' as const,
};
name: i18n.translate('xpack.observability.alertsTable.statusColumnDescription', {
defaultMessage: 'Status',
}),
align: 'center',
render: (_, alert) => {
const { active } = alert;
return active ? (
<EuiBadge iconType="alert" color="danger" style={style}>
{i18n.translate('xpack.observability.alertsTable.status.active', {
<EuiIconTip
content={i18n.translate('xpack.observability.alertsTable.statusActiveDescription', {
defaultMessage: 'Active',
})}
</EuiBadge>
color="danger"
type="alert"
/>
) : (
<EuiBadge iconType="check" color="hollow" style={style}>
{i18n.translate('xpack.observability.alertsTable.status.recovered', {
<EuiIconTip
content={i18n.translate('xpack.observability.alertsTable.statusRecoveredDescription', {
defaultMessage: 'Recovered',
})}
</EuiBadge>
type="check"
/>
);
},
},
{
field: 'start',
name: 'Triggered',
name: i18n.translate('xpack.observability.alertsTable.triggeredColumnDescription', {
defaultMessage: 'Triggered',
}),
render: (_, item) => {
return <TimestampTooltip time={new Date(item.start).getTime()} timeUnit="milliseconds" />;
},
},
{
field: 'duration',
name: 'Duration',
render: (_, { duration, active }) => {
return active ? null : asDuration(duration, { extended: true });
name: i18n.translate('xpack.observability.alertsTable.durationColumnDescription', {
defaultMessage: 'Duration',
}),
render: (_, alert) => {
const { active } = alert;
return active
? null
: asDuration(alert['kibana.rac.alert.duration.us'], { extended: true });
},
},
{
field: 'severity',
name: i18n.translate('xpack.observability.alertsTable.severityColumnDescription', {
defaultMessage: 'Severity',
}),
render: (_, alert) => {
return <SeverityBadge severityLevel={alert['kibana.rac.alert.severity.level']} />;
},
},
{
field: 'reason',
name: 'Reason',
name: i18n.translate('xpack.observability.alertsTable.reasonColumnDescription', {
defaultMessage: 'Reason',
}),
dataType: 'string',
render: (_, item) => {
return item.link ? <EuiLink href={prepend(item.link)}>{item.reason}</EuiLink> : item.reason;
return <EuiLink onClick={() => setFlyoutAlert(item)}>{item.reason}</EuiLink>;
},
},
{
actions,
name: 'Actions',
name: i18n.translate('xpack.observability.alertsTable.actionsColumnDescription', {
defaultMessage: 'Actions',
}),
},
];
@ -113,8 +131,6 @@ export function AlertsTable(props: AlertsTableProps) {
{flyoutAlert && <AlertsFlyout alert={flyoutAlert} onClose={handleFlyoutClose} />}
<EuiBasicTable<TopAlert>
{...props}
isSelectable={true}
selection={{} as EuiTableSelectionType<TopAlert>}
columns={columns}
tableLayout="auto"
pagination={{ pageIndex: 0, pageSize: 0, totalItemCount: 0 }}

View file

@ -12,6 +12,7 @@ export const apmAlertResponseExample = [
'rule.name': 'Error count threshold | opbeans-java (smith test)',
'kibana.rac.alert.duration.us': 180057000,
'kibana.rac.alert.status': 'open',
'kibana.rac.alert.severity.level': 'warning',
tags: ['apm', 'service.name:opbeans-java'],
'kibana.rac.alert.uuid': '0175ec0a-a3b1-4d41-b557-e21c2d024352',
'rule.uuid': '474920d0-93e9-11eb-ac86-0b455460de81',
@ -47,16 +48,6 @@ export const apmAlertResponseExample = [
},
];
export const flyoutItemExample = {
link: '/app/apm/services/opbeans-java?rangeFrom=now-15m&rangeTo=now',
reason: 'Error count for opbeans-java was above the threshold',
active: true,
start: 1618235449493,
duration: 180057000,
ruleCategory: 'Error count threshold',
ruleName: 'Error count threshold | opbeans-java (smith test)',
};
export const dynamicIndexPattern = {
fields: [
{

View file

@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { format, parse } from 'url';
import type { ObservabilityAPIReturnType } from '../../services/call_observability_api/types';
import { ExperimentalBadge } from '../../components/shared/experimental_badge';
import { useFetcher } from '../../hooks/use_fetcher';
import { usePluginContext } from '../../hooks/use_plugin_context';
@ -28,6 +29,15 @@ import { asDuration, asPercent } from '../../../common/utils/formatters';
import { AlertsSearchBar } from './alerts_search_bar';
import { AlertsTable } from './alerts_table';
export type TopAlertResponse = ObservabilityAPIReturnType<'GET /api/observability/rules/alerts/top'>[number];
export interface TopAlert extends TopAlertResponse {
start: number;
reason: string;
link?: string;
active: boolean;
}
interface AlertsPageProps {
routeParams: RouteParams<'/alerts'>;
}
@ -75,6 +85,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
const parsedLink = formatted.link ? parse(formatted.link, true) : undefined;
return {
...alert,
...formatted,
link: parsedLink
? format({
@ -87,11 +98,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) {
})
: undefined,
active: alert['event.action'] !== 'close',
severityLevel: alert['kibana.rac.alert.severity.level'],
start: new Date(alert['kibana.rac.alert.start']).getTime(),
duration: alert['kibana.rac.alert.duration.us'],
ruleCategory: alert['rule.category'],
ruleName: alert['rule.name'],
};
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 } from 'react';
import { SeverityBadge } from './severity_badge';
type Args = ComponentProps<typeof SeverityBadge>;
export default {
title: 'app/Alerts/SeverityBadge',
component: SeverityBadge,
};
export function Example({ severityLevel }: Args) {
return <SeverityBadge severityLevel={severityLevel} />;
}
Example.args = { severityLevel: 'critical' } as Args;

View file

@ -0,0 +1,30 @@
/*
* 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 { EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
export interface SeverityBadgeProps {
severityLevel?: string;
}
const colorMap: { [key: string]: string } = {
critical: 'danger',
warning: 'warning',
};
export function SeverityBadge({ severityLevel }: SeverityBadgeProps) {
return (
<EuiBadge color={severityLevel ? colorMap[severityLevel] : 'default'}>
{severityLevel ??
i18n.translate('xpack.observability.severityBadge.unknownDescription', {
defaultMessage: 'unknown',
})}
</EuiBadge>
);
}

View file

@ -9,7 +9,7 @@ import { ObservabilityRuleRegistry } from '../plugin';
const createRuleRegistryMock = () => ({
registerType: () => {},
getTypeByRuleId: () => {},
getTypeByRuleId: () => ({ format: () => ({ link: '/test/example' }) }),
create: () => createRuleRegistryMock(),
});