[Infrastructure UI] Add Alerts tab into Hosts View (#149579)

## 📓 Summary

Closes [#917](https://github.com/elastic/obs-infraobs-team/issues/917)

This PR integrates the AlertsSummary and AlertsTable components into the
Hosts view, reusing most of the code from the `observability` plugin.
The integration of these components will provide a comprehensive
overview of alerts related to hosts in the Hosts view.

By default, all the Inventory alerts will be shown, but based on the
applied query, filters, and time range, the alerts tab will only show
alerts related to the filtered hosts.

This implementation required some work on the `rule_registry` plugin,
with the implementation of a new `GET _alerts_count` endpoint that will
serve the count of the active/recovered alerts. This endpoint is
implemented there since most of the logic regarding alerts was already
defined in the
[alerts_client.ts](43ea5e356e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts)
file.

## 👨‍💻 Implementation details

- Since the mappings for the alerts summary and table doesn't match the
alerts one, we have to manually build a query filter for the alerts
starting from the `snapshot` API response with the returned hosts. This
is not optimal since it requires to sequentially trigger the Hosts
metrics and the Alerts request and could generate performance issues
when querying a large dataset, so we'll work out on optimizations with
another epic.
- The tabs rendering logic has been switched to a controlled rendering,
removing the existing usage of `EuiTabbedContent` in favour of
`EuiTabs`+`EuiTab`.
To avoid heavy re-renders and repeated requests, the most performing
approach of implementing the tabs content results in keeping in memory
into the DOM the already visited tabs, giving a better responsiveness
while navigating between tabs.
The rendering flow is:
- Render on DOM the first tab content, don't mount the others (prevent
API and additional runtime on tabs that won't possibly be opened)
- If a new tab is clicked, render its content and **hide** the other tab
content, still keeping it in the DOM.
- In case of switching back to another tab, hide all the others and make
visible only the currently selected.
- A new `useAlertsCount` hook is introduced to handle the
`_alerts_count` data fetching.
- A new `useAlertsQuery` hook is introduced to generate the different
queries used to fetch the alerts count and the alerts summary/table.
- The `useHostViewContext` hook has been refactored to have a simpler
API.

## 🚧 Next steps
- [[Infrastructure UI] Store selected tab as url state in Hosts
View#150856](https://github.com/elastic/kibana/issues/150856)
- [[Infrastructure UI] Add functional tests for Hosts Alerts
tab#150741](https://github.com/elastic/kibana/issues/150741)

## 🧪 Testing

Navigate to the Hosts View page, scroll down to the tabs section and
play with the Alerts tab.


https://user-images.githubusercontent.com/34506779/217485112-711fec51-6372-4def-90dc-887f9a9cc64e.mp4

---------

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Antonio Ghiani 2023-02-22 15:39:14 +01:00 committed by GitHub
parent f562b3c289
commit 7be9b50194
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 831 additions and 140 deletions

View file

@ -12,19 +12,23 @@
"infra"
],
"requiredPlugins": [
"share",
"features",
"usageCollection",
"embeddable",
"alerting",
"cases",
"charts",
"data",
"dataViews",
"visTypeTimeseries",
"alerting",
"embeddable",
"features",
"lens",
"triggersActionsUi",
"observability",
"ruleRegistry",
"unifiedSearch"
"security",
"share",
"spaces",
"triggersActionsUi",
"unifiedSearch",
"usageCollection",
"visTypeTimeseries",
],
"optionalPlugins": [
"spaces",

View file

@ -0,0 +1,26 @@
/*
* 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, { useLayoutEffect, useRef } from 'react';
export function HeightRetainer(
props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
) {
const containerElement = useRef<HTMLDivElement>(null);
const minHeight = useRef<number>(0);
useLayoutEffect(() => {
if (containerElement.current) {
const currentHeight = containerElement.current.clientHeight;
if (minHeight.current < currentHeight) {
minHeight.current = currentHeight;
}
}
});
return <div {...props} ref={containerElement} style={{ minHeight: minHeight.current }} />;
}

View file

@ -0,0 +1,127 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { ALERT_STATUS, ValidFeatureId } from '@kbn/rule-data-utils';
import { useAlertsCount } from './use_alerts_count';
import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public';
import { InfraClientStartDeps } from '../types';
import { coreMock } from '@kbn/core/public/mocks';
import { CoreStart } from '@kbn/core/public';
const mockedAlertsCountResponse = {
aggregations: {
count: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'active',
doc_count: 2,
},
{
key: 'recovered',
doc_count: 20,
},
],
},
},
};
const expectedResult = {
activeAlertCount: 2,
recoveredAlertCount: 20,
};
jest.mock('@kbn/kibana-react-plugin/public');
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
const mockedPostAPI = jest.fn();
const mockUseKibana = () => {
useKibanaMock.mockReturnValue({
services: {
...coreMock.createStart(),
http: { post: mockedPostAPI },
},
} as unknown as KibanaReactContextValue<Partial<CoreStart> & Partial<InfraClientStartDeps>>);
};
describe('useAlertsCount', () => {
const featureIds: ValidFeatureId[] = ['infrastructure'];
beforeAll(() => {
mockUseKibana();
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the mocked data from API', async () => {
mockedPostAPI.mockResolvedValue(mockedAlertsCountResponse);
const { result, waitForNextUpdate } = renderHook(() => useAlertsCount({ featureIds }));
expect(result.current.loading).toBe(true);
expect(result.current.alertsCount).toEqual(undefined);
await waitForNextUpdate();
const { alertsCount, loading, error } = result.current;
expect(alertsCount).toEqual(expectedResult);
expect(loading).toBeFalsy();
expect(error).toBeFalsy();
});
it('should call API with correct input', async () => {
const ruleId = 'c95bc120-1d56-11ed-9cc7-e7214ada1128';
const query = {
term: {
'kibana.alert.rule.uuid': ruleId,
},
};
mockedPostAPI.mockResolvedValue(mockedAlertsCountResponse);
const { waitForNextUpdate } = renderHook(() =>
useAlertsCount({
featureIds,
query,
})
);
await waitForNextUpdate();
const body = JSON.stringify({
aggs: {
count: {
terms: { field: ALERT_STATUS },
},
},
feature_ids: featureIds,
query,
size: 0,
});
expect(mockedPostAPI).toHaveBeenCalledWith(
'/internal/rac/alerts/find',
expect.objectContaining({ body })
);
});
it('should return error if API call fails', async () => {
const error = new Error('Fetch Alerts Count Failed');
mockedPostAPI.mockRejectedValueOnce(error);
const { result, waitForNextUpdate } = renderHook(() => useAlertsCount({ featureIds }));
await waitForNextUpdate();
expect(result.current.error?.message).toMatch(error.message);
});
});

View file

@ -0,0 +1,112 @@
/*
* 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 { useEffect, useRef } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants';
import { estypes } from '@elastic/elasticsearch';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { HttpSetup } from '@kbn/core/public';
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, ValidFeatureId } from '@kbn/rule-data-utils';
import { InfraClientCoreStart } from '../types';
interface UseAlertsCountProps {
featureIds: ValidFeatureId[];
query?: estypes.QueryDslQueryContainer;
}
interface FetchAlertsCountParams {
featureIds: ValidFeatureId[];
query?: estypes.QueryDslQueryContainer;
http: HttpSetup;
signal: AbortSignal;
}
interface AlertsCount {
activeAlertCount: number;
recoveredAlertCount: number;
}
const ALERT_STATUS = 'kibana.alert.status';
export function useAlertsCount({ featureIds, query }: UseAlertsCountProps) {
const { http } = useKibana<InfraClientCoreStart>().services;
const abortCtrlRef = useRef(new AbortController());
const [state, refetch] = useAsyncFn(
() => {
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
return fetchAlertsCount({
featureIds,
query,
http,
signal: abortCtrlRef.current.signal,
});
},
[featureIds, query, http],
{ loading: true }
);
useEffect(() => {
refetch();
}, [refetch]);
const { value: alertsCount, error, loading } = state;
return {
alertsCount,
error,
loading,
refetch,
};
}
async function fetchAlertsCount({
featureIds,
http,
query,
signal,
}: FetchAlertsCountParams): Promise<AlertsCount> {
return http
.post<estypes.SearchResponse<Record<string, unknown>>>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
signal,
body: JSON.stringify({
aggs: {
count: {
terms: { field: ALERT_STATUS },
},
},
feature_ids: featureIds,
query,
size: 0,
}),
})
.then(extractAlertsCount);
}
const extractAlertsCount = (response: estypes.SearchResponse<Record<string, unknown>>) => {
const countAggs = response.aggregations?.count as estypes.AggregationsMultiBucketAggregateBase;
const countBuckets = (countAggs?.buckets as estypes.AggregationsStringTermsBucketKeys[]) ?? [];
return countBuckets.reduce(
(counts, bucket) => {
if (bucket.key === ALERT_STATUS_ACTIVE) {
counts.activeAlertCount = bucket.doc_count;
} else if (bucket.key === ALERT_STATUS_RECOVERED) {
counts.recoveredAlertCount = bucket.doc_count;
}
return counts;
},
{ activeAlertCount: 0, recoveredAlertCount: 0 }
);
};

View file

@ -15,6 +15,7 @@ import { HostsTable } from './hosts_table';
import { HostsViewProvider } from '../hooks/use_hosts_view';
import { KPICharts } from './kpi_charts/kpi_charts';
import { Tabs } from './tabs/tabs';
import { AlertsQueryProvider } from '../hooks/use_alerts_query';
export const HostContainer = () => {
const { metricsDataView, isDataViewLoading, hasFailedLoadingDataView } =
@ -38,14 +39,16 @@ export const HostContainer = () => {
<EuiSpacer />
<HostsViewProvider>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFlexItem grow={false}>
<KPICharts />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<HostsTable />
</EuiFlexItem>
<EuiFlexItem>
<Tabs />
<EuiFlexItem grow={false}>
<AlertsQueryProvider>
<Tabs />
</AlertsQueryProvider>
</EuiFlexItem>
</EuiFlexGroup>
</HostsViewProvider>

View file

@ -5,59 +5,23 @@
* 2.0.
*/
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { EuiInMemoryTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEqual } from 'lodash';
import { NoData } from '../../../../components/empty_states';
import { InfraLoadingPanel } from '../../../../components/loading';
import { useHostsTable } from '../hooks/use_hosts_table';
import { useSnapshot } from '../../inventory_view/hooks/use_snaphot';
import type { SnapshotMetricType } from '../../../../../common/inventory_models/types';
import { useTableProperties } from '../hooks/use_table_properties_url_state';
import { useHostsViewContext } from '../hooks/use_hosts_view';
import { useUnifiedSearchContext } from '../hooks/use_unified_search';
const HOST_TABLE_METRICS: Array<{ type: SnapshotMetricType }> = [
{ type: 'rx' },
{ type: 'tx' },
{ type: 'memory' },
{ type: 'cpu' },
{ type: 'diskLatency' },
{ type: 'memoryTotal' },
];
export const HostsTable = () => {
const { baseRequest, setHostViewState, hostViewState } = useHostsViewContext();
const { hostNodes, loading } = useHostsViewContext();
const { onSubmit, unifiedSearchDateRange } = useUnifiedSearchContext();
const [properties, setProperties] = useTableProperties();
// Snapshot endpoint internally uses the indices stored in source.configuration.metricAlias.
// For the Unified Search, we create a data view, which for now will be built off of source.configuration.metricAlias too
// if we introduce data view selection, we'll have to change this hook and the endpoint to accept a new parameter for the indices
const { loading, nodes, error } = useSnapshot({
...baseRequest,
metrics: HOST_TABLE_METRICS,
});
const { columns, items } = useHostsTable(nodes, { time: unifiedSearchDateRange });
useEffect(() => {
if (hostViewState.loading !== loading || nodes.length !== hostViewState.totalHits) {
setHostViewState({
loading,
totalHits: nodes.length,
error,
});
}
}, [
error,
hostViewState.loading,
hostViewState.totalHits,
loading,
nodes.length,
setHostViewState,
]);
const { columns, items } = useHostsTable(hostNodes, { time: unifiedSearchDateRange });
const noData = items.length === 0;

View file

@ -10,15 +10,15 @@ import { useHostsViewContext } from '../../hooks/use_hosts_view';
import { type ChartBaseProps, KPIChart } from './kpi_chart';
export const HostsTile = ({ type, ...props }: ChartBaseProps) => {
const { hostViewState } = useHostsViewContext();
const { hostNodes, loading } = useHostsViewContext();
return (
<KPIChart
id={`$metric-${type}`}
type={type}
nodes={[]}
loading={hostViewState.loading}
overrideValue={hostViewState?.totalHits}
loading={loading}
overrideValue={hostNodes?.length}
{...props}
/>
);

View file

@ -0,0 +1,54 @@
/*
* 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 { 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';
export interface AlertStatusFilterProps {
status: AlertStatus;
onChange: (id: AlertStatus) => void;
}
const options: EuiButtonGroupOptionProps[] = [
{
id: ALL_ALERTS.status,
label: ALL_ALERTS.label,
value: ALL_ALERTS.query,
'data-test-subj': 'hostsView-alert-status-filter-show-all-button',
},
{
id: ACTIVE_ALERTS.status,
label: ACTIVE_ALERTS.label,
value: ACTIVE_ALERTS.query,
'data-test-subj': 'hostsView-alert-status-filter-active-button',
},
{
id: RECOVERED_ALERTS.status,
label: RECOVERED_ALERTS.label,
value: RECOVERED_ALERTS.query,
'data-test-subj': 'hostsView-alert-status-filter-recovered-button',
},
];
export function AlertsStatusFilter({ status, onChange }: AlertStatusFilterProps) {
return (
<EuiButtonGroup
legend={i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.legend', {
defaultMessage: 'Filter by',
})}
color="primary"
options={options}
idSelected={status}
onChange={(id) => onChange(id as AlertStatus)}
/>
);
}
// eslint-disable-next-line import/no-default-export
export default AlertsStatusFilter;

View file

@ -0,0 +1,115 @@
/*
* 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 } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
calculateTimeRangeBucketSize,
getAlertSummaryTimeRange,
useTimeBuckets,
} from '@kbn/observability-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { TimeRange } from '@kbn/es-query';
import { HeightRetainer } from '../../../../../../components/height_retainer';
import type { InfraClientCoreStart, InfraClientStartDeps } from '../../../../../../types';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
import {
ALERTS_PER_PAGE,
ALERTS_TABLE_ID,
casesFeatures,
casesOwner,
DEFAULT_DATE_FORMAT,
DEFAULT_INTERVAL,
infraAlertFeatureIds,
} from '../config';
import { useAlertsQuery } from '../../../hooks/use_alerts_query';
import AlertsStatusFilter from './alerts_status_filter';
export const AlertsTabContent = React.memo(() => {
const { services } = useKibana<InfraClientCoreStart & InfraClientStartDeps>();
const { alertStatus, setAlertStatus, alertsEsQueryByStatus } = useAlertsQuery();
const { unifiedSearchDateRange } = useUnifiedSearchContext();
const summaryTimeRange = useSummaryTimeRange(unifiedSearchDateRange);
const { application, cases, charts, triggersActionsUi } = services;
const {
alertsTableConfigurationRegistry,
getAlertsStateTable: AlertsStateTable,
getAlertSummaryWidget: AlertSummaryWidget,
} = triggersActionsUi;
const CasesContext = cases.ui.getCasesContext();
const uiCapabilities = application?.capabilities;
const casesCapabilities = cases.helpers.getUICapabilities(uiCapabilities.observabilityCases);
const chartThemes = {
theme: charts.theme.useChartsTheme(),
baseTheme: charts.theme.useChartsBaseTheme(),
};
return (
<HeightRetainer>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<AlertsStatusFilter onChange={setAlertStatus} status={alertStatus} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<AlertSummaryWidget
chartThemes={chartThemes}
featureIds={infraAlertFeatureIds}
filter={alertsEsQueryByStatus}
fullSize
timeRange={summaryTimeRange}
/>
</EuiFlexItem>
{alertsEsQueryByStatus && (
<EuiFlexItem>
<CasesContext
features={casesFeatures}
owner={casesOwner}
permissions={casesCapabilities}
>
<AlertsStateTable
alertsTableConfigurationRegistry={alertsTableConfigurationRegistry}
configurationId={AlertConsumers.OBSERVABILITY}
featureIds={infraAlertFeatureIds}
flyoutSize="s"
id={ALERTS_TABLE_ID}
pageSize={ALERTS_PER_PAGE}
query={alertsEsQueryByStatus}
showAlertStatusWithFlapping
showExpandToDetails={false}
/>
</CasesContext>
</EuiFlexItem>
)}
</EuiFlexGroup>
</HeightRetainer>
);
});
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

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './alerts_tab_content';

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiIcon, EuiLoadingSpinner, EuiNotificationBadge, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useAlertsCount } from '../../../../../hooks/use_alerts_count';
import { infraAlertFeatureIds } from './config';
import { useAlertsQuery } from '../../hooks/use_alerts_query';
export const AlertsTabBadge = () => {
const { alertsEsQuery } = useAlertsQuery();
const { alertsCount, loading, error } = useAlertsCount({
featureIds: infraAlertFeatureIds,
query: alertsEsQuery,
});
if (loading) {
return <EuiLoadingSpinner />;
}
if (error) {
return (
<EuiToolTip
content={i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.countError', {
defaultMessage:
'The active alert count was not retrieved correctly, try reloading the page.',
})}
>
<EuiIcon color="warning" type="alert" />
</EuiToolTip>
);
}
return (
<EuiNotificationBadge className="eui-alignCenter" size="m">
{alertsCount?.activeAlertCount}
</EuiNotificationBadge>
);
};

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AlertConsumers } from '@kbn/rule-data-utils';
import type { ValidFeatureId } from '@kbn/rule-data-utils';
export const ALERTS_PER_PAGE = 10;
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 casesFeatures = { alerts: { sync: false } };
export const casesOwner = [INFRA_ALERT_FEATURE_ID];
export const DEFAULT_INTERVAL = '60s';
export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm';

View file

@ -57,7 +57,7 @@ const CHARTS_IN_ORDER: Array<Pick<MetricChartProps, 'title' | 'type'> & { fullRo
},
];
export const MetricsGrid = () => {
export const MetricsGrid = React.memo(() => {
return (
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="hostsView-metricChart">
<EuiFlexItem>
@ -79,7 +79,7 @@ export const MetricsGrid = () => {
<EuiFlexItem>
<EuiFlexGrid columns={2} gutterSize="s">
{CHARTS_IN_ORDER.map(({ fullRow, ...chartProp }) => (
<EuiFlexItem style={fullRow ? { gridColumn: '1/-1' } : {}}>
<EuiFlexItem key={chartProp.type} style={fullRow ? { gridColumn: '1/-1' } : {}}>
<MetricChart breakdownSize={DEFAULT_BREAKDOWN_SIZE} {...chartProp} />
</EuiFlexItem>
))}
@ -87,4 +87,4 @@ export const MetricsGrid = () => {
</EuiFlexItem>
</EuiFlexGroup>
);
};
});

View file

@ -5,37 +5,71 @@
* 2.0.
*/
import React from 'react';
import { EuiTabbedContent, EuiSpacer, type EuiTabbedContentTab } from '@elastic/eui';
import React, { useRef, useState } from 'react';
import { EuiTabs, EuiTab, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { MetricsGrid } from './metrics/metrics_grid';
import { AlertsTabContent } from './alerts';
import { AlertsTabBadge } from './alerts_tab_badge';
import { TabIds } from '../../types';
const tabs = [
{
id: TabIds.METRICS,
name: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.title', {
defaultMessage: 'Metrics',
}),
'data-test-subj': 'hostsView-tabs-metrics',
},
{
id: TabIds.ALERTS,
name: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.title', {
defaultMessage: 'Alerts',
}),
append: <AlertsTabBadge />,
'data-test-subj': 'hostsView-tabs-alerts',
},
];
const initialRenderedTabsSet = new Set([tabs[0].id]);
export const Tabs = () => {
// This map allow to keep track of which tabs content have been rendered the first time.
// We need it in order to load a tab content only if it gets clicked, and then keep it in the DOM for performance improvement.
const renderedTabsSet = useRef(initialRenderedTabsSet);
const [selectedTabId, setSelectedTabId] = useState(tabs[0].id);
const tabEntries = tabs.map((tab, index) => (
<EuiTab
{...tab}
key={index}
onClick={() => {
renderedTabsSet.current.add(tab.id); // On a tab click, mark the tab content as allowed to be rendered
setSelectedTabId(tab.id);
}}
isSelected={tab.id === selectedTabId}
append={tab.append}
>
{tab.name}
</EuiTab>
));
interface WrapperProps {
children: React.ReactElement;
}
const Wrapper = ({ children }: WrapperProps) => {
return (
<>
<EuiSpacer size="s" />
{children}
<EuiTabs>{tabEntries}</EuiTabs>
<EuiSpacer />
{renderedTabsSet.current.has(TabIds.METRICS) && (
<div hidden={selectedTabId !== TabIds.METRICS}>
<MetricsGrid />
</div>
)}
{renderedTabsSet.current.has(TabIds.ALERTS) && (
<div hidden={selectedTabId !== TabIds.ALERTS}>
<AlertsTabContent />
</div>
)}
</>
);
};
export const Tabs = () => {
const tabs: EuiTabbedContentTab[] = [
{
id: 'metrics',
name: i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.title', {
defaultMessage: 'Metrics',
}),
'data-test-subj': 'hostsView-tabs-metrics',
content: (
<Wrapper>
<MetricsGrid />
</Wrapper>
),
},
];
return <EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} autoFocus="selected" />;
};

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 { i18n } from '@kbn/i18n';
import { ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils';
import { AlertStatusFilter } from './types';
export const ALERT_STATUS_ALL = 'all';
export const ALL_ALERTS: AlertStatusFilter = {
status: ALERT_STATUS_ALL,
query: '',
label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.showAll', {
defaultMessage: 'Show all',
}),
};
export const ACTIVE_ALERTS: AlertStatusFilter = {
status: ALERT_STATUS_ACTIVE,
query: `${ALERT_STATUS}: "${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: `${ALERT_STATUS}: "${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,
};

