diff --git a/packages/kbn-alerting-types/alert_type.ts b/packages/kbn-alerting-types/alert_type.ts new file mode 100644 index 0000000000000..61746aaaa14f6 --- /dev/null +++ b/packages/kbn-alerting-types/alert_type.ts @@ -0,0 +1,20 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TechnicalRuleDataFieldName } from '@kbn/rule-data-utils'; + +export interface BasicFields { + _id: string; + _index: string; +} + +export type Alert = BasicFields & { + [Property in TechnicalRuleDataFieldName]?: string[]; +} & { + [x: string]: unknown[]; +}; diff --git a/packages/kbn-alerting-types/index.ts b/packages/kbn-alerting-types/index.ts index f260374c13183..6d63366f4343d 100644 --- a/packages/kbn-alerting-types/index.ts +++ b/packages/kbn-alerting-types/index.ts @@ -9,3 +9,4 @@ export * from './builtin_action_groups_types'; export * from './rule_type'; export * from './action_group_types'; +export * from './alert_type'; diff --git a/packages/kbn-alerting-types/tsconfig.json b/packages/kbn-alerting-types/tsconfig.json index 049d2ef8d89b1..911e35551bbbd 100644 --- a/packages/kbn-alerting-types/tsconfig.json +++ b/packages/kbn-alerting-types/tsconfig.json @@ -18,5 +18,6 @@ "kbn_references": [ "@kbn/i18n", "@kbn/licensing-plugin", + "@kbn/rule-data-utils" ] } diff --git a/packages/kbn-alerts-ui-shared/index.ts b/packages/kbn-alerts-ui-shared/index.ts index 3dd1174da4129..25ef9bf8a66ca 100644 --- a/packages/kbn-alerts-ui-shared/index.ts +++ b/packages/kbn-alerts-ui-shared/index.ts @@ -15,3 +15,5 @@ export * from './src/alerts_search_bar/hooks'; export * from './src/alerts_search_bar/apis'; export { AlertsSearchBar } from './src/alerts_search_bar'; export type { AlertsSearchBarProps } from './src/alerts_search_bar/types'; + +export * from './src/alert_fields_table'; diff --git a/packages/kbn-alerts-ui-shared/src/alert_fields_table/index.test.tsx b/packages/kbn-alerts-ui-shared/src/alert_fields_table/index.test.tsx new file mode 100644 index 0000000000000..e930faa7acc0a --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/alert_fields_table/index.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { AlertFieldsTable, AlertFieldsTableProps } from '.'; +import { mount, ReactWrapper } from 'enzyme'; + +describe('AlertFieldsTable', () => { + const defaultProps = { + alert: { + 'kibana.alert.status': ['active'], + 'kibana.alert.url': ['ALERT_URL'], + 'kibana.alert.evaluation.conditions': [ + 'Number of matching documents is NOT greater than 1000', + ], + 'kibana.alert.rule.producer': ['stackAlerts'], + 'kibana.alert.reason.text': [ + 'Document count is 0 in the last 5m in metrics-* data view. Alert when greater than 1000.', + ], + 'kibana.alert.rule.rule_type_id': ['.es-query'], + 'kibana.alert.evaluation.value': ['0'], + 'kibana.alert.instance.id': ['query matched'], + 'kibana.alert.flapping': [true], + 'kibana.alert.rule.name': ['Test rule'], + 'event.kind': ['signal'], + 'kibana.alert.title': ["rule 'Test rule' recovered"], + 'kibana.alert.workflow_status': ['open'], + 'kibana.alert.rule.uuid': ['313b0cfb-3455-41da-aff9-cee2bc9637f1'], + 'kibana.alert.time_range': [ + { + gte: '1703241047013', + }, + ], + 'kibana.alert.flapping_history': [false], + 'kibana.alert.reason': [ + 'Document count is 0 in the last 5m in metrics-* data view. Alert when greater than 1000.', + ], + 'kibana.alert.rule.consumer': ['infrastructure'], + 'kibana.alert.action_group': ['query matched'], + 'kibana.alert.rule.category': ['Elasticsearch query'], + 'kibana.alert.start': ['2023-12-22T10:30:47.013Z'], + 'event.action': ['active'], + '@timestamp': ['2023-12-22T10:32:53.036Z'], + 'kibana.alert.duration.us': [126023000], + 'kibana.alert.rule.execution.uuid': ['ffcc5773-a4cc-48be-a265-9b16f85f4e62'], + 'kibana.alert.uuid': ['93377bf3-d837-425d-b63f-97a8a5ae8054'], + 'kibana.space_ids': ['default'], + 'kibana.version': ['8.13.0'], + 'kibana.alert.evaluation.threshold': [1000], + 'kibana.alert.rule.parameters': [ + { + searchConfiguration: { + query: { + language: 'kuery', + query: '', + }, + index: '8e29356e-9d83-4a89-a79d-7d096104f3f6', + }, + timeField: '@timestamp', + searchType: 'searchSource', + timeWindowSize: 5, + timeWindowUnit: 'm', + threshold: [1000], + thresholdComparator: '>', + size: 100, + aggType: 'count', + groupBy: 'all', + termSize: 5, + excludeHitsFromPreviousRun: true, + }, + ], + 'kibana.alert.rule.revision': [0], + _id: '93377bf3-d837-425d-b63f-97a8a5ae8054', + _index: '.internal.alerts-stack.alerts-default-000001', + }, + } as unknown as AlertFieldsTableProps; + let wrapper: ReactWrapper; + + beforeEach(async () => { + wrapper = mount(); + }); + + it('should paginate the results', () => { + expect(wrapper.find('tbody tr')).toHaveLength(25); + wrapper.find(`[data-test-subj="pagination-button-next"]`).last().simulate('click'); + expect(wrapper.find('tbody tr')).toHaveLength(8); + }); + + it('should filter the rows according to the search string', async () => { + wrapper + .find('input[type="search"]') + .simulate('keyup', { target: { value: 'kibana.alert.status' } }); + expect(wrapper.find('tbody tr')).toHaveLength(1); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/alert_fields_table/index.tsx b/packages/kbn-alerts-ui-shared/src/alert_fields_table/index.tsx new file mode 100644 index 0000000000000..cf120a00e5aa2 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/alert_fields_table/index.tsx @@ -0,0 +1,114 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { + EuiInMemoryTable, + EuiTabbedContent, + EuiTabbedContentProps, + useEuiOverflowScroll, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { Alert } from '@kbn/alerting-types'; +import { euiThemeVars } from '@kbn/ui-theme'; + +export const search = { + box: { + incremental: true, + placeholder: i18n.translate('alertsUIShared.alertFieldsTable.filter.placeholder', { + defaultMessage: 'Filter by Field, Value, or Description...', + }), + schema: true, + }, +}; + +const columns = [ + { + field: 'key', + name: i18n.translate('alertsUIShared.alertFieldsTable.field', { + defaultMessage: 'Field', + }), + width: '30%', + }, + { + field: 'value', + name: i18n.translate('alertsUIShared.alertFieldsTable.value', { + defaultMessage: 'Value', + }), + width: '70%', + }, +]; + +export const ScrollableFlyoutTabbedContent = (props: EuiTabbedContentProps) => ( + +); + +const COUNT_PER_PAGE_OPTIONS = [25, 50, 100]; + +const useFieldBrowserPagination = () => { + const [pagination, setPagination] = useState<{ pageIndex: number }>({ + pageIndex: 0, + }); + + const onTableChange = useCallback(({ page: { index } }: { page: { index: number } }) => { + setPagination({ pageIndex: index }); + }, []); + const paginationTableProp = useMemo( + () => ({ + ...pagination, + pageSizeOptions: COUNT_PER_PAGE_OPTIONS, + }), + [pagination] + ); + + return { + onTableChange, + paginationTableProp, + }; +}; + +export interface AlertFieldsTableProps { + alert: Alert; +} + +export const AlertFieldsTable = memo(({ alert }: AlertFieldsTableProps) => { + const { onTableChange, paginationTableProp } = useFieldBrowserPagination(); + return ( + ({ + key, + value: Array.isArray(value) ? value?.[0] : value, + }))} + itemId="key" + columns={columns} + onTableChange={onTableChange} + pagination={paginationTableProp} + search={search} + css={css` + & .euiTableRow { + font-size: ${euiThemeVars.euiFontSizeXS}; + font-family: ${euiThemeVars.euiCodeFontFamily}; + } + `} + /> + ); +}); diff --git a/packages/kbn-alerts-ui-shared/tsconfig.json b/packages/kbn-alerts-ui-shared/tsconfig.json index a7af1fcec80cc..f2347b579acef 100644 --- a/packages/kbn-alerts-ui-shared/tsconfig.json +++ b/packages/kbn-alerts-ui-shared/tsconfig.json @@ -28,5 +28,6 @@ "@kbn/data-views-plugin", "@kbn/unified-search-plugin", "@kbn/es-query", + "@kbn/ui-theme", ] } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.test.tsx new file mode 100644 index 0000000000000..b0fae16cf365d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.test.tsx @@ -0,0 +1,135 @@ +/* + * 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 { waitFor } from '@testing-library/react'; +import { mount } from 'enzyme'; +import type { ReactWrapper } from 'enzyme'; +import React from 'react'; + +import { getDefaultAlertFlyout } from './default_alerts_flyout'; +import { AlertsTableFlyoutBaseProps } from '../../../..'; + +const columns = [ + { + columnHeaderType: 'not-filtered', + displayAsText: 'Alert Status', + id: 'kibana.alert.status', + initialWidth: 110, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Last updated', + id: '@timestamp', + initialWidth: 230, + schema: 'datetime', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Duration', + id: 'kibana.alert.duration.us', + initialWidth: 116, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + linkField: '*', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Maintenance windows', + id: 'kibana.alert.maintenance_window_ids', + schema: 'string', + initialWidth: 180, + }, +]; + +const alert = { + _id: 'dc80788f-f869-4f14-bedb-950186c9d2f8', + _index: '.internal.alerts-stack.alerts-default-000001', + 'kibana.alert.status': ['active'], + 'kibana.alert.evaluation.conditions': ['Number of matching documents is NOT greater than 1000'], + 'kibana.alert.rule.producer': ['stackAlerts'], + 'kibana.alert.reason.text': [ + 'Document count is 0 in the last 5m in metrics-* data view. Alert when greater than 1000.', + ], + 'kibana.alert.rule.rule_type_id': ['.es-query'], + 'kibana.alert.evaluation.value': ['0'], + 'kibana.alert.instance.id': ['query matched'], + 'kibana.alert.rule.name': ['Test rule'], + 'event.kind': ['signal'], + 'kibana.alert.title': ["rule 'Test rule' recovered"], + 'kibana.alert.workflow_status': ['open'], + 'kibana.alert.rule.uuid': ['TEST_RULE_UUID'], + 'kibana.alert.reason': [ + 'Document count is 0 in the last 5m in metrics-* data view. Alert when greater than 1000.', + ], + 'kibana.alert.rule.consumer': ['infrastructure'], + 'kibana.alert.action_group': ['query matched'], + 'kibana.alert.rule.category': ['Elasticsearch query'], + 'event.action': ['active'], + '@timestamp': ['2023-12-22T09:23:08.244Z'], + 'kibana.alert.rule.execution.uuid': ['9ca6fe40-90c0-4e32-9772-025e4de79dd8'], + 'kibana.alert.uuid': ['dc80788f-f869-4f14-bedb-950186c9d2f8'], + 'kibana.space_ids': ['default'], + 'kibana.version': ['8.13.0'], +} as unknown as AlertsTableFlyoutBaseProps['alert']; + +const tabsData = [ + { name: 'Overview', subj: 'overviewTab' }, + { name: 'Table', subj: 'tableTab' }, +]; + +describe('DefaultAlertsFlyout', () => { + let wrapper: ReactWrapper; + beforeAll(async () => { + const { body: FlyoutBody } = getDefaultAlertFlyout(columns, (_columnId, value) => value)(); + wrapper = mount() as ReactWrapper; + await waitFor(() => wrapper.update()); + }); + + describe('tabs', () => { + tabsData.forEach(({ name: tab }) => { + test(`should render the ${tab} tab`, () => { + expect( + wrapper + .find('[data-test-subj="defaultAlertFlyoutTabs"]') + .find('[role="tablist"]') + .containsMatchingElement({tab}) + ).toBeTruthy(); + }); + }); + + test('the Overview tab should be selected by default', () => { + expect( + wrapper + .find('[data-test-subj="defaultAlertFlyoutTabs"]') + .find('.euiTab-isSelected') + .first() + .text() + ).toEqual('Overview'); + }); + + tabsData.forEach(({ subj, name }) => { + test(`should render the ${name} tab panel`, () => { + wrapper + .find('[data-test-subj="defaultAlertFlyoutTabs"]') + .find('[role="tablist"]') + .find(`[data-test-subj="${subj}"]`) + .first() + .simulate('click'); + expect( + wrapper + .find('[data-test-subj="defaultAlertFlyoutTabs"]') + .find('[role="tabpanel"]') + .find(`[data-test-subj="${subj}Panel"]`) + .exists() + ).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.tsx index ab9b4d4813c18..ac6fd3e68970d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_flyout/default_alerts_flyout.tsx @@ -5,12 +5,20 @@ * 2.0. */ -import { get } from 'lodash'; -import React from 'react'; -import { type EuiDataGridColumn, EuiDescriptionList, EuiPanel, EuiTitle } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + type EuiDataGridColumn, + EuiDescriptionList, + EuiPanel, + EuiTabbedContentTab, + EuiTitle, +} from '@elastic/eui'; import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; -import { AlertsTableFlyoutBaseProps, AlertTableFlyoutComponent } from '../../../..'; +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { ScrollableFlyoutTabbedContent, AlertFieldsTable } from '@kbn/alerts-ui-shared'; import { RegisterFormatter } from '../cells/render_cell_value'; +import { AlertsTableFlyoutBaseProps, AlertTableFlyoutComponent } from '../../../..'; const FlyoutHeader: AlertTableFlyoutComponent = ({ alert }: AlertsTableFlyoutBaseProps) => { const name = alert[ALERT_RULE_NAME]; @@ -21,28 +29,83 @@ const FlyoutHeader: AlertTableFlyoutComponent = ({ alert }: AlertsTableFlyoutBas ); }; +type TabId = 'overview' | 'table'; + export const getDefaultAlertFlyout = (columns: EuiDataGridColumn[], formatter: RegisterFormatter) => () => { - const FlyoutBody: AlertTableFlyoutComponent = ({ alert }: AlertsTableFlyoutBaseProps) => ( - - { - const value = get(alert, column.id)?.[0]; - - return { - title: column.displayAsText as string, - description: value != null ? formatter(column.id, value) : '—', - }; - })} - type="column" - columnWidths={[1, 3]} + const FlyoutBody: AlertTableFlyoutComponent = ({ alert }: AlertsTableFlyoutBaseProps) => { + const overviewTab = useMemo( + () => ({ + id: 'overview', + 'data-test-subj': 'overviewTab', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.alertsFlyout.overview', + { + defaultMessage: 'Overview', + } + ), + content: ( + + { + const value = get(alert, column.id)?.[0]; + + return { + title: column.displayAsText as string, + description: value != null ? formatter(column.id, value) : '—', + }; + })} + type="column" + columnWidths={[1, 3]} + /> + + ), + }), + [alert] + ); + + const tableTab = useMemo( + () => ({ + id: 'table', + 'data-test-subj': 'tableTab', + name: i18n.translate('xpack.triggersActionsUI.sections.alertsTable.alertsFlyout.table', { + defaultMessage: 'Table', + }), + content: ( + + + + ), + }), + [alert] + ); + + const tabs = useMemo(() => [overviewTab, tableTab], [overviewTab, tableTab]); + const [selectedTabId, setSelectedTabId] = useState('overview'); + const handleTabClick = useCallback( + (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as TabId), + [] + ); + + const selectedTab = useMemo( + () => tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0], + [tabs, selectedTabId] + ); + + return ( + - - ); + ); + }; return { - body: FlyoutBody, header: FlyoutHeader, + body: FlyoutBody, footer: null, }; };