[Infrastructure UI] Replace Snapshot API with InfraMetrics API in Hosts View (#155531)

closes [#154443](https://github.com/elastic/kibana/issues/154443)
## Summary

This PR replaces the usage of the Snapshot API in favor of the new
`metrics/infra` endpoint and also includes a new control in the Search
Bar to allow users to select how many hosts they want the API to return.


https://user-images.githubusercontent.com/2767137/233728658-bccc7258-6955-47fb-8f7b-85ef6ec5d0f9.mov

Because the KPIs now needs to show an "Average (of X hosts)", they will
only start fetching the data once the table has been loaded.

The hosts count KPI tile was not converted to Lens, because the page
needs to know the total number of hosts.

### Possible follow-up

Since now everything depends on the table to be loaded, I have been
experimenting with batched requests to the new API. The idea is to fetch
at least the host names as soon as possible.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2023-04-24 15:02:44 -03:00 committed by GitHub
parent 3e94b43aa2
commit 06545277d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 673 additions and 396 deletions

View file

@ -23,8 +23,9 @@ export const RangeRT = rt.type({
});
export const InfraAssetMetadataTypeRT = rt.keyof({
'host.os.name': null,
'cloud.provider': null,
'host.ip': null,
'host.os.name': null,
});
export const InfraAssetMetricsRT = rt.type({
@ -35,7 +36,7 @@ export const InfraAssetMetricsRT = rt.type({
export const InfraAssetMetadataRT = rt.type({
// keep the actual field name from the index mappings
name: InfraAssetMetadataTypeRT,
value: rt.union([rt.string, rt.number, rt.null]),
value: rt.union([rt.string, rt.null]),
});
export const GetInfraMetricsRequestBodyPayloadRT = rt.intersection([
@ -64,6 +65,7 @@ export const GetInfraMetricsResponsePayloadRT = rt.type({
export type InfraAssetMetrics = rt.TypeOf<typeof InfraAssetMetricsRT>;
export type InfraAssetMetadata = rt.TypeOf<typeof InfraAssetMetadataRT>;
export type InfraAssetMetadataType = rt.TypeOf<typeof InfraAssetMetadataTypeRT>;
export type InfraAssetMetricType = rt.TypeOf<typeof InfraMetricTypeRT>;
export type InfraAssetMetricsItem = rt.TypeOf<typeof InfraAssetMetricsItemRT>;

View file

@ -74,11 +74,7 @@ export const useLensAttributes = ({
return visualizationAttributes;
}, [dataView, formulaAPI, options, type, visualizationType]);
const injectFilters = (data: {
timeRange: TimeRange;
filters: Filter[];
query: Query;
}): LensAttributes | null => {
const injectFilters = (data: { filters: Filter[]; query: Query }): LensAttributes | null => {
if (!attributes) {
return null;
}
@ -121,7 +117,7 @@ export const useLensAttributes = ({
return true;
},
async execute(_context: ActionExecutionContext): Promise<void> {
const injectedAttributes = injectFilters({ timeRange, filters, query });
const injectedAttributes = injectFilters({ filters, query });
if (injectedAttributes) {
navigateToPrefilledEditor(
{

View file

@ -15,7 +15,7 @@ import { useIntersectedOnce } from '../../../../../hooks/use_intersection_once';
import { LensAttributes } from '../../../../../common/visualizations';
import { ChartLoader } from './chart_loader';
export interface Props {
export interface LensWrapperProps {
id: string;
attributes: LensAttributes | null;
dateRange: TimeRange;
@ -42,11 +42,12 @@ export const LensWrapper = ({
lastReloadRequestTime,
loading = false,
hasTitle = false,
}: Props) => {
}: LensWrapperProps) => {
const intersectionRef = useRef(null);
const [loadedOnce, setLoadedOnce] = useState(false);
const [state, setState] = useState({
attributes,
lastReloadRequestTime,
query,
filters,
@ -65,15 +66,23 @@ export const LensWrapper = ({
useEffect(() => {
if ((intersection?.intersectionRatio ?? 0) === 1) {
setState({
attributes,
lastReloadRequestTime,
query,
dateRange,
filters,
dateRange,
});
}
}, [dateRange, filters, intersection?.intersectionRatio, lastReloadRequestTime, query]);
}, [
attributes,
dateRange,
filters,
intersection?.intersectionRatio,
lastReloadRequestTime,
query,
]);
const isReady = attributes && intersectedOnce;
const isReady = state.attributes && intersectedOnce;
return (
<div ref={intersectionRef}>
@ -83,11 +92,11 @@ export const LensWrapper = ({
style={style}
hasTitle={hasTitle}
>
{isReady && (
{state.attributes && (
<EmbeddableComponent
id={id}
style={style}
attributes={attributes}
attributes={state.attributes}
viewMode={ViewMode.VIEW}
timeRange={state.dateRange}
query={state.query}

View file

@ -4,42 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo, useRef } from 'react';
import {
Chart,
Metric,
MetricTrendShape,
type MetricWNumber,
type MetricWTrend,
} from '@elastic/charts';
import { EuiPanel } from '@elastic/eui';
import React, { useEffect, useRef } from 'react';
import { Chart, Metric, type MetricWNumber, type MetricWTrend } from '@elastic/charts';
import { EuiPanel, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
import { EuiToolTip } from '@elastic/eui';
import type { SnapshotNode, SnapshotNodeMetric } from '../../../../../../common/http_api';
import { createInventoryMetricFormatter } from '../../../inventory_view/lib/create_inventory_metric_formatter';
import type { SnapshotMetricType } from '../../../../../../common/inventory_models/types';
import { ChartLoader } from './chart_loader';
type MetricType = keyof Pick<SnapshotNodeMetric, 'avg' | 'max' | 'value'>;
type AcceptedType = SnapshotMetricType | 'hostsCount';
export interface ChartBaseProps
extends Pick<
MetricWTrend,
'title' | 'color' | 'extra' | 'subtitle' | 'trendA11yDescription' | 'trendA11yTitle'
> {
type: AcceptedType;
toolTip: string;
metricType: MetricType;
['data-test-subj']?: string;
}
interface Props extends ChartBaseProps {
export interface Props extends Pick<MetricWTrend, 'title' | 'color' | 'extra' | 'subtitle'> {
id: string;
nodes: SnapshotNode[];
loading: boolean;
overrideValue?: number;
value: number;
toolTip: string;
['data-test-subj']?: string;
}
const MIN_HEIGHT = 150;
@ -49,23 +25,13 @@ export const MetricChartWrapper = ({
extra,
id,
loading,
metricType,
nodes,
overrideValue,
value,
subtitle,
title,
toolTip,
trendA11yDescription,
trendA11yTitle,
type,
...props
}: Props) => {
const loadedOnce = useRef(false);
const metrics = useMemo(() => (nodes ?? [])[0]?.metrics ?? [], [nodes]);
const metricsTimeseries = useMemo(
() => (metrics ?? []).find((m) => m.name === type)?.timeseries,
[metrics, type]
);
useEffect(() => {
if (!loadedOnce.current && !loading) {
@ -76,29 +42,13 @@ export const MetricChartWrapper = ({
};
}, [loading]);
const metricsValue = useMemo(() => {
if (overrideValue) {
return overrideValue;
}
return (metrics ?? []).find((m) => m.name === type)?.[metricType] ?? 0;
}, [metricType, metrics, overrideValue, type]);
const metricsData: MetricWNumber = {
title,
subtitle,
color,
extra,
value: metricsValue,
valueFormatter: (d: number) =>
type === 'hostsCount' ? d.toString() : createInventoryMetricFormatter({ type })(d),
...(!!metricsTimeseries
? {
trend: metricsTimeseries.rows.map((row) => ({ x: row.timestamp, y: row.metric_0 ?? 0 })),
trendShape: MetricTrendShape.Area,
trendA11yTitle,
trendA11yDescription,
}
: {}),
value,
valueFormatter: (d: number) => d.toString(),
};
return (

View file

@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { InfraLoadingPanel } from '../../../../components/loading';
import { useMetricsDataViewContext } from '../hooks/use_data_view';
import { UnifiedSearchBar } from './unified_search_bar';
import { UnifiedSearchBar } from './search_bar/unified_search_bar';
import { HostsTable } from './hosts_table';
import { KPIGrid } from './kpis/kpi_grid';
import { Tabs } from './tabs/tabs';

View file

@ -4,22 +4,46 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHostCountContext } from '../../hooks/use_host_count';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search';
import { useHostsViewContext } from '../../hooks/use_hosts_view';
import { type ChartBaseProps, MetricChartWrapper } from '../chart/metric_chart_wrapper';
import { type Props, MetricChartWrapper } from '../chart/metric_chart_wrapper';
export const HostsTile = ({ type, ...props }: ChartBaseProps) => {
const { hostNodes, loading } = useHostsViewContext();
const HOSTS_CHART: Omit<Props, 'loading' | 'value'> = {
id: `metric-hostCount`,
color: '#6DCCB1',
title: i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.title', {
defaultMessage: 'Hosts',
}),
toolTip: i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.tooltip', {
defaultMessage: 'The number of hosts returned by your current search criteria.',
}),
['data-test-subj']: 'hostsView-metricsTrend-hosts',
};
export const HostsTile = () => {
const { data: hostCountData, isRequestRunning: hostCountLoading } = useHostCountContext();
const { searchCriteria } = useUnifiedSearchContext();
const getSubtitle = () => {
return searchCriteria.limit < (hostCountData?.count.value ?? 0)
? i18n.translate('xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit', {
defaultMessage: 'Limited to {limit}',
values: {
limit: searchCriteria.limit,
},
})
: undefined;
};
return (
<MetricChartWrapper
id={`metric-${type}`}
type={type}
nodes={[]}
loading={loading}
overrideValue={hostNodes?.length}
{...props}
{...HOSTS_CHART}
value={hostCountData?.count.value ?? 0}
subtitle={getSubtitle()}
loading={hostCountLoading}
/>
);
};

View file

@ -9,10 +9,10 @@ import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { KPIChartProps, Tile } from './tile';
import { HostCountProvider } from '../../hooks/use_host_count';
import { HostsTile } from './hosts_tile';
import { ChartBaseProps } from '../chart/metric_chart_wrapper';
const KPI_CHARTS: KPIChartProps[] = [
const KPI_CHARTS: Array<Omit<KPIChartProps, 'loading' | 'subtitle'>> = [
{
type: 'cpu',
trendLine: true,
@ -20,9 +20,6 @@ const KPI_CHARTS: KPIChartProps[] = [
title: i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.title', {
defaultMessage: 'CPU usage',
}),
subtitle: i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.subtitle', {
defaultMessage: 'Average',
}),
toolTip: i18n.translate('xpack.infra.hostsViewPage.metricTrend.cpu.tooltip', {
defaultMessage:
'Average of percentage of CPU time spent in states other than Idle and IOWait, normalized by the number of CPU cores. Includes both time spent on user space and kernel space. 100% means all CPUs of the host are busy.',
@ -35,9 +32,6 @@ const KPI_CHARTS: KPIChartProps[] = [
title: i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.title', {
defaultMessage: 'Memory usage',
}),
subtitle: i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.subtitle', {
defaultMessage: 'Average',
}),
toolTip: i18n.translate('xpack.infra.hostsViewPage.metricTrend.memory.tooltip', {
defaultMessage:
"Average of percentage of main memory usage excluding page cache. This includes resident memory for all processes plus memory used by the kernel structures and code apart the page cache. A high level indicates a situation of memory saturation for a host. 100% means the main memory is entirely filled with memory that can't be reclaimed, except by swapping out.",
@ -50,9 +44,6 @@ const KPI_CHARTS: KPIChartProps[] = [
title: i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.title', {
defaultMessage: 'Network inbound (RX)',
}),
subtitle: i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.subtitle', {
defaultMessage: 'Average',
}),
toolTip: i18n.translate('xpack.infra.hostsViewPage.metricTrend.rx.tooltip', {
defaultMessage:
'Number of bytes which have been received per second on the public interfaces of the hosts.',
@ -65,9 +56,6 @@ const KPI_CHARTS: KPIChartProps[] = [
title: i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.title', {
defaultMessage: 'Network outbound (TX)',
}),
subtitle: i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.subtitle', {
defaultMessage: 'Average',
}),
toolTip: i18n.translate('xpack.infra.hostsViewPage.metricTrend.tx.tooltip', {
defaultMessage:
'Number of bytes which have been received per second on the public interfaces of the hosts.',
@ -75,38 +63,24 @@ const KPI_CHARTS: KPIChartProps[] = [
},
];
const HOSTS_CHART: ChartBaseProps = {
type: 'hostsCount',
color: '#6DCCB1',
metricType: 'value',
title: i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.title', {
defaultMessage: 'Hosts',
}),
trendA11yTitle: i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.a11y.title', {
defaultMessage: 'Hosts count.',
}),
toolTip: i18n.translate('xpack.infra.hostsViewPage.metricTrend.hostCount.tooltip', {
defaultMessage: 'The number of hosts returned by your current search criteria.',
}),
['data-test-subj']: 'hostsView-metricsTrend-hosts',
};
export const KPIGrid = () => {
return (
<EuiFlexGroup
direction="row"
gutterSize="s"
style={{ flexGrow: 0 }}
data-test-subj="hostsView-metricsTrend"
>
<EuiFlexItem>
<HostsTile {...HOSTS_CHART} />
</EuiFlexItem>
{KPI_CHARTS.map(({ ...chartProp }) => (
<EuiFlexItem key={chartProp.type}>
<Tile {...chartProp} />
<HostCountProvider>
<EuiFlexGroup
direction="row"
gutterSize="s"
style={{ flexGrow: 0 }}
data-test-subj="hostsView-metricsTrend"
>
<EuiFlexItem>
<HostsTile />
</EuiFlexItem>
))}
</EuiFlexGroup>
{KPI_CHARTS.map(({ ...chartProp }) => (
<EuiFlexItem key={chartProp.type}>
<Tile {...chartProp} />
</EuiFlexItem>
))}
</EuiFlexGroup>
</HostCountProvider>
);
};

View file

@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { i18n } from '@kbn/i18n';
import { BrushTriggerEvent } from '@kbn/charts-plugin/public';
import {
EuiIcon,
@ -24,6 +24,9 @@ import { useUnifiedSearchContext } from '../../hooks/use_unified_search';
import { HostsLensMetricChartFormulas } from '../../../../../common/visualizations';
import { useHostsViewContext } from '../../hooks/use_hosts_view';
import { LensWrapper } from '../chart/lens_wrapper';
import { createHostsFilter } from '../../utils';
import { useHostCountContext } from '../../hooks/use_host_count';
import { useAfterLoadedState } from '../../hooks/use_after_loaded_state';
export interface KPIChartProps {
title: string;
@ -38,7 +41,6 @@ const MIN_HEIGHT = 150;
export const Tile = ({
title,
subtitle,
type,
backgroundColor,
toolTip,
@ -46,14 +48,28 @@ export const Tile = ({
}: KPIChartProps) => {
const { searchCriteria, onSubmit } = useUnifiedSearchContext();
const { dataView } = useMetricsDataViewContext();
const { baseRequest } = useHostsViewContext();
const { requestTs, hostNodes, loading: hostsLoading } = useHostsViewContext();
const { data: hostCountData, isRequestRunning: hostCountLoading } = useHostCountContext();
const getSubtitle = () => {
return searchCriteria.limit < (hostCountData?.count.value ?? 0)
? i18n.translate('xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit', {
defaultMessage: 'Average (of {limit} hosts)',
values: {
limit: searchCriteria.limit,
},
})
: i18n.translate('xpack.infra.hostsViewPage.metricTrend.subtitle.average', {
defaultMessage: 'Average',
});
};
const { attributes, getExtraActions, error } = useLensAttributes({
type,
dataView,
options: {
title,
subtitle,
subtitle: getSubtitle(),
backgroundColor,
showTrendLine: trendLine,
showTitle: false,
@ -61,15 +77,24 @@ export const Tile = ({
visualizationType: 'metricChart',
});
const filters = [...searchCriteria.filters, ...searchCriteria.panelFilters];
const hostsFilterQuery = useMemo(() => {
return createHostsFilter(
hostNodes.map((p) => p.name),
dataView
);
}, [hostNodes, dataView]);
const filters = useMemo(
() => [...searchCriteria.filters, ...searchCriteria.panelFilters, ...[hostsFilterQuery]],
[hostsFilterQuery, searchCriteria.filters, searchCriteria.panelFilters]
);
const extraActionOptions = getExtraActions({
timeRange: searchCriteria.dateRange,
filters,
query: searchCriteria.query,
});
const extraActions: Action[] = [extraActionOptions.openInLens];
const handleBrushEnd = ({ range }: BrushTriggerEvent['data']) => {
const [min, max] = range;
onSubmit({
@ -81,6 +106,14 @@ export const Tile = ({
});
};
const loading = hostsLoading || !attributes || hostCountLoading;
const { afterLoadedState } = useAfterLoadedState(loading, {
attributes,
lastReloadRequestTime: requestTs,
...searchCriteria,
filters,
});
return (
<EuiPanelStyled
hasShadow={false}
@ -117,14 +150,15 @@ export const Tile = ({
>
<LensWrapper
id={`hostViewKPIChart-${type}`}
attributes={attributes}
attributes={afterLoadedState.attributes}
style={{ height: MIN_HEIGHT }}
extraActions={extraActions}
lastReloadRequestTime={baseRequest.requestTs}
dateRange={searchCriteria.dateRange}
filters={filters}
query={searchCriteria.query}
extraActions={[extraActionOptions.openInLens]}
lastReloadRequestTime={afterLoadedState.lastReloadRequestTime}
dateRange={afterLoadedState.dateRange}
filters={afterLoadedState.filters}
query={afterLoadedState.query}
onBrushEnd={handleBrushEnd}
loading={loading}
/>
</EuiToolTip>
)}
@ -134,7 +168,7 @@ export const Tile = ({
const EuiPanelStyled = styled(EuiPanel)`
.echMetric {
border-radius: ${(p) => p.theme.eui.euiBorderRadius};
border-radius: ${({ theme }) => theme.eui.euiBorderRadius};
pointer-events: none;
}
`;

View file

@ -12,15 +12,16 @@ import {
type ControlGroupInput,
} from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, Query, TimeRange } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/public';
import { Subscription } from 'rxjs';
import { useControlPanels } from '../hooks/use_control_panels_url_state';
import { skipWhile, Subscription } from 'rxjs';
import { useControlPanels } from '../../hooks/use_control_panels_url_state';
interface Props {
dataView: DataView | undefined;
timeRange: TimeRange;
filters: Filter[];
selectedOptions: Filter[];
query: Query;
onFiltersChange: (filters: Filter[]) => void;
}
@ -29,6 +30,7 @@ export const ControlsContent: React.FC<Props> = ({
dataView,
filters,
query,
selectedOptions,
timeRange,
onFiltersChange,
}) => {
@ -55,15 +57,21 @@ export const ControlsContent: React.FC<Props> = ({
const loadCompleteHandler = useCallback(
(controlGroup: ControlGroupAPI) => {
if (!controlGroup) return;
inputSubscription.current = controlGroup.onFiltersPublished$.subscribe((newFilters) => {
onFiltersChange(newFilters);
});
inputSubscription.current = controlGroup.onFiltersPublished$
.pipe(
skipWhile((newFilters) =>
compareFilters(selectedOptions, newFilters, COMPARE_ALL_OPTIONS)
)
)
.subscribe((newFilters) => {
onFiltersChange(newFilters);
});
filterSubscription.current = controlGroup
.getInput$()
.subscribe(({ panels }) => setControlPanels(panels));
},
[onFiltersChange, setControlPanels]
[onFiltersChange, setControlPanels, selectedOptions]
);
useEffect(() => {

View file

@ -0,0 +1,81 @@
/*
* 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 {
EuiButtonGroup,
EuiButtonGroupOptionProps,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiToolTip,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { HOST_LIMIT_OPTIONS } from '../../constants';
import { HostLimitOptions } from '../../types';
interface Props {
limit: HostLimitOptions;
onChange: (limit: number) => void;
}
export const LimitOptions = ({ limit, onChange }: Props) => {
return (
<EuiFlexGroup
direction="row"
alignItems="center"
justifyContent="spaceBetween"
responsive={false}
gutterSize="xs"
>
<EuiFlexItem grow={false}>
<EuiText size="xs" textAlign="left">
<strong>
<FormattedMessage
id="xpack.infra.hostsViewPage.hostLimit"
defaultMessage="Host limit"
/>
</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip
className="eui-fullWidth"
delay="regular"
content={i18n.translate('xpack.infra.hostsViewPage.hostLimit.tooltip', {
defaultMessage:
'To ensure faster query performance, there is a limit to the number of hosts returned',
})}
anchorClassName="eui-fullWidth"
>
<EuiIcon type="iInCircle" size="m" />
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonGroup
type="single"
legend={i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.legend', {
defaultMessage: 'Filter by',
})}
idSelected={buildId(limit)}
options={options}
onChange={(_, value: number) => onChange(value)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const buildId = (option: number) => `hostLimit_${option}`;
const options: EuiButtonGroupOptionProps[] = HOST_LIMIT_OPTIONS.map((option) => ({
id: buildId(option),
label: `${option}`,
value: option,
'data-test-subj': `hostsViewLimitSelection${option}button`,
}));

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGrid,
useEuiTheme,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { METRICS_APP_DATA_TEST_SUBJ } from '../../../../../apps/metrics_app';
import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search';
import { ControlsContent } from './controls_content';
import { useMetricsDataViewContext } from '../../hooks/use_data_view';
import { HostsSearchPayload } from '../../hooks/use_unified_search_url_state';
import { LimitOptions } from './limit_options';
import { HostLimitOptions } from '../../types';
export const UnifiedSearchBar = () => {
const {
services: { unifiedSearch, application },
} = useKibanaContextForPlugin();
const { dataView } = useMetricsDataViewContext();
const { searchCriteria, onSubmit } = useUnifiedSearchContext();
const { SearchBar } = unifiedSearch.ui;
const onLimitChange = (limit: number) => {
onSubmit({ limit });
};
const onPanelFiltersChange = (panelFilters: Filter[]) => {
if (!compareFilters(searchCriteria.panelFilters, panelFilters, COMPARE_ALL_OPTIONS)) {
onSubmit({ panelFilters });
}
};
const handleRefresh = (payload: HostsSearchPayload, isUpdate?: boolean) => {
// This makes sure `onQueryChange` is only called when the submit button is clicked
if (isUpdate === false) {
onSubmit(payload);
}
};
return (
<StickyContainer>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<SearchBar
appName={'Infra Hosts'}
displayStyle="inPage"
indexPatterns={dataView && [dataView]}
placeholder={i18n.translate('xpack.infra.hosts.searchPlaceholder', {
defaultMessage: 'Search hosts (E.g. cloud.provider:gcp AND system.load.1 > 0.5)',
})}
onQuerySubmit={handleRefresh}
showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)}
showDatePicker
showFilterBar
showQueryInput
showQueryMenu
useDefaultBehaviors
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="row" alignItems="center" wrap={false} gutterSize="xs">
<EuiFlexItem>
<ControlsContent
timeRange={searchCriteria.dateRange}
dataView={dataView}
query={searchCriteria.query}
filters={searchCriteria.filters}
selectedOptions={searchCriteria.panelFilters}
onFiltersChange={onPanelFiltersChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LimitOptions
limit={searchCriteria.limit as HostLimitOptions}
onChange={onLimitChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="none" />
</StickyContainer>
);
};
const StickyContainer = (props: { children: React.ReactNode }) => {
const { euiTheme } = useEuiTheme();
const top = useMemo(() => {
const wrapper = document.querySelector(`[data-test-subj="${METRICS_APP_DATA_TEST_SUBJ}"]`);
if (!wrapper) {
return `calc(${euiTheme.size.xxxl} * 2)`;
}
return `${wrapper.getBoundingClientRect().top}px`;
}, [euiTheme]);
return (
<EuiFlexGrid
gutterSize="none"
css={css`
position: sticky;
top: ${top};
z-index: ${euiTheme.levels.header};
background: ${euiTheme.colors.emptyShade};
padding-top: ${euiTheme.size.m};
margin-top: -${euiTheme.size.l};
`}
{...props}
/>
);
};

View file

@ -9,7 +9,6 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { InfraLoadingPanel } from '../../../../../../components/loading';
import { SnapshotNode } from '../../../../../../../common/http_api';
import { LogStream } from '../../../../../../components/log_stream';
import { useHostsViewContext } from '../../../hooks/use_hosts_view';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
@ -30,7 +29,7 @@ export const LogsTabContent = () => {
);
const logsLinkToStreamQuery = useMemo(() => {
const hostsFilterQueryParam = createHostsFilterQueryParam(hostNodes);
const hostsFilterQueryParam = createHostsFilterQueryParam(hostNodes.map((p) => p.name));
if (filterQuery.query && hostsFilterQueryParam) {
return `${filterQuery.query} and ${hostsFilterQueryParam}`;
@ -83,12 +82,12 @@ export const LogsTabContent = () => {
);
};
const createHostsFilterQueryParam = (hostNodes: SnapshotNode[]): string => {
const createHostsFilterQueryParam = (hostNodes: string[]): string => {
if (!hostNodes.length) {
return '';
}
const joinedHosts = hostNodes.map((p) => p.name).join(' or ');
const joinedHosts = hostNodes.join(' or ');
const hostsQueryParam = `host.name:(${joinedHosts})`;
return hostsQueryParam;

View file

@ -40,13 +40,13 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) =>
const { euiTheme } = useEuiTheme();
const { searchCriteria, onSubmit } = useUnifiedSearchContext();
const { dataView } = useMetricsDataViewContext();
const { baseRequest, loading } = useHostsViewContext();
const { requestTs, loading } = useHostsViewContext();
const { currentPage } = useHostsTableContext();
// prevents updates on requestTs and serchCriteria states from relaoding the chart
// we want it to reload only once the table has finished loading
const { afterLoadedState } = useAfterLoadedState(loading, {
lastReloadRequestTime: baseRequest.requestTs,
lastReloadRequestTime: requestTs,
...searchCriteria,
});

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { EuiFlexGrid, EuiFlexItem, EuiFlexGroup, EuiText, EuiI18n } from '@elastic/eui';
import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { MetricChart, MetricChartProps } from './metric_chart';
@ -64,32 +64,12 @@ const CHARTS_IN_ORDER: Array<Pick<MetricChartProps, 'title' | 'type'> & { fullRo
export const MetricsGrid = React.memo(() => {
return (
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="hostsView-metricChart">
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false} style={{ flex: 1 }}>
<EuiText size="xs">
<EuiI18n
token="xpack.infra.hostsViewPage.tabs.metricsCharts.sortingCriteria"
default="Showing for Top {maxHosts} hosts by {attribute}"
values={{
maxHosts: <strong>{DEFAULT_BREAKDOWN_SIZE}</strong>,
attribute: <strong>name</strong>,
}}
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGrid columns={2} gutterSize="s">
{CHARTS_IN_ORDER.map(({ fullRow, ...chartProp }) => (
<EuiFlexItem key={chartProp.type} style={fullRow ? { gridColumn: '1/-1' } : {}}>
<MetricChart breakdownSize={DEFAULT_BREAKDOWN_SIZE} {...chartProp} />
</EuiFlexItem>
))}
</EuiFlexGrid>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGrid columns={2} gutterSize="s">
{CHARTS_IN_ORDER.map(({ fullRow, ...chartProp }) => (
<EuiFlexItem key={chartProp.type} style={fullRow ? { gridColumn: '1/-1' } : {}}>
<MetricChart breakdownSize={DEFAULT_BREAKDOWN_SIZE} {...chartProp} />
</EuiFlexItem>
))}
</EuiFlexGrid>
);
});

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { EuiFlexGrid, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiHorizontalRule } from '@elastic/eui';
import { METRICS_APP_DATA_TEST_SUBJ } from '../../../../apps/metrics_app';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { useUnifiedSearchContext } from '../hooks/use_unified_search';
import { ControlsContent } from './controls_content';
import { useMetricsDataViewContext } from '../hooks/use_data_view';
import { HostsSearchPayload } from '../hooks/use_unified_search_url_state';
export const UnifiedSearchBar = () => {
const {
services: { unifiedSearch, application },
} = useKibanaContextForPlugin();
const { dataView } = useMetricsDataViewContext();
const { searchCriteria, onSubmit } = useUnifiedSearchContext();
const { SearchBar } = unifiedSearch.ui;
const onPanelFiltersChange = (panelFilters: Filter[]) => {
if (!compareFilters(searchCriteria.panelFilters, panelFilters, COMPARE_ALL_OPTIONS)) {
onSubmit({ panelFilters });
}
};
const handleRefresh = (payload: HostsSearchPayload, isUpdate?: boolean) => {
// This makes sure `onQueryChange` is only called when the submit button is clicked
if (isUpdate === false) {
onSubmit(payload);
}
};
return (
<StickyContainer>
<SearchBar
appName={'Infra Hosts'}
displayStyle="inPage"
indexPatterns={dataView && [dataView]}
placeholder={i18n.translate('xpack.infra.hosts.searchPlaceholder', {
defaultMessage: 'Search hosts (E.g. cloud.provider:gcp AND system.load.1 > 0.5)',
})}
onQuerySubmit={handleRefresh}
showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)}
showDatePicker
showFilterBar
showQueryInput
showQueryMenu
useDefaultBehaviors
/>
<ControlsContent
timeRange={searchCriteria.dateRange}
dataView={dataView}
query={searchCriteria.query}
filters={searchCriteria.filters}
onFiltersChange={onPanelFiltersChange}
/>
<EuiHorizontalRule margin="none" />
</StickyContainer>
);
};
const StickyContainer = (props: { children: React.ReactNode }) => {
const { euiTheme } = useEuiTheme();
const top = useMemo(() => {
const wrapper = document.querySelector(`[data-test-subj="${METRICS_APP_DATA_TEST_SUBJ}"]`);
if (!wrapper) {
return `calc(${euiTheme.size.xxxl} * 2)`;
}
return `${wrapper.getBoundingClientRect().top}px`;
}, [euiTheme]);
return (
<EuiFlexGrid
gutterSize="none"
css={css`
position: sticky;
top: ${top};
z-index: ${euiTheme.levels.header};
background: ${euiTheme.colors.emptyShade};
padding-top: ${euiTheme.size.m};
margin-top: -${euiTheme.size.l};
`}
{...props}
/>
);
};

View file

@ -7,13 +7,15 @@
import { i18n } from '@kbn/i18n';
import { ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils';
import { AlertStatusFilter } from './types';
import { AlertStatusFilter, HostLimitOptions } from './types';
export const ALERT_STATUS_ALL = 'all';
export const TIMESTAMP_FIELD = '@timestamp';
export const DATA_VIEW_PREFIX = 'infra_metrics';
export const DEFAULT_HOST_LIMIT: HostLimitOptions = 100;
export const DEFAULT_PAGE_SIZE = 10;
export const LOCAL_STORAGE_HOST_LIMIT_KEY = 'hostsView:hostLimitSelection';
export const LOCAL_STORAGE_PAGE_SIZE_KEY = 'hostsView:pageSizeSelection';
export const ALL_ALERTS: AlertStatusFilter = {
@ -55,3 +57,5 @@ 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;

View file

@ -9,7 +9,7 @@ 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 { SnapshotNode } from '../../../../../common/http_api';
import { InfraAssetMetricsItem } from '../../../../../common/http_api';
import { useUnifiedSearchContext } from './use_unified_search';
import { HostsState } from './use_unified_search_url_state';
import { useHostsViewContext } from './use_hosts_view';
@ -63,7 +63,7 @@ const createAlertsEsQuery = ({
status,
}: {
dateRange: HostsState['dateRange'];
hostNodes: SnapshotNode[];
hostNodes: InfraAssetMetricsItem[];
status?: AlertStatus;
}): AlertsEsQuery => {
const alertStatusFilter = createAlertStatusFilter(status);

View file

@ -72,6 +72,7 @@ export const useDataView = ({ metricAlias }: { metricAlias: string }) => {
}, [hasError, notifications, metricAlias]);
return {
metricAlias,
dataView,
loading,
hasError,

View file

@ -0,0 +1,129 @@
/*
* 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 * as rt from 'io-ts';
import { ES_SEARCH_STRATEGY, IKibanaSearchResponse } from '@kbn/data-plugin/common';
import { useCallback, useEffect } from 'react';
import { catchError, map, Observable, of, startWith } from 'rxjs';
import createContainer from 'constate';
import type { QueryDslQueryContainer, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { decodeOrThrow } from '../../../../../common/runtime_types';
import { useDataSearch, useLatestPartialDataSearchResponse } from '../../../../utils/data_search';
import { useMetricsDataViewContext } from './use_data_view';
import { useUnifiedSearchContext } from './use_unified_search';
export const useHostCount = () => {
const { dataView, metricAlias } = useMetricsDataViewContext();
const { buildQuery, getParsedDateRange } = useUnifiedSearchContext();
const { search: fetchHostCount, requests$ } = useDataSearch({
getRequest: useCallback(() => {
const query = buildQuery();
const dateRange = getParsedDateRange();
const filters: QueryDslQueryContainer = {
bool: {
...query.bool,
filter: [
...query.bool.filter,
{
exists: {
field: 'host.name',
},
},
{
range: {
[dataView?.timeFieldName ?? '@timestamp']: {
gte: dateRange.from,
lte: dateRange.to,
format: 'strict_date_optional_time',
},
},
},
],
},
};
return {
request: {
params: {
allow_no_indices: true,
ignore_unavailable: true,
index: metricAlias,
size: 0,
track_total_hits: false,
body: {
query: filters,
aggs: {
count: {
cardinality: {
field: 'host.name',
},
},
},
},
},
},
options: { strategy: ES_SEARCH_STRATEGY },
};
}, [buildQuery, dataView, getParsedDateRange, metricAlias]),
parseResponses: normalizeDataSearchResponse,
});
const { isRequestRunning, isResponsePartial, latestResponseData, latestResponseErrors } =
useLatestPartialDataSearchResponse(requests$);
useEffect(() => {
fetchHostCount();
}, [fetchHostCount]);
return {
errors: latestResponseErrors,
isRequestRunning,
isResponsePartial,
data: latestResponseData ?? null,
};
};
export const HostCount = createContainer(useHostCount);
export const [HostCountProvider, useHostCountContext] = HostCount;
const INITIAL_STATE = {
data: null,
errors: [],
isPartial: true,
isRunning: true,
loaded: 0,
total: undefined,
};
const normalizeDataSearchResponse = (
response$: Observable<IKibanaSearchResponse<SearchResponse<Record<string, unknown>>>>
) =>
response$.pipe(
map((response) => ({
data: decodeOrThrow(HostCountResponseRT)(response.rawResponse.aggregations),
errors: [],
isPartial: response.isPartial ?? false,
isRunning: response.isRunning ?? false,
loaded: response.loaded,
total: response.total,
})),
startWith(INITIAL_STATE),
catchError((error) =>
of({
...INITIAL_STATE,
errors: [error.message ?? error],
isRunning: false,
})
)
);
const HostCountResponseRT = rt.type({
count: rt.type({
value: rt.number,
}),
});

View file

@ -7,7 +7,7 @@
import { useHostsTable } from './use_hosts_table';
import { renderHook } from '@testing-library/react-hooks';
import { SnapshotNode } from '../../../../../common/http_api';
import { InfraAssetMetricsItem } from '../../../../../common/http_api';
import * as useUnifiedSearchHooks from './use_unified_search';
import * as useHostsViewHooks from './use_hosts_view';
@ -22,20 +22,20 @@ const mockUseHostsViewContext = useHostsViewHooks.useHostsViewContext as jest.Mo
typeof useHostsViewHooks.useHostsViewContext
>;
const mockHostNode: SnapshotNode[] = [
const mockHostNode: InfraAssetMetricsItem[] = [
{
metrics: [
{
name: 'rx',
avg: 252456.92916666667,
value: 252456.92916666667,
},
{
name: 'tx',
avg: 252758.425,
value: 252758.425,
},
{
name: 'memory',
avg: 0.94525,
value: 0.94525,
},
{
name: 'cpu',
@ -43,25 +43,28 @@ const mockHostNode: SnapshotNode[] = [
},
{
name: 'memoryTotal',
avg: 34359.738368,
value: 34359.738368,
},
],
path: [{ value: 'host-0', label: 'host-0', os: null, cloudProvider: 'aws' }],
metadata: [
{ name: 'host.os.name', value: null },
{ name: 'cloud.provider', value: 'aws' },
],
name: 'host-0',
},
{
metrics: [
{
name: 'rx',
avg: 95.86339715321859,
value: 95.86339715321859,
},
{
name: 'tx',
avg: 110.38566859563191,
value: 110.38566859563191,
},
{
name: 'memory',
avg: 0.5400000214576721,
value: 0.5400000214576721,
},
{
name: 'cpu',
@ -69,12 +72,12 @@ const mockHostNode: SnapshotNode[] = [
},
{
name: 'memoryTotal',
avg: 9.194304,
value: 9.194304,
},
],
path: [
{ value: 'host-1', label: 'host-1' },
{ value: 'host-1', label: 'host-1', ip: '243.86.94.22', os: 'macOS' },
metadata: [
{ name: 'host.os.name', value: 'macOS' },
{ name: 'host.ip', value: '243.86.94.22' },
],
name: 'host-1',
},

View file

@ -15,10 +15,10 @@ import { isNumber } from 'lodash/fp';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { createInventoryMetricFormatter } from '../../inventory_view/lib/create_inventory_metric_formatter';
import { HostsTableEntryTitle } from '../components/hosts_table_entry_title';
import type {
SnapshotNode,
SnapshotNodeMetric,
SnapshotMetricInput,
import {
InfraAssetMetadataType,
InfraAssetMetricsItem,
InfraAssetMetricType,
} from '../../../../../common/http_api';
import { useHostFlyoutOpen } from './use_host_flyout_open_url_state';
import { Sorting, useHostsTableProperties } from './use_hosts_table_url_state';
@ -29,42 +29,55 @@ import { useUnifiedSearchContext } from './use_unified_search';
* Columns and items types
*/
export type CloudProvider = 'gcp' | 'aws' | 'azure' | 'unknownProvider';
type HostMetrics = Record<InfraAssetMetricType, number | null>;
type HostMetric = 'cpu' | 'diskLatency' | 'rx' | 'tx' | 'memory' | 'memoryTotal';
type HostMetrics = Record<HostMetric, SnapshotNodeMetric['avg']>;
export interface HostNodeRow extends HostMetrics {
interface HostMetadata {
os?: string | null;
ip?: string | null;
servicesOnHost?: number | null;
title: { name: string; cloudProvider?: CloudProvider | null };
name: string;
id: string;
}
export type HostNodeRow = HostMetadata &
HostMetrics & {
name: string;
};
/**
* Helper functions
*/
const formatMetric = (type: SnapshotMetricInput['type'], value: number | undefined | null) => {
const formatMetric = (type: InfraAssetMetricType, value: number | undefined | null) => {
return value || value === 0 ? createInventoryMetricFormatter({ type })(value) : 'N/A';
};
const buildItemsList = (nodes: SnapshotNode[]) => {
return nodes.map(({ metrics, path, name }) => ({
id: `${name}-${path.at(-1)?.os ?? '-'}`,
name,
os: path.at(-1)?.os ?? '-',
ip: path.at(-1)?.ip ?? '',
title: {
const buildItemsList = (nodes: InfraAssetMetricsItem[]): HostNodeRow[] => {
return nodes.map(({ metrics, metadata, name }) => {
const metadataKeyValue = metadata.reduce(
(acc, curr) => ({
...acc,
[curr.name]: curr.value,
}),
{} as Record<InfraAssetMetadataType, string | null>
);
return {
name,
cloudProvider: path.at(-1)?.cloudProvider ?? null,
},
...metrics.reduce((data, metric) => {
data[metric.name as HostMetric] = metric.avg ?? metric.value;
return data;
}, {} as HostMetrics),
})) as HostNodeRow[];
id: `${name}-${metadataKeyValue['host.os.name'] ?? '-'}`,
title: {
name,
cloudProvider: (metadataKeyValue['cloud.provider'] as CloudProvider) ?? null,
},
os: metadataKeyValue['host.os.name'] ?? '-',
ip: metadataKeyValue['host.ip'] ?? '',
...metrics.reduce(
(acc, curr) => ({
...acc,
[curr.name]: curr.value ?? 0,
}),
{} as HostMetrics
),
};
});
};
const isTitleColumn = (cell: any): cell is HostNodeRow['title'] => {

View file

@ -12,16 +12,21 @@
* 2.0.
*/
import { useMemo } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import createContainer from 'constate';
import { BoolQuery } from '@kbn/es-query';
import { SnapshotMetricType } from '../../../../../common/inventory_models/types';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { useSourceContext } from '../../../../containers/metrics_source';
import { useSnapshot, type UseSnapshotRequest } from '../../inventory_view/hooks/use_snaphot';
import { useUnifiedSearchContext } from './use_unified_search';
import { StringDateRangeTimestamp } from './use_unified_search_url_state';
import {
GetInfraMetricsRequestBodyPayload,
GetInfraMetricsResponsePayload,
InfraAssetMetricType,
} from '../../../../../common/http_api';
import { StringDateRange } from './use_unified_search_url_state';
const HOST_TABLE_METRICS: Array<{ type: SnapshotMetricType }> = [
const HOST_TABLE_METRICS: Array<{ type: InfraAssetMetricType }> = [
{ type: 'rx' },
{ type: 'tx' },
{ type: 'memory' },
@ -30,40 +35,52 @@ const HOST_TABLE_METRICS: Array<{ type: SnapshotMetricType }> = [
{ type: 'memoryTotal' },
];
const BASE_INFRA_METRICS_PATH = '/api/metrics/infra';
export const useHostsView = () => {
const { sourceId } = useSourceContext();
const { buildQuery, getDateRangeAsTimestamp } = useUnifiedSearchContext();
const {
services: { http },
} = useKibanaContextForPlugin();
const { buildQuery, getParsedDateRange, searchCriteria } = useUnifiedSearchContext();
const abortCtrlRef = useRef(new AbortController());
const baseRequest = useMemo(
() =>
createSnapshotRequest({
dateRange: getDateRangeAsTimestamp(),
createInfraMetricsRequest({
dateRange: getParsedDateRange(),
esQuery: buildQuery(),
sourceId,
limit: searchCriteria.limit,
}),
[buildQuery, getDateRangeAsTimestamp, sourceId]
[buildQuery, getParsedDateRange, sourceId, searchCriteria.limit]
);
// 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,
const [state, refetch] = useAsyncFn(
() => {
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
return http.post<GetInfraMetricsResponsePayload>(`${BASE_INFRA_METRICS_PATH}`, {
signal: abortCtrlRef.current.signal,
body: JSON.stringify(baseRequest),
});
},
{ abortable: true }
[baseRequest, http],
{ loading: true }
);
useEffect(() => {
refetch();
}, [refetch]);
const { value, error, loading } = state;
return {
baseRequest,
requestTs: baseRequest.requestTs,
loading,
error,
hostNodes,
hostNodes: value?.nodes ?? [],
};
};
@ -73,30 +90,26 @@ export const [HostsViewProvider, useHostsViewContext] = HostsView;
/**
* Helpers
*/
const createSnapshotRequest = ({
const createInfraMetricsRequest = ({
esQuery,
sourceId,
dateRange,
limit,
}: {
esQuery: { bool: BoolQuery };
sourceId: string;
dateRange: StringDateRangeTimestamp;
}): UseSnapshotRequest => ({
filterQuery: JSON.stringify(esQuery),
metrics: [],
groupBy: [],
nodeType: 'host',
sourceId,
currentTime: dateRange.to,
includeTimeseries: false,
sendRequestImmediately: true,
timerange: {
interval: '1m',
dateRange: StringDateRange;
limit: number;
}): GetInfraMetricsRequestBodyPayload & { requestTs: number } => ({
type: 'host',
query: esQuery,
range: {
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.
metrics: HOST_TABLE_METRICS,
limit,
sourceId,
requestTs: Date.now(),
});

View file

@ -23,14 +23,14 @@ import {
} from './use_unified_search_url_state';
const buildQuerySubmittedPayload = (
hostState: HostsState & { dateRangeTimestamp: StringDateRangeTimestamp }
hostState: HostsState & { parsedDateRange: StringDateRangeTimestamp }
) => {
const { panelFilters, filters, dateRangeTimestamp, query: queryObj } = hostState;
const { panelFilters, filters, parsedDateRange, query: queryObj } = hostState;
return {
control_filters: panelFilters.map((filter) => JSON.stringify(filter)),
filters: filters.map((filter) => JSON.stringify(filter)),
interval: telemetryTimeRangeFormatter(dateRangeTimestamp.to - dateRangeTimestamp.from),
interval: telemetryTimeRangeFormatter(parsedDateRange.to - parsedDateRange.from),
query: queryObj.query,
};
};
@ -41,8 +41,8 @@ const getDefaultTimestamps = () => {
const now = Date.now();
return {
from: now - DEFAULT_FROM_IN_MILLISECONDS,
to: now,
from: new Date(now - DEFAULT_FROM_IN_MILLISECONDS).toISOString(),
to: new Date(now).toISOString(),
};
};
@ -63,16 +63,25 @@ export const useUnifiedSearch = () => {
const onSubmit = (params?: HostsSearchPayload) => setSearch(params ?? {});
const getDateRangeAsTimestamp = useCallback(() => {
const getParsedDateRange = useCallback(() => {
const defaults = getDefaultTimestamps();
const from = DateMath.parse(searchCriteria.dateRange.from)?.valueOf() ?? defaults.from;
const from = DateMath.parse(searchCriteria.dateRange.from)?.toISOString() ?? defaults.from;
const to =
DateMath.parse(searchCriteria.dateRange.to, { roundUp: true })?.valueOf() ?? defaults.to;
DateMath.parse(searchCriteria.dateRange.to, { roundUp: true })?.toISOString() ?? defaults.to;
return { from, to };
}, [searchCriteria.dateRange]);
const getDateRangeAsTimestamp = useCallback(() => {
const parsedDate = getParsedDateRange();
const from = new Date(parsedDate.from).getTime();
const to = new Date(parsedDate.to).getTime();
return { from, to };
}, [getParsedDateRange]);
const buildQuery = useCallback(() => {
return buildEsQuery(dataView, searchCriteria.query, [
...searchCriteria.filters,
@ -116,15 +125,16 @@ export const useUnifiedSearch = () => {
// Track telemetry event on query/filter/date changes
useEffect(() => {
const dateRangeTimestamp = getDateRangeAsTimestamp();
const parsedDateRange = getDateRangeAsTimestamp();
telemetry.reportHostsViewQuerySubmitted(
buildQuerySubmittedPayload({ ...searchCriteria, dateRangeTimestamp })
buildQuerySubmittedPayload({ ...searchCriteria, parsedDateRange })
);
}, [getDateRangeAsTimestamp, searchCriteria, telemetry]);
return {
buildQuery,
onSubmit,
getParsedDateRange,
getDateRangeAsTimestamp,
searchCriteria,
};

View file

@ -13,11 +13,13 @@ import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { enumeration } from '@kbn/securitysolution-io-ts-types';
import { FilterStateStore } from '@kbn/es-query';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { useUrlState } from '../../../../utils/use_url_state';
import {
useKibanaTimefilterTime,
useSyncKibanaTimeFilterTime,
} from '../../../../hooks/use_kibana_timefilter_time';
import { DEFAULT_HOST_LIMIT, LOCAL_STORAGE_HOST_LIMIT_KEY } from '../constants';
const DEFAULT_QUERY = {
language: 'kuery',
@ -32,6 +34,7 @@ const INITIAL_HOSTS_STATE: HostsState = {
filters: [],
panelFilters: [],
dateRange: INITIAL_DATE_RANGE,
limit: DEFAULT_HOST_LIMIT,
};
const reducer = (prevState: HostsState, params: HostsSearchPayload) => {
@ -45,9 +48,17 @@ const reducer = (prevState: HostsState, params: HostsSearchPayload) => {
export const useHostsUrlState = (): [HostsState, HostsStateUpdater] => {
const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE);
const [localStorageHostLimit, setLocalStorageHostLimit] = useLocalStorage<number>(
LOCAL_STORAGE_HOST_LIMIT_KEY,
INITIAL_HOSTS_STATE.limit
);
const [urlState, setUrlState] = useUrlState<HostsState>({
defaultState: { ...INITIAL_HOSTS_STATE, dateRange: getTime() },
defaultState: {
...INITIAL_HOSTS_STATE,
dateRange: getTime(),
limit: localStorageHostLimit ?? INITIAL_HOSTS_STATE.limit,
},
decodeUrlState,
encodeUrlState,
urlStateKey: '_a',
@ -57,6 +68,9 @@ export const useHostsUrlState = (): [HostsState, HostsStateUpdater] => {
const [search, setSearch] = useReducer(reducer, urlState);
if (!deepEqual(search, urlState)) {
setUrlState(search);
if (localStorageHostLimit !== search.limit) {
setLocalStorageHostLimit(search.limit);
}
}
useSyncKibanaTimeFilterTime(INITIAL_DATE_RANGE, urlState.dateRange, (dateRange) =>
@ -110,6 +124,7 @@ const HostsStateRT = rt.type({
panelFilters: HostsFiltersRT,
query: HostsQueryStateRT,
dateRange: StringDateRangeRT,
limit: rt.number,
});
export type HostsState = rt.TypeOf<typeof HostsStateRT>;
@ -118,6 +133,7 @@ export type HostsSearchPayload = Partial<HostsState>;
export type HostsStateUpdater = (params: HostsSearchPayload) => void;
export type StringDateRange = rt.TypeOf<typeof StringDateRangeRT>;
export interface StringDateRangeTimestamp {
from: number;
to: number;

View file

@ -7,7 +7,7 @@
import { Filter } from '@kbn/es-query';
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils';
import { ALERT_STATUS_ALL } from './constants';
import { ALERT_STATUS_ALL, HOST_LIMIT_OPTIONS } from './constants';
export type AlertStatus =
| typeof ALERT_STATUS_ACTIVE
@ -19,3 +19,5 @@ export interface AlertStatusFilter {
query?: Filter['query'];
label: string;
}
export type HostLimitOptions = typeof HOST_LIMIT_OPTIONS[number];

View file

@ -24,6 +24,9 @@ export const METADATA_AGGREGATION: Record<string, estypes.AggregationsAggregatio
{
field: 'cloud.provider',
},
{
field: 'host.ip',
},
],
size: 1,
sort: {

View file

@ -62,7 +62,7 @@
"@kbn/discover-plugin",
"@kbn/observability-alert-details",
"@kbn/observability-shared-plugin",
"@kbn/ui-theme"
"@kbn/ui-theme",
],
"exclude": ["target/**/*"]
}

View file

@ -17335,21 +17335,19 @@
"xpack.infra.hostsViewPage.landing.introTitle": "Présentation : Analyse de l'hôte",
"xpack.infra.hostsViewPage.landing.learnMore": "En savoir plus",
"xpack.infra.hostsViewPage.landing.tryTheFeatureMessage": "Il s'agit d'une version préliminaire de la fonctionnalité, et nous souhaiterions vivement connaître votre avis tandis que nous continuons\n à la développer et à l'améliorer. Pour accéder à cette fonctionnalité, il suffit de l'activer ci-dessous. Ne passez pas à côté\n de cette nouvelle et puissante fonctionnalité ajoutée à notre plateforme... Essayez-la aujourd'hui même !",
"xpack.infra.hostsViewPage.metricTrend.cpu.subtitle": "Moyenne",
"xpack.infra.hostsViewPage.metricTrend.cpu.title": "Utilisation CPU",
"xpack.infra.hostsViewPage.metricTrend.cpu.tooltip": "Moyenne de pourcentage de temps CPU utilisé dans les états autres que Inactif et IOWait, normalisée par le nombre de cœurs de processeur. Inclut le temps passé à la fois sur l'espace utilisateur et sur l'espace du noyau. 100 % signifie que tous les processeurs de l'hôte sont occupés.",
"xpack.infra.hostsViewPage.metricTrend.hostCount.a11y.title": "Utilisation CPU sur la durée.",
"xpack.infra.hostsViewPage.metricTrend.hostCount.title": "Hôtes",
"xpack.infra.hostsViewPage.metricTrend.hostCount.tooltip": "Nombre d'hôtes renvoyé par vos critères de recherche actuels.",
"xpack.infra.hostsViewPage.metricTrend.memory.subtitle": "Moyenne",
"xpack.infra.hostsViewPage.metricTrend.memory.title": "Utilisation mémoire",
"xpack.infra.hostsViewPage.metricTrend.memory.tooltip": "Moyenne de pourcentage d'utilisation de la mémoire principale, en excluant le cache de pages. Cela inclut la mémoire résidente pour tous les processus, plus la mémoire utilisée par les structures et le code du noyau, à l'exception du cache de pages. Un niveau élevé indique une situation de saturation de la mémoire pour un hôte. 100 % signifie que la mémoire principale est entièrement remplie par de la mémoire ne pouvant pas être récupérée, sauf en l'échangeant.",
"xpack.infra.hostsViewPage.metricTrend.rx.subtitle": "Moyenne",
"xpack.infra.hostsViewPage.metricTrend.rx.title": "Réseau entrant (RX)",
"xpack.infra.hostsViewPage.metricTrend.rx.tooltip": "Nombre d'octets qui ont été reçus par seconde sur les interfaces publiques des hôtes.",
"xpack.infra.hostsViewPage.metricTrend.tx.subtitle": "Moyenne",
"xpack.infra.hostsViewPage.metricTrend.tx.title": "Réseau sortant (TX)",
"xpack.infra.hostsViewPage.metricTrend.tx.tooltip": "Nombre d'octets qui ont été envoyés par seconde sur les interfaces publiques des hôtes",
"xpack.infra.hostsViewPage.metricTrend.subtitle.average": "Moyenne",
"xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit": "Moyenne (of {limit} hosts)",
"xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit": "Limited to {limit}",
"xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader": "Total de la mémoire (moy.)",
"xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader": "Utilisation de la mémoire (moy.)",
"xpack.infra.hostsViewPage.table.averageRxColumnHeader": "RX (moy.)",
@ -37970,4 +37968,4 @@
"xpack.painlessLab.title": "Painless Lab",
"xpack.painlessLab.walkthroughButtonLabel": "Présentation"
}
}
}

View file

@ -17334,21 +17334,19 @@
"xpack.infra.hostsViewPage.landing.introTitle": "導入:ホスト分析",
"xpack.infra.hostsViewPage.landing.learnMore": "詳細",
"xpack.infra.hostsViewPage.landing.tryTheFeatureMessage": "この機能は初期バージョンであり、今後継続する中で、開発、改善するうえで皆様からのフィードバックをお願いします\n 。機能にアクセスするには、以下を有効にします。プラットフォームに\n 追加されたこの強力な新機能をお見逃しなく。今すぐお試しください!",
"xpack.infra.hostsViewPage.metricTrend.cpu.subtitle": "平均",
"xpack.infra.hostsViewPage.metricTrend.cpu.title": "CPU 使用状況",
"xpack.infra.hostsViewPage.metricTrend.cpu.tooltip": "アイドルおよびIOWait以外の状態で費やされたCPU時間の割合の平均値を、CPUコア数で正規化したもの。ユーザースペースとカーネルスペースの両方で費やされた時間が含まれます。100はホストのすべてのCPUがビジー状態であることを示します。",
"xpack.infra.hostsViewPage.metricTrend.hostCount.a11y.title": "経時的なCPU使用状況。",
"xpack.infra.hostsViewPage.metricTrend.hostCount.title": "ホスト",
"xpack.infra.hostsViewPage.metricTrend.hostCount.tooltip": "現在の検索条件から返されたホストの数です。",
"xpack.infra.hostsViewPage.metricTrend.memory.subtitle": "平均",
"xpack.infra.hostsViewPage.metricTrend.memory.title": "メモリー使用状況",
"xpack.infra.hostsViewPage.metricTrend.memory.tooltip": "ページキャッシュを除いたメインメモリの割合の平均値。これには、すべてのプロセスの常駐メモリと、ページキャッシュを離れてカーネル構造とコードによって使用されるメモリが含まれます。高レベルは、ホストのメモリが飽和状態にあることを示します。100%とは、メインメモリがすべてスワップアウト以外の、再利用不可能なメモリで満たされていることを意味します。",
"xpack.infra.hostsViewPage.metricTrend.rx.subtitle": "平均",
"xpack.infra.hostsViewPage.metricTrend.rx.title": "ネットワーク受信RX",
"xpack.infra.hostsViewPage.metricTrend.rx.tooltip": "ホストのパブリックインターフェースで1秒間に受信したバイト数。",
"xpack.infra.hostsViewPage.metricTrend.tx.subtitle": "平均",
"xpack.infra.hostsViewPage.metricTrend.tx.title": "ネットワーク送信TX",
"xpack.infra.hostsViewPage.metricTrend.tx.tooltip": "ホストのパブリックインターフェースで1秒間に送信したバイト数",
"xpack.infra.hostsViewPage.metricTrend.subtitle.average": "平均",
"xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit": "平均 (of {limit} hosts)",
"xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit": "Limited to {limit}",
"xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader": "メモリー合計(平均)",
"xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader": "メモリー使用状況(平均)",
"xpack.infra.hostsViewPage.table.averageRxColumnHeader": "RX平均",
@ -37938,4 +37936,4 @@
"xpack.painlessLab.title": "Painless Lab",
"xpack.painlessLab.walkthroughButtonLabel": "実地検証"
}
}
}

View file

@ -17335,21 +17335,19 @@
"xpack.infra.hostsViewPage.landing.introTitle": "即将引入:主机分析",
"xpack.infra.hostsViewPage.landing.learnMore": "了解详情",
"xpack.infra.hostsViewPage.landing.tryTheFeatureMessage": "这是早期版本的功能,我们需要您的反馈,\n 以便继续开发和改进该功能。要访问该功能,直接在下面启用即可。请抓紧时间,\n 了解新添加到我们平台中的这项强大功能 - 立即试用!",
"xpack.infra.hostsViewPage.metricTrend.cpu.subtitle": "平均值",
"xpack.infra.hostsViewPage.metricTrend.cpu.title": "CPU 使用",
"xpack.infra.hostsViewPage.metricTrend.cpu.tooltip": "CPU 在空闲和 IOWait 状态以外所花费时间的平均百分比,按 CPU 核心数进行标准化。包括在用户空间和内核空间上花费的时间。100% 表示主机的所有 CPU 都处于忙碌状态。",
"xpack.infra.hostsViewPage.metricTrend.hostCount.a11y.title": "一段时间的 CPU 使用率。",
"xpack.infra.hostsViewPage.metricTrend.hostCount.title": "主机",
"xpack.infra.hostsViewPage.metricTrend.hostCount.tooltip": "当前搜索条件返回的主机数。",
"xpack.infra.hostsViewPage.metricTrend.memory.subtitle": "平均值",
"xpack.infra.hostsViewPage.metricTrend.memory.title": "内存使用",
"xpack.infra.hostsViewPage.metricTrend.memory.tooltip": "主内存使用率不包括页面缓存的平均百分比。这包括所有进程的常驻内存加上由内核结构和代码使用的内存但不包括页面缓存。高比率表明主机出现内存饱和情况。100% 表示主内存被完全占用,除了进行换出外无法回收内存。",
"xpack.infra.hostsViewPage.metricTrend.rx.subtitle": "平均值",
"xpack.infra.hostsViewPage.metricTrend.rx.title": "网络入站数据 (RX)",
"xpack.infra.hostsViewPage.metricTrend.rx.tooltip": "主机的公共接口上每秒接收的字节数。",
"xpack.infra.hostsViewPage.metricTrend.tx.subtitle": "平均值",
"xpack.infra.hostsViewPage.metricTrend.tx.title": "网络出站数据 (TX)",
"xpack.infra.hostsViewPage.metricTrend.tx.tooltip": "主机的公共接口上每秒发送的字节数",
"xpack.infra.hostsViewPage.metricTrend.subtitle.average": "平均值",
"xpack.infra.hostsViewPage.metricTrend.subtitle.average.limit": "平均值 (of {limit} hosts)",
"xpack.infra.hostsViewPage.metricTrend.subtitle.hostCount.limit": "Limited to {limit}",
"xpack.infra.hostsViewPage.table.averageMemoryTotalColumnHeader": "内存合计(平均值)",
"xpack.infra.hostsViewPage.table.averageMemoryUsageColumnHeader": "内存使用率(平均值)",
"xpack.infra.hostsViewPage.table.averageRxColumnHeader": "RX平均值",
@ -37965,4 +37963,4 @@
"xpack.painlessLab.title": "Painless 实验室",
"xpack.painlessLab.walkthroughButtonLabel": "指导"
}
}
}

View file

@ -89,6 +89,7 @@ export default function ({ getService }: FtrProviderContext) {
metadata: [
{ name: 'host.os.name', value: 'CentOS Linux' },
{ name: 'cloud.provider', value: 'gcp' },
{ name: 'host.ip', value: null },
],
metrics: [
{ name: 'cpu', value: 0.44708333333333333 },
@ -120,6 +121,7 @@ export default function ({ getService }: FtrProviderContext) {
metadata: [
{ name: 'host.os.name', value: 'CentOS Linux' },
{ name: 'cloud.provider', value: 'gcp' },
{ name: 'host.ip', value: null },
],
metrics: [{ name: 'memory', value: 0.4563333333333333 }],
name: 'gke-observability-8--observability-8--bc1afd95-f0zc',
@ -128,6 +130,7 @@ export default function ({ getService }: FtrProviderContext) {
metadata: [
{ name: 'host.os.name', value: 'CentOS Linux' },
{ name: 'cloud.provider', value: 'gcp' },
{ name: 'host.ip', value: null },
],
metrics: [{ name: 'memory', value: 0.32066666666666666 }],
name: 'gke-observability-8--observability-8--bc1afd95-ngmh',
@ -136,6 +139,7 @@ export default function ({ getService }: FtrProviderContext) {
metadata: [
{ name: 'host.os.name', value: 'CentOS Linux' },
{ name: 'cloud.provider', value: 'gcp' },
{ name: 'host.ip', value: null },
],
metrics: [{ name: 'memory', value: 0.2346666666666667 }],
name: 'gke-observability-8--observability-8--bc1afd95-nhhw',