From 5a7f395003ab652db28134afa7b182eb41b176d2 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Tue, 18 Jul 2023 19:21:37 +0200 Subject: [PATCH] [Infra UI] Add alerts to asset details flyout (#161677) Closes #160371 ## Summary This PR adds alerts section to the overview tab inside the asset details flyout component. Notes: A lot of changes are extracting common components from the alerts tab to a common folder. The flyout version is not showing the chart so it's not exactly the same component but a big part of the logic is reused there. The tooltip content can be found in a [Figma comment ](https://www.figma.com/file/XBVpHX6pOBaTPoGHWhEQJH?node-id=843:435665&mode=design#492130894) alerts_section ## Alerts summary widget changes: After introducing the `hideChart` prop [here](https://github.com/elastic/kibana/pull/161263) in this PR I change the spinner type and size in case of no chart we want to have a smaller section with a smaller spinner: ![image](https://github.com/elastic/kibana/assets/14139027/43a3c611-0404-4c21-a503-22f1a79dc1de) ![image](https://github.com/elastic/kibana/assets/14139027/a870fa9b-5367-4303-9b7d-4da9ff2eae2b) ## Storybook I added some changes to make the alerts widget show in the storybook [[Workaround for storybook](https://github.com/elastic/kibana/pull/161677/commits/d97a2b173618993fb2348fac4eefeb1d757f65c0)] image ## Testing - Go to Hosts view and open the single host flyout - alerts section should be visible - Alerts title icon should open a tooltip with links to alerts and alerts documentation - Alerts links: - The Create rule link will open a flyout (on top, not closing the existing flyout) to create an inventory rule, when closed/saved rule the single host flyout should remain open - The Show All link should navigate to alerts and apply time range / host.name filter selected in the hosts view https://github.com/elastic/kibana/assets/14139027/b362042a-b9de-460c-86ae-282154b586ff --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../inventory/components/alert_flyout.tsx | 2 +- .../infra/public/common/alerts/constants.ts | 57 +++++++ .../common/alerts/create_alerts_es_query.ts | 41 +++++ .../infra/public/common/alerts/types.ts | 18 +++ .../__stories__/context/fixtures/alerts.ts | 41 +++++ .../__stories__/context/fixtures/index.ts | 1 + .../asset_details/__stories__/context/http.ts | 10 ++ .../asset_details/__stories__/decorator.tsx | 19 ++- .../components/alerts_tooltip_content.tsx | 52 +++++++ .../asset_details/links/link_to_alerts.tsx | 8 +- .../links/link_to_alerts_page.tsx | 70 +++++++++ .../asset_details/tabs/overview/alerts.tsx | 143 ++++++++++++++++++ .../asset_details/tabs/overview/overview.tsx | 7 +- .../tabs/alerts/alerts_status_filter.tsx | 8 +- .../tabs/alerts/alerts_tab_content.tsx | 38 +---- .../metrics/hosts/components/tabs/config.ts | 3 - .../public/pages/metrics/hosts/constants.ts | 45 +----- .../metrics/hosts/hooks/use_alerts_query.ts | 51 +------ .../infra/public/pages/metrics/hosts/types.ts | 16 +- .../public/hooks/use_summary_time_range.tsx | 28 ++++ x-pack/plugins/observability/public/index.ts | 1 + .../alert_summary_widget.tsx | 3 +- .../alert_summary_widget_loader.tsx | 19 ++- .../public/common/get_rule_alerts_summary.tsx | 9 +- .../test/functional/apps/infra/hosts_view.ts | 54 +++++++ .../page_objects/infra_hosts_view.ts | 24 +++ 26 files changed, 612 insertions(+), 156 deletions(-) create mode 100644 x-pack/plugins/infra/public/common/alerts/constants.ts create mode 100644 x-pack/plugins/infra/public/common/alerts/create_alerts_es_query.ts create mode 100644 x-pack/plugins/infra/public/common/alerts/types.ts create mode 100644 x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/alerts.ts create mode 100644 x-pack/plugins/infra/public/components/asset_details/components/alerts_tooltip_content.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts_page.tsx create mode 100644 x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx create mode 100644 x-pack/plugins/observability/public/hooks/use_summary_time_range.tsx diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx index b57cf4c68ce1c..0c9dfb731df86 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx @@ -25,7 +25,7 @@ export const AlertFlyout = ({ options, nodeType, filter, visible, setVisible }: const { triggersActionsUI } = useContext(TriggerActionsContext); const { inventoryPrefill } = useAlertPrefillContext(); - const { customMetrics } = inventoryPrefill; + const { customMetrics = [] } = inventoryPrefill ?? {}; const onCloseFlyout = useCallback(() => setVisible(false), [setVisible]); const AddAlertFlyout = useMemo( () => diff --git a/x-pack/plugins/infra/public/common/alerts/constants.ts b/x-pack/plugins/infra/public/common/alerts/constants.ts new file mode 100644 index 0000000000000..8c71ba66e86e8 --- /dev/null +++ b/x-pack/plugins/infra/public/common/alerts/constants.ts @@ -0,0 +1,57 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; +import type { AlertStatusFilter } from './types'; + +export const ALERT_STATUS_ALL = 'all'; + +export const ALL_ALERTS: AlertStatusFilter = { + status: ALERT_STATUS_ALL, + label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.showAll', { + defaultMessage: 'Show all', + }), +}; + +export const ACTIVE_ALERTS: AlertStatusFilter = { + status: ALERT_STATUS_ACTIVE, + query: { + term: { + [ALERT_STATUS]: { + value: ALERT_STATUS_ACTIVE, + }, + }, + }, + label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.active', { + defaultMessage: 'Active', + }), +}; + +export const RECOVERED_ALERTS: AlertStatusFilter = { + status: ALERT_STATUS_RECOVERED, + query: { + term: { + [ALERT_STATUS]: { + value: ALERT_STATUS_RECOVERED, + }, + }, + }, + label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.recovered', { + defaultMessage: 'Recovered', + }), +}; + +export const ALERT_STATUS_QUERY = { + [ACTIVE_ALERTS.status]: ACTIVE_ALERTS.query, + [RECOVERED_ALERTS.status]: RECOVERED_ALERTS.query, +}; + +export const ALERTS_DOC_HREF = + 'https://www.elastic.co/guide/en/observability/current/create-alerts.html'; + +export const ALERTS_PATH = '/app/observability/alerts'; diff --git a/x-pack/plugins/infra/public/common/alerts/create_alerts_es_query.ts b/x-pack/plugins/infra/public/common/alerts/create_alerts_es_query.ts new file mode 100644 index 0000000000000..3fddf57a23695 --- /dev/null +++ b/x-pack/plugins/infra/public/common/alerts/create_alerts_es_query.ts @@ -0,0 +1,41 @@ +/* + * 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 { getTime } from '@kbn/data-plugin/common'; +import { ALERT_TIME_RANGE } from '@kbn/rule-data-utils'; +import { buildEsQuery, Filter, type TimeRange } from '@kbn/es-query'; +import type { AlertStatus } from '@kbn/observability-plugin/common/typings'; +import { ALERT_STATUS_QUERY } from './constants'; +import { buildCombinedHostsFilter } from '../../utils/filters/build'; +import type { AlertsEsQuery } from './types'; + +export const createAlertsEsQuery = ({ + dateRange, + hostNodeNames, + status, +}: { + dateRange: TimeRange; + hostNodeNames: string[]; + status?: AlertStatus; +}): AlertsEsQuery => { + const alertStatusFilter = createAlertStatusFilter(status); + + const dateFilter = createDateFilter(dateRange); + const hostsFilter = buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodeNames, + }); + + const filters = [alertStatusFilter, dateFilter, hostsFilter].filter(Boolean) as Filter[]; + + return buildEsQuery(undefined, [], filters); +}; + +const createDateFilter = (date: TimeRange) => + getTime(undefined, date, { fieldName: ALERT_TIME_RANGE }); + +const createAlertStatusFilter = (status: AlertStatus = 'all'): Filter | null => + ALERT_STATUS_QUERY[status] ? { query: ALERT_STATUS_QUERY[status], meta: {} } : null; diff --git a/x-pack/plugins/infra/public/common/alerts/types.ts b/x-pack/plugins/infra/public/common/alerts/types.ts new file mode 100644 index 0000000000000..435b0c17fe61d --- /dev/null +++ b/x-pack/plugins/infra/public/common/alerts/types.ts @@ -0,0 +1,18 @@ +/* + * 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 type { BoolQuery, Filter } from '@kbn/es-query'; +import type { AlertStatus } from '@kbn/observability-plugin/common/typings'; +export interface AlertStatusFilter { + status: AlertStatus; + query?: Filter['query']; + label: string; +} + +export interface AlertsEsQuery { + bool: BoolQuery; +} diff --git a/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/alerts.ts b/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/alerts.ts new file mode 100644 index 0000000000000..1fbe684a37ab9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/alerts.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +const summaryResponse = { + activeAlertCount: 3, + recoveredAlertCount: 3, + activeAlerts: [ + { + key_as_string: '1689172920000', + key: 1689172920000, + doc_count: 3, + }, + { + key_as_string: '1689172980000', + key: 1689172980000, + doc_count: 3, + }, + ], + recoveredAlerts: [ + { + key_as_string: '2023-07-12T14:42:00.000Z', + key: 1689172920000, + doc_count: 3, + }, + { + key_as_string: '2023-07-12T14:43:00.000Z', + key: 1689172980000, + doc_count: 3, + }, + ], +}; + +export const alertsSummaryHttpResponse = { + default: () => Promise.resolve({ ...summaryResponse }), +}; + +export type AlertsSummaryHttpMocks = keyof typeof alertsSummaryHttpResponse; diff --git a/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/index.ts b/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/index.ts index b194bb9e02ac0..051fdb73a9e95 100644 --- a/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/index.ts +++ b/x-pack/plugins/infra/public/components/asset_details/__stories__/context/fixtures/index.ts @@ -11,6 +11,7 @@ export { processesChartHttpResponse, type ProcessesHttpMocks, } from './processes'; +export { alertsSummaryHttpResponse, type AlertsSummaryHttpMocks } from './alerts'; export { anomaliesHttpResponse, type AnomaliesHttpMocks } from './anomalies'; export { snapshotAPItHttpResponse, type SnapshotAPIHttpMocks } from './snapshot_api'; export { getLogEntries } from './log_entries'; diff --git a/x-pack/plugins/infra/public/components/asset_details/__stories__/context/http.ts b/x-pack/plugins/infra/public/components/asset_details/__stories__/context/http.ts index a8d72239aa7d7..3d9e13bf52667 100644 --- a/x-pack/plugins/infra/public/components/asset_details/__stories__/context/http.ts +++ b/x-pack/plugins/infra/public/components/asset_details/__stories__/context/http.ts @@ -9,11 +9,13 @@ import type { HttpStart, HttpHandler } from '@kbn/core/public'; import type { Parameters } from '@storybook/react'; import { INFA_ML_GET_METRICS_HOSTS_ANOMALIES_PATH } from '../../../../../common/http_api/infra_ml'; import { + alertsSummaryHttpResponse, anomaliesHttpResponse, metadataHttpResponse, processesChartHttpResponse, processesHttpResponse, snapshotAPItHttpResponse, + type AlertsSummaryHttpMocks, type AnomaliesHttpMocks, type MetadataResponseMocks, type ProcessesHttpMocks, @@ -43,6 +45,14 @@ export const getHttp = (params: Parameters): HttpStart => { return Promise.resolve({}); } }) as HttpHandler, + post: (async (path: string) => { + switch (path) { + case '/internal/rac/alerts/_alert_summary': + return alertsSummaryHttpResponse[params.mock as AlertsSummaryHttpMocks](); + default: + return Promise.resolve({}); + } + }) as HttpHandler, } as unknown as HttpStart; return http; diff --git a/x-pack/plugins/infra/public/components/asset_details/__stories__/decorator.tsx b/x-pack/plugins/infra/public/components/asset_details/__stories__/decorator.tsx index 4a4eaa740230f..542bdc33d7431 100644 --- a/x-pack/plugins/infra/public/components/asset_details/__stories__/decorator.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/__stories__/decorator.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { JSXElementConstructor, ReactElement } from 'react'; import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, @@ -18,6 +18,9 @@ import { useParameter } from '@storybook/addons'; import type { DeepPartial } from 'utility-types'; import type { LocatorPublic } from '@kbn/share-plugin/public'; import type { IKibanaSearchRequest, ISearchOptions } from '@kbn/data-plugin/public'; +import { AlertSummaryWidget } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alert_summary_widget/alert_summary_widget'; +import type { Theme } from '@elastic/charts/dist/utils/themes/theme'; +import type { AlertSummaryWidgetProps } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alert_summary_widget'; import type { PluginKibanaContextValue } from '../../../hooks/use_kibana'; import { SourceProvider } from '../../../containers/metrics_source'; import { getHttp } from './context/http'; @@ -66,6 +69,20 @@ export const DecorateWithKibanaContext: DecoratorFn = (story) => { return Promise.resolve([]); }, }, + uiSettings: { + get: () => ({ key: 'mock', defaultOverride: undefined } as any), + }, + triggersActionsUi: { + getAlertSummaryWidget: AlertSummaryWidget as ( + props: AlertSummaryWidgetProps + ) => ReactElement>, + }, + charts: { + theme: { + useChartsTheme: () => ({} as Theme), + useChartsBaseTheme: () => ({} as Theme), + }, + }, settings: { client: { get$: (key: string) => of(getSettings(key)), diff --git a/x-pack/plugins/infra/public/components/asset_details/components/alerts_tooltip_content.tsx b/x-pack/plugins/infra/public/components/asset_details/components/alerts_tooltip_content.tsx new file mode 100644 index 0000000000000..3ad149ce6c78f --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/components/alerts_tooltip_content.tsx @@ -0,0 +1,52 @@ +/* + * 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 from 'react'; +import { EuiText, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ALERTS_DOC_HREF } from '../../../common/alerts/constants'; +import { LinkToAlertsHomePage } from '../links/link_to_alerts_page'; + +export const AlertsTooltipContent = React.memo(() => { + const onClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return ( + +

+ , + }} + /> +

