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


<img width="1616" alt="alerts_section"
src="399dd1ea-e1cb-4e7f-9ed5-917ced7cc490">

## 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](43a3c611-0404-4c21-a503-22f1a79dc1de)



![image](a870fa9b-5367-4303-9b7d-4da9ff2eae2b)


##  Storybook
I added some changes to make the alerts widget show in the storybook
[[Workaround for
storybook](d97a2b1736)]

<img width="1905" alt="image"
src="539c9443-f977-4301-8d2b-d24f1d01b44e">
 
## 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


b362042a-b9de-460c-86ae-282154b586ff

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
jennypavlova 2023-07-18 19:21:37 +02:00 committed by GitHub
parent b222f7a7d1
commit 5a7f395003
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 612 additions and 156 deletions

View file

@ -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(
() =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AlertSummaryWidgetProps, string | JSXElementConstructor<any>>,
},
charts: {
theme: {
useChartsTheme: () => ({} as Theme),
useChartsBaseTheme: () => ({} as Theme),
},
},
settings: {
client: {
get$: (key: string) => of(getSettings(key)),

View file

@ -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<HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
};
return (
<EuiText size="xs" onClick={onClick}>
<p>
<FormattedMessage
id="xpack.infra.assetDetails.alerts.tooltip.alertsLabel"
defaultMessage="Showing alerts for this host. You can create and manage alerts in {alerts}"
values={{
alerts: <LinkToAlertsHomePage />,
}}
/>
</p>
<p>
<FormattedMessage
id="xpack.infra.assetDetails.alerts.tooltip.documentationLabel"
defaultMessage="See {documentation} for more information"
values={{
documentation: (
<EuiLink
data-test-subj="assetDetailsTooltipDocumentationLink"
href={ALERTS_DOC_HREF}
target="_blank"
>
<FormattedMessage
id="xpack.infra.assetDetails.alerts.tooltip.documentationLink"
defaultMessage="documentation"
/>
</EuiLink>
),
}}
/>
</p>
</EuiText>
);
});

View file

@ -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 (
<EuiButtonEmpty
data-test-subj="infraNodeContextPopoverCreateInventoryRuleButton"
@ -23,8 +23,8 @@ export const LinkToAlertsRule = ({ onClick }: LinkToAlertsRule) => {
iconType="bell"
>
<FormattedMessage
id="xpack.infra.infra.nodeDetails.createAlertLink"
defaultMessage="Create inventory rule"
id="xpack.infra.infra.assetDetails.alerts.createAlertLink"
defaultMessage="Create rule"
/>
</EuiButtonEmpty>
);

View file

@ -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 (
<RedirectAppLinks coreStart={services}>
<EuiButtonEmpty
data-test-subj="assetDetails-flyout-alerts-link"
size="xs"
iconSide="right"
iconType="sortRight"
flush="both"
href={linkToAlertsPage}
>
<FormattedMessage
id="xpack.infra.assetDetails.flyout.AlertsPageLinkLabel"
defaultMessage="Show all"
/>
</EuiButtonEmpty>
</RedirectAppLinks>
);
};
export const LinkToAlertsHomePage = () => {
const { services } = useKibanaContextForPlugin();
const { http } = services;
const linkToAlertsPage = http.basePath.prepend(ALERTS_PATH);
return (
<RedirectAppLinks coreStart={services}>
<EuiLink data-test-subj="assetDetailsTooltipDocumentationLink" href={linkToAlertsPage}>
<FormattedMessage
id="xpack.infra.assetDetails.table.tooltip.alertsLink"
defaultMessage="alerts."
/>
</EuiLink>
</RedirectAppLinks>
);
};

View file