View file

@ -0,0 +1,88 @@
/*
* 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 { useCallback, useMemo, useState } from 'react';
import createContainer from 'constate';
import { getTime } from '@kbn/data-plugin/common';
import { TIMESTAMP } from '@kbn/rule-data-utils';
import { buildEsQuery, Filter, Query } from '@kbn/es-query';
import { SnapshotNode } from '../../../../../common/http_api';
import { useUnifiedSearchContext } from './use_unified_search';
import { HostsState } from './use_unified_search_url_state';
import { useHostsView } from './use_hosts_view';
import { AlertStatus } from '../types';
import { ALERT_STATUS_QUERY } from '../constants';
export const useAlertsQueryImpl = () => {
const { hostNodes } = useHostsView();
const { unifiedSearchDateRange } = useUnifiedSearchContext();
const [alertStatus, setAlertStatus] = useState<AlertStatus>('all');
const getAlertsEsQuery = useCallback(
(status?: AlertStatus) =>
createAlertsEsQuery({ dateRange: unifiedSearchDateRange, hostNodes, status }),
[hostNodes, unifiedSearchDateRange]
);
// Regenerate the query when status change even if is not used.
// eslint-disable-next-line react-hooks/exhaustive-deps
const alertsEsQuery = useMemo(() => getAlertsEsQuery(), [getAlertsEsQuery, alertStatus]);
const alertsEsQueryByStatus = useMemo(
() => getAlertsEsQuery(alertStatus),
[getAlertsEsQuery, alertStatus]
);
return {
alertStatus,
setAlertStatus,
alertsEsQuery,
alertsEsQueryByStatus,
};
};
export const AlertsQueryContainer = createContainer(useAlertsQueryImpl);
export const [AlertsQueryProvider, useAlertsQuery] = AlertsQueryContainer;
/**
* Helpers
*/
const createAlertsEsQuery = ({
dateRange,
hostNodes,
status,
}: {
dateRange: HostsState['dateRange'];
hostNodes: SnapshotNode[];
status?: AlertStatus;
}) => {
const alertStatusQuery = createAlertStatusQuery(status);
const dateFilter = createDateFilter(dateRange);
const hostsFilter = createHostsFilter(hostNodes);
const queries = alertStatusQuery ? [alertStatusQuery] : [];
const filters = [hostsFilter, dateFilter].filter(Boolean) as Filter[];
return buildEsQuery(undefined, queries, filters);
};
const createDateFilter = (date: HostsState['dateRange']) =>
getTime(undefined, date, { fieldName: TIMESTAMP });
const createAlertStatusQuery = (status: AlertStatus = 'all'): Query | null =>
ALERT_STATUS_QUERY[status] ? { query: ALERT_STATUS_QUERY[status], language: 'kuery' } : null;
const createHostsFilter = (hosts: SnapshotNode[]): Filter => ({
query: {
terms: {
'host.name': hosts.map((p) => p.name),
},
},
meta: {},
});

