diff --git a/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts b/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts index bcfeeafcee06f..80e5e501169d6 100644 --- a/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts +++ b/x-pack/plugins/infra/common/http_api/infra/get_infra_metrics.ts @@ -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; export type InfraAssetMetadata = rt.TypeOf; +export type InfraAssetMetadataType = rt.TypeOf; export type InfraAssetMetricType = rt.TypeOf; export type InfraAssetMetricsItem = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/hooks/use_lens_attributes.ts b/x-pack/plugins/infra/public/hooks/use_lens_attributes.ts index c9ce48c909f9a..6250d20750e29 100644 --- a/x-pack/plugins/infra/public/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/infra/public/hooks/use_lens_attributes.ts @@ -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 { - const injectedAttributes = injectFilters({ timeRange, filters, query }); + const injectedAttributes = injectFilters({ filters, query }); if (injectedAttributes) { navigateToPrefilledEditor( { diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/chart/lens_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/chart/lens_wrapper.tsx index 9a2472949f54c..34e536aaf37d2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/chart/lens_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/chart/lens_wrapper.tsx @@ -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 (
@@ -83,11 +92,11 @@ export const LensWrapper = ({ style={style} hasTitle={hasTitle} > - {isReady && ( + {state.attributes && ( ; - -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 { 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 ( diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx index 0c965feca8e9e..d42944857af34 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_container.tsx @@ -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'; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/hosts_tile.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/hosts_tile.tsx index 396c0bd72ad71..14a617682bf25 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/hosts_tile.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/hosts_tile.tsx @@ -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 = { + 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 ( ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/kpi_grid.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/kpi_grid.tsx index 2dbd0c4324eca..c3f751d26befb 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/kpi_grid.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/kpi_grid.tsx @@ -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> = [ { 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 ( - - - - - {KPI_CHARTS.map(({ ...chartProp }) => ( - - + + + + - ))} - + {KPI_CHARTS.map(({ ...chartProp }) => ( + + + + ))} + + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx index a95f18b4a10ee..89eebeefd240e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx @@ -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 ( )} @@ -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; } `; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/controls_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/controls_content.tsx similarity index 79% rename from x-pack/plugins/infra/public/pages/metrics/hosts/components/controls_content.tsx rename to x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/controls_content.tsx index a3e82b9901422..e2bd7d0c74dae 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/controls_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/controls_content.tsx @@ -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 = ({ dataView, filters, query, + selectedOptions, timeRange, onFiltersChange, }) => { @@ -55,15 +57,21 @@ export const ControlsContent: React.FC = ({ 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(() => { diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/limit_options.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/limit_options.tsx new file mode 100644 index 0000000000000..1d8ce4f9c9e87 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/limit_options.tsx @@ -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 ( + + + + + + + + + + + + + + + onChange(value)} + /> + + + ); +}; + +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`, +})); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx new file mode 100644 index 0000000000000..ef515cc018839 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx @@ -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 ( + + + + 0.5)', + })} + onQuerySubmit={handleRefresh} + showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)} + showDatePicker + showFilterBar + showQueryInput + showQueryMenu + useDefaultBehaviors + /> + + + + + + + + + + + + + + + ); +}; + +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 ( + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx index d5cc0b0f021d7..6813dee1caa10 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx @@ -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; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx index 28d07b94d9437..f81228957107a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx @@ -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, }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metrics_grid.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metrics_grid.tsx index e307dde0d09e5..7f3dac7a3af16 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metrics_grid.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metrics_grid.tsx @@ -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 & { fullRo export const MetricsGrid = React.memo(() => { return ( - - - - - - {DEFAULT_BREAKDOWN_SIZE}, - attribute: name, - }} - /> - - - - - - - {CHARTS_IN_ORDER.map(({ fullRow, ...chartProp }) => ( - - - - ))} - - - + + {CHARTS_IN_ORDER.map(({ fullRow, ...chartProp }) => ( + + + + ))} + ); }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx deleted file mode 100644 index 168f825a9d2d1..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx +++ /dev/null @@ -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 ( - - 0.5)', - })} - onQuerySubmit={handleRefresh} - showSaveQuery={Boolean(application?.capabilities?.visualize?.saveQuery)} - showDatePicker - showFilterBar - showQueryInput - showQueryMenu - useDefaultBehaviors - /> - - - - ); -}; - -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 ( - - ); -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts index b854120a86887..69cfc446d0095 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/constants.ts @@ -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; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts index 7a895591d68c7..200bff521d86a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts @@ -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); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts index 94e3a963075be..83fedf4292937 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_data_view.ts @@ -72,6 +72,7 @@ export const useDataView = ({ metricAlias }: { metricAlias: string }) => { }, [hasError, notifications, metricAlias]); return { + metricAlias, dataView, loading, hasError, diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts new file mode 100644 index 0000000000000..5575c46e621f1 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_host_count.ts @@ -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>>> +) => + 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, + }), +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts index a921a0daeb011..5619a788b19a7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.test.ts @@ -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', }, diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx index 2d2d6c9d7f8e4..7350f402c57ec 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_table.tsx @@ -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; -type HostMetric = 'cpu' | 'diskLatency' | 'rx' | 'tx' | 'memory' | 'memoryTotal'; - -type HostMetrics = Record; - -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 + ); + + 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'] => { diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_view.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_view.ts index d0df961dc7ef9..f84acf5931ea1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_view.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_view.ts @@ -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(`${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(), }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts index 950bd9cf3c94e..e242f58054c6c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts @@ -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, }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search_url_state.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search_url_state.ts index 861f3c26472e8..bae9f2ed3f713 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search_url_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search_url_state.ts @@ -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( + LOCAL_STORAGE_HOST_LIMIT_KEY, + INITIAL_HOSTS_STATE.limit + ); const [urlState, setUrlState] = useUrlState({ - 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; @@ -118,6 +133,7 @@ export type HostsSearchPayload = Partial; export type HostsStateUpdater = (params: HostsSearchPayload) => void; +export type StringDateRange = rt.TypeOf; export interface StringDateRangeTimestamp { from: number; to: number; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts index 6b948fb0da6c9..080b47f54d4da 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/types.ts @@ -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]; diff --git a/x-pack/plugins/infra/server/routes/infra/lib/constants.ts b/x-pack/plugins/infra/server/routes/infra/lib/constants.ts index bc9131d1d52fc..f39eaafdd039e 100644 --- a/x-pack/plugins/infra/server/routes/infra/lib/constants.ts +++ b/x-pack/plugins/infra/server/routes/infra/lib/constants.ts @@ -24,6 +24,9 @@ export const METADATA_AGGREGATION: Record