+

+ + + + ), + }} + /> +

+
+ ); +}); diff --git a/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts.tsx index 17f3f7a30d861..47d7075cb60dc 100644 --- a/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts.tsx @@ -8,11 +8,11 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonEmpty } from '@elastic/eui'; -export interface LinkToAlertsRule { +export interface LinkToAlertsRuleProps { onClick?: () => void; } -export const LinkToAlertsRule = ({ onClick }: LinkToAlertsRule) => { +export const LinkToAlertsRule = ({ onClick }: LinkToAlertsRuleProps) => { return ( { iconType="bell" > ); diff --git a/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts_page.tsx b/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts_page.tsx new file mode 100644 index 0000000000000..ce8c0bf08e657 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/links/link_to_alerts_page.tsx @@ -0,0 +1,70 @@ +/* + * 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 from 'react'; +import { encode } from '@kbn/rison'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonEmpty, EuiLink } from '@elastic/eui'; +import type { TimeRange } from '@kbn/es-query'; +import { ALERTS_PATH } from '../../../common/alerts/constants'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; + +export interface LinkToAlertsPageProps { + nodeName: string; + queryField: string; + dateRange: TimeRange; +} + +export const LinkToAlertsPage = ({ nodeName, queryField, dateRange }: LinkToAlertsPageProps) => { + const { services } = useKibanaContextForPlugin(); + const { http } = services; + + const linkToAlertsPage = http.basePath.prepend( + `${ALERTS_PATH}?_a=${encode({ + kuery: `${queryField}:"${nodeName}"`, + rangeFrom: dateRange.from, + rangeTo: dateRange.to, + status: 'all', + })}` + ); + + return ( + + + + + + ); +}; + +export const LinkToAlertsHomePage = () => { + const { services } = useKibanaContextForPlugin(); + const { http } = services; + + const linkToAlertsPage = http.basePath.prepend(ALERTS_PATH); + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx new file mode 100644 index 0000000000000..87ebaae1f5f20 --- /dev/null +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/alerts.tsx @@ -0,0 +1,143 @@ +/* + * 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, { useMemo } from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPopover, EuiIcon, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useSummaryTimeRange } from '@kbn/observability-plugin/public'; +import type { TimeRange } from '@kbn/es-query'; +import type { AlertsEsQuery } from '../../../../common/alerts/types'; +import { AlertsTooltipContent } from '../../components/alerts_tooltip_content'; +import type { InventoryItemType } from '../../../../../common/inventory_models/types'; +import { findInventoryFields } from '../../../../../common/inventory_models'; +import { createAlertsEsQuery } from '../../../../common/alerts/create_alerts_es_query'; +import { infraAlertFeatureIds } from '../../../../pages/metrics/hosts/components/tabs/config'; + +import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; +import { LinkToAlertsRule } from '../../links/link_to_alerts'; +import { LinkToAlertsPage } from '../../links/link_to_alerts_page'; +import { AlertFlyout } from '../../../../alerting/inventory/components/alert_flyout'; +import { useBoolean } from '../../../../hooks/use_boolean'; +import { ALERT_STATUS_ALL } from '../../../../common/alerts/constants'; + +export const AlertsSummaryContent = ({ + nodeName, + nodeType, + dateRange, +}: { + nodeName: string; + nodeType: InventoryItemType; + dateRange: TimeRange; +}) => { + const [isAlertFlyoutVisible, { toggle: toggleAlertFlyout }] = useBoolean(false); + + const alertsEsQueryByStatus = useMemo( + () => + createAlertsEsQuery({ + dateRange, + hostNodeNames: [nodeName], + status: ALERT_STATUS_ALL, + }), + [nodeName, dateRange] + ); + + return ( + <> + + + + + + + + + + + + + + + + ); +}; + +interface MemoAlertSummaryWidgetProps { + alertsQuery: AlertsEsQuery; + dateRange: TimeRange; +} + +const MemoAlertSummaryWidget = React.memo( + ({ alertsQuery, dateRange }: MemoAlertSummaryWidgetProps) => { + const { services } = useKibanaContextForPlugin(); + + const summaryTimeRange = useSummaryTimeRange(dateRange); + + const { charts, triggersActionsUi } = services; + const { getAlertSummaryWidget: AlertSummaryWidget } = triggersActionsUi; + + const chartProps = { + theme: charts.theme.useChartsTheme(), + baseTheme: charts.theme.useChartsBaseTheme(), + }; + + return ( + + ); + } +); + +const AlertsSectionTitle = () => { + const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false); + + return ( + + + +
+ +
+
+
+ + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + repositionOnScroll + anchorPosition="upCenter" + > + + + +
+ ); +}; diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/overview.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/overview.tsx index c86d9814ef62e..e85a2c65fe2a9 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/overview/overview.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/overview/overview.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiHorizontalRule } from '@elastic/eui'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { TimeRange } from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -17,6 +17,7 @@ import { findInventoryModel } from '../../../../../common/inventory_models'; import { useMetadata } from '../../hooks/use_metadata'; import { useSourceContext } from '../../../../containers/metrics_source'; import { MetadataSummary } from './metadata_summary'; +import { AlertsSummaryContent } from './alerts'; import { KPIGrid } from './kpis/kpi_grid'; import { MetricsGrid } from './metrics/metrics_grid'; import { toTimestampRange } from '../../utils'; @@ -92,6 +93,10 @@ export const Overview = ({ )} + + + + void; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx index 53e19d73fb4a8..9913f11b20cc9 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx @@ -4,29 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { - calculateTimeRangeBucketSize, - getAlertSummaryTimeRange, - useTimeBuckets, -} from '@kbn/observability-plugin/public'; import { AlertConsumers } from '@kbn/rule-data-utils'; -import { TimeRange } from '@kbn/es-query'; -import { BrushEndListener, XYBrushEvent } from '@elastic/charts'; +import { BrushEndListener, type XYBrushEvent } from '@elastic/charts'; +import { useSummaryTimeRange } from '@kbn/observability-plugin/public'; +import type { AlertsEsQuery } from '../../../../../../common/alerts/types'; import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; import { HeightRetainer } from '../../../../../../components/height_retainer'; import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; - -import { - ALERTS_PER_PAGE, - ALERTS_TABLE_ID, - DEFAULT_DATE_FORMAT, - DEFAULT_INTERVAL, - infraAlertFeatureIds, -} from '../config'; -import { AlertsEsQuery, useAlertsQuery } from '../../../hooks/use_alerts_query'; +import { useAlertsQuery } from '../../../hooks/use_alerts_query'; import AlertsStatusFilter from './alerts_status_filter'; +import { ALERTS_PER_PAGE, ALERTS_TABLE_ID, infraAlertFeatureIds } from '../config'; import { HostsState, HostsStateUpdater } from '../../../hooks/use_unified_search_url_state'; export const AlertsTabContent = () => { @@ -120,18 +109,3 @@ const MemoAlertSummaryWidget = React.memo( ); } ); - -const useSummaryTimeRange = (unifiedSearchDateRange: TimeRange) => { - const timeBuckets = useTimeBuckets(); - - const bucketSize = useMemo( - () => calculateTimeRangeBucketSize(unifiedSearchDateRange, timeBuckets), - [unifiedSearchDateRange, timeBuckets] - ); - - return getAlertSummaryTimeRange( - unifiedSearchDateRange, - bucketSize?.intervalString || DEFAULT_INTERVAL, - bucketSize?.dateFormat || DEFAULT_DATE_FORMAT - ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts index 28ba329e6a264..cf4374cae94fa 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/config.ts @@ -13,6 +13,3 @@ export const ALERTS_TABLE_ID = 'xpack.infra.hosts.alerts.table'; export const INFRA_ALERT_FEATURE_ID = 'infrastructure'; export const infraAlertFeatureIds: ValidFeatureId[] = [AlertConsumers.INFRASTRUCTURE]; - -export const DEFAULT_INTERVAL = '60s'; -export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm'; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts index ca39287f27044..e8f3d2714127e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts @@ -5,11 +5,8 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; -import { AlertStatusFilter, HostLimitOptions } from './types'; +import { HostLimitOptions } from './types'; -export const ALERT_STATUS_ALL = 'all'; export const TIMESTAMP_FIELD = '@timestamp'; export const DATA_VIEW_PREFIX = 'infra_metrics'; @@ -21,45 +18,5 @@ export const LOCAL_STORAGE_PAGE_SIZE_KEY = 'hostsView:pageSizeSelection'; export const KPI_CHART_MIN_HEIGHT = 150; export const METRIC_CHART_MIN_HEIGHT = 300; -export const ALL_ALERTS: AlertStatusFilter = { - status: ALERT_STATUS_ALL, - label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.showAll', { - defaultMessage: 'Show all', - }), -}; - -export const ACTIVE_ALERTS: AlertStatusFilter = { - status: ALERT_STATUS_ACTIVE, - query: { - term: { - [ALERT_STATUS]: { - value: ALERT_STATUS_ACTIVE, - }, - }, - }, - label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.active', { - defaultMessage: 'Active', - }), -}; - -export const RECOVERED_ALERTS: AlertStatusFilter = { - status: ALERT_STATUS_RECOVERED, - query: { - term: { - [ALERT_STATUS]: { - value: ALERT_STATUS_RECOVERED, - }, - }, - }, - label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.recovered', { - defaultMessage: 'Recovered', - }), -}; - -export const ALERT_STATUS_QUERY = { - [ACTIVE_ALERTS.status]: ACTIVE_ALERTS.query, - [RECOVERED_ALERTS.status]: RECOVERED_ALERTS.query, -}; - export const HOST_LIMIT_OPTIONS = [10, 20, 50, 100, 500] as const; export const HOST_METRICS_DOC_HREF = 'https://ela.st/docs-infra-host-metrics'; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts index 46ef8c62d3ed7..2cb557e902efc 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts @@ -6,20 +6,10 @@ */ import { useCallback, useMemo, useState } from 'react'; import createContainer from 'constate'; -import { getTime } from '@kbn/data-plugin/common'; -import { ALERT_TIME_RANGE } from '@kbn/rule-data-utils'; -import { BoolQuery, buildEsQuery, Filter } from '@kbn/es-query'; -import { InfraAssetMetricsItem } from '../../../../../common/http_api'; +import type { AlertStatus } from '@kbn/observability-plugin/common/typings'; +import { createAlertsEsQuery } from '../../../../common/alerts/create_alerts_es_query'; import { useUnifiedSearchContext } from './use_unified_search'; -import { HostsState } from './use_unified_search_url_state'; import { useHostsViewContext } from './use_hosts_view'; -import { AlertStatus } from '../types'; -import { ALERT_STATUS_QUERY } from '../constants'; -import { buildCombinedHostsFilter } from '../../../../utils/filters/build'; - -export interface AlertsEsQuery { - bool: BoolQuery; -} export const useAlertsQueryImpl = () => { const { hostNodes } = useHostsViewContext(); @@ -28,10 +18,12 @@ export const useAlertsQueryImpl = () => { const [alertStatus, setAlertStatus] = useState('all'); + const hostNodeNames = useMemo(() => hostNodes.map((n) => n.name), [hostNodes]); + const getAlertsEsQuery = useCallback( (status?: AlertStatus) => - createAlertsEsQuery({ dateRange: searchCriteria.dateRange, hostNodes, status }), - [hostNodes, searchCriteria.dateRange] + createAlertsEsQuery({ dateRange: searchCriteria.dateRange, hostNodeNames, status }), + [hostNodeNames, searchCriteria.dateRange] ); // Regenerate the query when status change even if is not used. @@ -53,34 +45,3 @@ export const useAlertsQueryImpl = () => { export const AlertsQueryContainer = createContainer(useAlertsQueryImpl); export const [AlertsQueryProvider, useAlertsQuery] = AlertsQueryContainer; - -/** - * Helpers - */ -const createAlertsEsQuery = ({ - dateRange, - hostNodes, - status, -}: { - dateRange: HostsState['dateRange']; - hostNodes: InfraAssetMetricsItem[]; - status?: AlertStatus; -}): AlertsEsQuery => { - const alertStatusFilter = createAlertStatusFilter(status); - - const dateFilter = createDateFilter(dateRange); - const hostsFilter = buildCombinedHostsFilter({ - field: 'host.name', - values: hostNodes.map((p) => p.name), - }); - - const filters = [alertStatusFilter, dateFilter, hostsFilter].filter(Boolean) as Filter[]; - - return buildEsQuery(undefined, [], filters); -}; - -const createDateFilter = (date: HostsState['dateRange']) => - getTime(undefined, date, { fieldName: ALERT_TIME_RANGE }); - -const createAlertStatusFilter = (status: AlertStatus = 'all'): Filter | null => - ALERT_STATUS_QUERY[status] ? { query: ALERT_STATUS_QUERY[status], meta: {} } : null; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts index 080b47f54d4da..6d6b6214e00bd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts @@ -4,20 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { Filter } from '@kbn/es-query'; -import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils'; -import { ALERT_STATUS_ALL, HOST_LIMIT_OPTIONS } from './constants'; - -export type AlertStatus = - | typeof ALERT_STATUS_ACTIVE - | typeof ALERT_STATUS_RECOVERED - | typeof ALERT_STATUS_ALL; - -export interface AlertStatusFilter { - status: AlertStatus; - query?: Filter['query']; - label: string; -} +import { HOST_LIMIT_OPTIONS } from './constants'; export type HostLimitOptions = typeof HOST_LIMIT_OPTIONS[number]; diff --git a/x-pack/plugins/observability/public/hooks/use_summary_time_range.tsx b/x-pack/plugins/observability/public/hooks/use_summary_time_range.tsx new file mode 100644 index 0000000000000..b5edd968f52c8 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_summary_time_range.tsx @@ -0,0 +1,28 @@ +/* + * 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 { useMemo } from 'react'; +import type { TimeRange } from '@kbn/es-query'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; +import { calculateTimeRangeBucketSize, getAlertSummaryTimeRange, useTimeBuckets } from '..'; +import { DEFAULT_INTERVAL, DEFAULT_DATE_FORMAT } from '../constants'; + +export const useSummaryTimeRange = (unifiedSearchDateRange: TimeRange) => { + const timeBuckets = useTimeBuckets(); + const dateFormat = useUiSetting('dateFormat'); + + const bucketSize = useMemo( + () => calculateTimeRangeBucketSize(unifiedSearchDateRange, timeBuckets), + [unifiedSearchDateRange, timeBuckets] + ); + + return getAlertSummaryTimeRange( + unifiedSearchDateRange, + bucketSize?.intervalString ?? DEFAULT_INTERVAL, + bucketSize?.dateFormat ?? dateFormat ?? DEFAULT_DATE_FORMAT + ); +}; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 50b67fc5d0bb0..fabc5914087ff 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -70,6 +70,7 @@ export { observabilityFeatureId, observabilityAppId } from '../common'; export { useTimeBuckets } from './hooks/use_time_buckets'; export { createUseRulesLink } from './hooks/create_use_rules_link'; +export { useSummaryTimeRange } from './hooks/use_summary_time_range'; export { getApmTraceUrl } from './utils/get_apm_trace_url'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx index 68da793cf37b9..b880d93d63179 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.tsx @@ -34,7 +34,8 @@ export const AlertSummaryWidget = ({ timeRange, }); - if (isLoading) return ; + if (isLoading) + return ; if (error) return ; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_loader.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_loader.tsx index 146e79173fd93..67d98de4eff66 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_loader.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_loader.tsx @@ -6,22 +6,29 @@ */ import React from 'react'; -import { EuiLoadingChart } from '@elastic/eui'; +import { EuiLoadingChart, EuiLoadingSpinner } from '@elastic/eui'; import { AlertSummaryWidgetProps } from '..'; -type Props = Pick; +type Props = { isLoadingWithoutChart: boolean | undefined } & Pick< + AlertSummaryWidgetProps, + 'fullSize' +>; -export const AlertSummaryWidgetLoader = ({ fullSize }: Props) => { +export const AlertSummaryWidgetLoader = ({ fullSize, isLoadingWithoutChart }: Props) => { return (
- + {isLoadingWithoutChart ? ( + + ) : ( + + )}
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rule_alerts_summary.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_alerts_summary.tsx index ae7c8462320db..21763d1e5f0c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/get_rule_alerts_summary.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rule_alerts_summary.tsx @@ -15,7 +15,14 @@ const AlertSummaryWidgetLazy: React.FC = lazy( export const getAlertSummaryWidgetLazy = (props: AlertSummaryWidgetProps) => { return ( - }> + + } + > ); diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index 944e4e8b9c45e..a36dd236b78ad 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -295,6 +295,40 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHostsView.clickShowAllMetadataOverviewTab(); await pageObjects.header.waitUntilLoadingHasFinished(); await pageObjects.infraHostsView.metadataTableExist(); + await pageObjects.infraHostsView.clickOverviewFlyoutTab(); + }); + + it('should show alerts', async () => { + await pageObjects.header.waitUntilLoadingHasFinished(); + await pageObjects.infraHostsView.overviewAlertsTitleExist(); + }); + + it('should open alerts flyout', async () => { + await pageObjects.header.waitUntilLoadingHasFinished(); + await pageObjects.infraHostsView.clickOverviewOpenAlertsFlyout(); + // There are 2 flyouts open (asset details and alerts) + // so we need a stricter selector + // to be sure that we are closing the alerts flyout + const closeAlertFlyout = await find.byCssSelector( + '[aria-labelledby="flyoutRuleAddTitle"] > [data-test-subj="euiFlyoutCloseButton"]' + ); + await closeAlertFlyout.click(); + }); + + it('should navigate to alerts', async () => { + await pageObjects.infraHostsView.clickOverviewLinkToAlerts(); + await pageObjects.header.waitUntilLoadingHasFinished(); + const url = parse(await browser.getCurrentUrl()); + + const query = decodeURIComponent(url.query ?? ''); + + const alertsQuery = + "_a=(kuery:'host.name:\"Jennys-MBP.fritz.box\"',rangeFrom:'2023-03-28T18:20:00.000Z',rangeTo:'2023-03-28T18:21:00.000Z',status:all)"; + + expect(url.pathname).to.eql('/app/observability/alerts'); + expect(query).to.contain(alertsQuery); + + await returnTo(HOSTS_VIEW_PATH); }); }); @@ -479,6 +513,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + it('should render alerts count for a host inside a flyout', async () => { + await pageObjects.infraHostsView.clickHostCheckbox('demo-stack-mysql-01', '-'); + await pageObjects.infraHostsView.clickSelectedHostsButton(); + await pageObjects.infraHostsView.clickSelectedHostsAddFilterButton(); + await pageObjects.infraHostsView.clickTableOpenFlyoutButton(); + + const activeAlertsCount = await pageObjects.infraHostsView.getActiveAlertsCountText(); + const totalAlertsCount = await pageObjects.infraHostsView.getTotalAlertsCountText(); + + expect(activeAlertsCount).to.equal('2 '); + expect(totalAlertsCount).to.equal('3'); + + const deleteFilterButton = await find.byCssSelector( + `[title="Delete host.name: demo-stack-mysql-01"]` + ); + await deleteFilterButton.click(); + + await pageObjects.infraHostsView.clickCloseFlyoutButton(); + }); + it('should render "N/A" when processes summary is not available in flyout', async () => { await pageObjects.infraHostsView.clickTableOpenFlyoutButton(); await pageObjects.infraHostsView.clickProcessesFlyoutTab(); diff --git a/x-pack/test/functional/page_objects/infra_hosts_view.ts b/x-pack/test/functional/page_objects/infra_hosts_view.ts index 631b475197b1a..b56b57843cd3b 100644 --- a/x-pack/test/functional/page_objects/infra_hosts_view.ts +++ b/x-pack/test/functional/page_objects/infra_hosts_view.ts @@ -48,6 +48,14 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { return testSubjects.click('hostsView-flyout-tabs-metadata'); }, + async clickOverviewLinkToAlerts() { + return testSubjects.click('assetDetails-flyout-alerts-link'); + }, + + async clickOverviewOpenAlertsFlyout() { + return testSubjects.click('infraNodeContextPopoverCreateInventoryRuleButton'); + }, + async clickProcessesFlyoutTab() { return testSubjects.click('hostsView-flyout-tabs-processes'); }, @@ -203,6 +211,22 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { return div.getAttribute('title'); }, + overviewAlertsTitleExist() { + return testSubjects.exists('assetDetailsAlertsTitle'); + }, + + async getActiveAlertsCountText() { + const container = await testSubjects.find('activeAlertCount'); + const containerText = await container.getVisibleText(); + return containerText; + }, + + async getTotalAlertsCountText() { + const container = await testSubjects.find('totalAlertCount'); + const containerText = await container.getVisibleText(); + return containerText; + }, + async getAssetDetailsMetricsCharts() { const container = await testSubjects.find('assetDetailsMetricsChartGrid'); return container.findAllByCssSelector('[data-test-subj*="assetDetailsMetricsChart"]');