View file

@ -12,61 +12,85 @@
* 2.0.
*/
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import createContainer from 'constate';
import { BoolQuery } from '@kbn/es-query';
import { SnapshotMetricType } from '../../../../../common/inventory_models/types';
import { useSourceContext } from '../../../../containers/metrics_source';
import type { UseSnapshotRequest } from '../../inventory_view/hooks/use_snaphot';
import { useSnapshot, type UseSnapshotRequest } from '../../inventory_view/hooks/use_snaphot';
import { useUnifiedSearchContext } from './use_unified_search';
import { StringDateRangeTimestamp } from './use_unified_search_url_state';
export interface HostViewState {
totalHits: number;
loading: boolean;
error: string | null;
}
export const INITAL_VALUE = {
error: null,
loading: true,
totalHits: 0,
};
const HOST_TABLE_METRICS: Array<{ type: SnapshotMetricType }> = [
{ type: 'rx' },
{ type: 'tx' },
{ type: 'memory' },
{ type: 'cpu' },
{ type: 'diskLatency' },
{ type: 'memoryTotal' },
];
export const useHostsView = () => {
const { sourceId } = useSourceContext();
const { buildQuery, getDateRangeAsTimestamp } = useUnifiedSearchContext();
const [hostViewState, setHostViewState] = useState<HostViewState>(INITAL_VALUE);
const baseRequest = useMemo(() => {
const esQuery = buildQuery();
const { from, to } = getDateRangeAsTimestamp();
const baseRequest = useMemo(
() =>
createSnapshotRequest({
dateRange: getDateRangeAsTimestamp(),
esQuery: buildQuery(),
sourceId,
}),
[buildQuery, getDateRangeAsTimestamp, sourceId]
);
const snapshotRequest: UseSnapshotRequest = {
filterQuery: esQuery ? JSON.stringify(esQuery) : null,
metrics: [],
groupBy: [],
nodeType: 'host',
sourceId,
currentTime: to,
includeTimeseries: false,
sendRequestImmediately: true,
timerange: {
interval: '1m',
from,
to,
ignoreLookback: true,
},
// The user might want to click on the submit button without changing the filters
// This makes sure all child components will re-render.
requestTs: Date.now(),
};
return snapshotRequest;
}, [buildQuery, getDateRangeAsTimestamp, sourceId]);
// Snapshot endpoint internally uses the indices stored in source.configuration.metricAlias.
// For the Unified Search, we create a data view, which for now will be built off of source.configuration.metricAlias too
// if we introduce data view selection, we'll have to change this hook and the endpoint to accept a new parameter for the indices
const {
loading,
error,
nodes: hostNodes,
} = useSnapshot({ ...baseRequest, metrics: HOST_TABLE_METRICS });
return {
baseRequest,
hostViewState,
setHostViewState,
loading,
error,
hostNodes,
};
};
export const HostsView = createContainer(useHostsView);
export const [HostsViewProvider, useHostsViewContext] = HostsView;
/**
* Helpers
*/
const createSnapshotRequest = ({
esQuery,
sourceId,
dateRange,
}: {
esQuery: { bool: BoolQuery } | null;
sourceId: string;
dateRange: StringDateRangeTimestamp;
}): UseSnapshotRequest => ({
filterQuery: esQuery ? JSON.stringify(esQuery) : null,
metrics: [],
groupBy: [],
nodeType: 'host',
sourceId,
currentTime: dateRange.to,
includeTimeseries: false,
sendRequestImmediately: true,
timerange: {
interval: '1m',
from: dateRange.from,
to: dateRange.to,
ignoreLookback: true,
},
// The user might want to click on the submit button without changing the filters
// This makes sure all child components will re-render.
requestTs: Date.now(),
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils';
import { ALERT_STATUS_ALL } from './constants';
export enum TabIds {
ALERTS = 'alerts',
METRICS = 'metrics',
}
export type AlertStatus =
| typeof ALERT_STATUS_ACTIVE
| typeof ALERT_STATUS_RECOVERED
| typeof ALERT_STATUS_ALL;
export interface AlertStatusFilter {
status: AlertStatus;
query: string;
label: string;
}

View file

@ -30,6 +30,8 @@ import type {
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { type TypedLensByValueInput, LensPublicStart } from '@kbn/lens-plugin/public';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import { CasesUiStart } from '@kbn/cases-plugin/public';
import type { UnwrapPromise } from '../common/utility_types';
import type {
SourceProviderProps,
@ -67,19 +69,21 @@ export interface InfraClientSetupDeps {
}
export interface InfraClientStartDeps {
cases: CasesUiStart;
charts: ChartsPluginStart;
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
observability: ObservabilityPublicStart;
spaces?: SpacesPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
usageCollection: UsageCollectionStart;
ml: MlPluginStart;
embeddable?: EmbeddableStart;
lens: LensPublicStart;
ml: MlPluginStart;
observability: ObservabilityPublicStart;
osquery?: unknown; // OsqueryPluginStart;
share: SharePluginStart;
spaces: SpacesPluginStart;
storage: IStorageWrapper;
lens: LensPublicStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
usageCollection: UsageCollectionStart;
telemetry: ITelemetryClient;
}

View file

@ -53,6 +53,7 @@
"@kbn/core-saved-objects-common",
"@kbn/core-analytics-server",
"@kbn/analytics-client",
"@kbn/cases-plugin",
"@kbn/shared-ux-router"
],
"exclude": ["target/**/*"]

View file

@ -82,6 +82,7 @@ export { useChartTheme } from './hooks/use_chart_theme';
export { useBreadcrumbs } from './hooks/use_breadcrumbs';
export { useTheme } from './hooks/use_theme';
export { useTimeZone } from './hooks/use_time_zone';
export { useTimeBuckets } from './hooks/use_time_buckets';
export { createUseRulesLink } from './hooks/create_use_rules_link';
export { useLinkProps, shouldHandleLinkEvent } from './hooks/use_link_props';
export type { LinkDescriptor } from './hooks/use_link_props';
@ -118,6 +119,8 @@ export {
} from './components/shared/exploratory_view/configurations/constants';
export { ExploratoryViewContextProvider } from './components/shared/exploratory_view/contexts/exploratory_view_config';
export { fromQuery, toQuery } from './utils/url';
export { getAlertSummaryTimeRange } from './utils/alert_summary_widget';
export { calculateTimeRangeBucketSize } from './pages/overview/containers/overview_page/helpers';
export type { NavigationSection } from './services/navigation_registry';
export { convertTo } from '../common/utils/formatters/duration';

View file

@ -53,6 +53,8 @@ const getRenderValue = (mappedNonEcsValue: any) => {
return '—';
};
const displayCss = { display: 'contents' };
/**
* This implementation of `EuiDataGrid`'s `renderCellValue`
* accepts `EuiDataGridCellValueElementProps`, plus `data`
@ -94,15 +96,7 @@ export const getRenderCellValue = ({
const alert = parseAlert(observabilityRuleTypeRegistry)(dataFieldEs);
return (
// NOTE: EuiLink automatically renders links using a <button>
// instead of an <a> when an `onClick` prop is provided, but this
// breaks text-truncation in `EuiDataGrid`, because (per the HTML
// spec), buttons are *always* rendered as `inline-block`, even if
// `display` is overridden. Passing an empty `href` prop forces
// `EuiLink` to render the link as an (inline) <a>, which enables
// text truncation, but requires overriding the linter warning below:
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink href="" onClick={() => setFlyoutAlert && setFlyoutAlert(alert)}>
<EuiLink css={displayCss} onClick={() => setFlyoutAlert && setFlyoutAlert(alert)}>
{alert.reason}
</EuiLink>
);

View file

@ -5,5 +5,5 @@
* 2.0.
*/
export { calculateBucketSize } from './calculate_bucket_size';
export { calculateBucketSize, calculateTimeRangeBucketSize } from './calculate_bucket_size';
export { useOverviewMetrics } from './use_metrics';