@ -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 (
<>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<AlertsSectionTitle />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LinkToAlertsRule onClick={toggleAlertFlyout} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LinkToAlertsPage
nodeName={nodeName}
queryField={`${nodeType}.name`}
dateRange={dateRange}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<MemoAlertSummaryWidget alertsQuery={alertsEsQueryByStatus} dateRange={dateRange} />
<AlertFlyout
filter={`${findInventoryFields(nodeType).name}: "${nodeName}"`}
nodeType={nodeType}
setVisible={toggleAlertFlyout}
visible={isAlertFlyoutVisible}
/>
</>
);
};
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 (
<AlertSummaryWidget
chartProps={chartProps}
featureIds={infraAlertFeatureIds}
filter={alertsQuery}
timeRange={summaryTimeRange}
fullSize
hideChart
/>
);
}
);
const AlertsSectionTitle = () => {
const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false);
return (
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle data-test-subj="assetDetailsAlertsTitle" size="xxs">
<h5>
<FormattedMessage
id="xpack.infra.assetDetails.overview.alertsSectionTitle"
defaultMessage="Alerts"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiIcon
data-test-subj="assetDetailsAlertsPopoverButton"
type="iInCircle"
onClick={togglePopover}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
repositionOnScroll
anchorPosition="upCenter"
>
<AlertsTooltipContent />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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 = ({
)}
<SectionSeparator />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertsSummaryContent nodeName={nodeName} nodeType={nodeType} dateRange={dateRange} />
<SectionSeparator />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MetricsGrid
timeRange={dateRange}

View file

@ -8,8 +8,12 @@
import { EuiButtonGroup, EuiButtonGroupOptionProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ACTIVE_ALERTS, ALL_ALERTS, RECOVERED_ALERTS } from '../../../constants';
import { AlertStatus } from '../../../types';
import type { AlertStatus } from '@kbn/observability-plugin/common/typings';
import {
ACTIVE_ALERTS,
ALL_ALERTS,
RECOVERED_ALERTS,
} from '../../../../../../common/alerts/constants';
export interface AlertStatusFilterProps {
status: AlertStatus;
onChange: (id: AlertStatus) => void;

View file

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

View file

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

View file

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

View file

@ -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<AlertStatus>('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;

View file

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

View file

@ -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<string>('dateFormat');
const bucketSize = useMemo(
() => calculateTimeRangeBucketSize(unifiedSearchDateRange, timeBuckets),
[unifiedSearchDateRange, timeBuckets]
);
return getAlertSummaryTimeRange(
unifiedSearchDateRange,
bucketSize?.intervalString ?? DEFAULT_INTERVAL,
bucketSize?.dateFormat ?? dateFormat ?? DEFAULT_DATE_FORMAT
);
};

View file

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

View file

@ -34,7 +34,8 @@ export const AlertSummaryWidget = ({
timeRange,
});
if (isLoading) return <AlertSummaryWidgetLoader fullSize={fullSize} />;
if (isLoading)
return <AlertSummaryWidgetLoader fullSize={fullSize} isLoadingWithoutChart={hideChart} />;
if (error) return <AlertSummaryWidgetError />;

View file

@ -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<AlertSummaryWidgetProps, 'fullSize'>;
type Props = { isLoadingWithoutChart: boolean | undefined } & Pick<
AlertSummaryWidgetProps,
'fullSize'
>;
export const AlertSummaryWidgetLoader = ({ fullSize }: Props) => {
export const AlertSummaryWidgetLoader = ({ fullSize, isLoadingWithoutChart }: Props) => {
return (
<div
style={{
minHeight: fullSize ? 238 : 224,
minHeight: isLoadingWithoutChart ? 44 : fullSize ? 238 : 224,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
justifyContent: isLoadingWithoutChart ? 'flex-start' : 'center',
}}
>
<EuiLoadingChart size="l" data-test-subj="alertSummaryWidgetLoading" />
{isLoadingWithoutChart ? (
<EuiLoadingSpinner size="m" />
) : (
<EuiLoadingChart size="l" data-test-subj="alertSummaryWidgetLoading" />
)}
</div>
);
};

View file

@ -15,7 +15,14 @@ const AlertSummaryWidgetLazy: React.FC<AlertSummaryWidgetProps> = lazy(
export const getAlertSummaryWidgetLazy = (props: AlertSummaryWidgetProps) => {
return (
<Suspense fallback={<AlertSummaryWidgetLoader fullSize={props.fullSize} />}>
<Suspense
fallback={
<AlertSummaryWidgetLoader
fullSize={props.fullSize}
isLoadingWithoutChart={props.hideChart}
/>
}
>
<AlertSummaryWidgetLazy {...props} />
</Suspense>
);

View file

@ -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();

View file

@ -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"]');