diff --git a/x-pack/plugins/infra/common/http_api/host_details/process_list.ts b/x-pack/plugins/infra/common/http_api/host_details/process_list.ts index 4b4a0a54b9d13..3141d100307c4 100644 --- a/x-pack/plugins/infra/common/http_api/host_details/process_list.ts +++ b/x-pack/plugins/infra/common/http_api/host_details/process_list.ts @@ -5,16 +5,139 @@ */ import * as rt from 'io-ts'; -import { MetricsAPITimerangeRT, MetricsAPISeriesRT } from '../metrics_api'; +import { MetricsAPISeriesRT, MetricsAPIRow } from '../metrics_api'; + +const AggValueRT = rt.type({ + value: rt.number, +}); export const ProcessListAPIRequestRT = rt.type({ hostTerm: rt.record(rt.string, rt.string), - timerange: MetricsAPITimerangeRT, + timefield: rt.string, indexPattern: rt.string, + to: rt.number, + sortBy: rt.type({ + name: rt.string, + isAscending: rt.boolean, + }), + searchFilter: rt.array(rt.record(rt.string, rt.record(rt.string, rt.unknown))), }); -export const ProcessListAPIResponseRT = rt.array(MetricsAPISeriesRT); +export const ProcessListAPIQueryAggregationRT = rt.type({ + summaryEvent: rt.type({ + summary: rt.type({ + hits: rt.type({ + hits: rt.array( + rt.type({ + _source: rt.type({ + system: rt.type({ + process: rt.type({ + summary: rt.record(rt.string, rt.number), + }), + }), + }), + }) + ), + }), + }), + }), + processes: rt.type({ + filteredProcs: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.string, + cpu: AggValueRT, + memory: AggValueRT, + startTime: rt.type({ + value_as_string: rt.string, + }), + meta: rt.type({ + hits: rt.type({ + hits: rt.array( + rt.type({ + _source: rt.type({ + process: rt.type({ + pid: rt.number, + }), + system: rt.type({ + process: rt.type({ + state: rt.string, + }), + }), + user: rt.type({ + name: rt.string, + }), + }), + }) + ), + }), + }), + }) + ), + }), + }), +}); + +export const ProcessListAPIResponseRT = rt.type({ + processList: rt.array( + rt.type({ + cpu: rt.number, + memory: rt.number, + startTime: rt.number, + pid: rt.number, + state: rt.string, + user: rt.string, + command: rt.string, + }) + ), + summary: rt.record(rt.string, rt.number), +}); + +export type ProcessListAPIQueryAggregation = rt.TypeOf; export type ProcessListAPIRequest = rt.TypeOf; export type ProcessListAPIResponse = rt.TypeOf; + +export const ProcessListAPIChartRequestRT = rt.type({ + hostTerm: rt.record(rt.string, rt.string), + timefield: rt.string, + indexPattern: rt.string, + to: rt.number, + command: rt.string, +}); + +export const ProcessListAPIChartQueryAggregationRT = rt.type({ + process: rt.type({ + filteredProc: rt.type({ + buckets: rt.array( + rt.type({ + timeseries: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.number, + memory: AggValueRT, + cpu: AggValueRT, + }) + ), + }), + }) + ), + }), + }), +}); + +export const ProcessListAPIChartResponseRT = rt.type({ + cpu: MetricsAPISeriesRT, + memory: MetricsAPISeriesRT, +}); + +export type ProcessListAPIChartQueryAggregation = rt.TypeOf< + typeof ProcessListAPIChartQueryAggregationRT +>; + +export type ProcessListAPIChartRequest = rt.TypeOf; + +export type ProcessListAPIChartResponse = rt.TypeOf; + +export type ProcessListAPIRow = MetricsAPIRow; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx index 836d491e6210e..6ee3c9f1fae80 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx @@ -5,16 +5,28 @@ */ import React, { useMemo, useState } from 'react'; +import { debounce } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { EuiSearchBar, EuiSpacer, EuiEmptyPrompt, EuiButton, Query } from '@elastic/eui'; -import { useProcessList } from '../../../../hooks/use_process_list'; +import { EuiSearchBar, EuiSpacer, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { + useProcessList, + SortBy, + ProcessListContextProvider, +} from '../../../../hooks/use_process_list'; import { TabContent, TabProps } from '../shared'; import { STATE_NAMES } from './states'; import { SummaryTable } from './summary_table'; import { ProcessesTable } from './processes_table'; +import { parseSearchString } from './parse_search_string'; const TabComponent = ({ currentTime, node, nodeType, options }: TabProps) => { - const [searchFilter, setSearchFilter] = useState(EuiSearchBar.Query.MATCH_ALL); + const [searchFilter, setSearchFilter] = useState(''); + const [sortBy, setSortBy] = useState({ + name: 'cpu', + isAscending: false, + }); + + const timefield = options.fields!.timestamp; const hostTerm = useMemo(() => { const field = @@ -26,69 +38,80 @@ const TabComponent = ({ currentTime, node, nodeType, options }: TabProps) => { const { loading, error, response, makeRequest: reload } = useProcessList( hostTerm, - 'metricbeat-*', - options.fields!.timestamp, - currentTime + timefield, + currentTime, + sortBy, + parseSearchString(searchFilter) ); - if (error) { - return ( - - - {i18n.translate('xpack.infra.metrics.nodeDetails.processListError', { - defaultMessage: 'Unable to show process data', - })} - - } - actions={ - - {i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', { - defaultMessage: 'Try again', - })} - - } - /> - - ); - } + const debouncedSearchOnChange = useMemo( + () => + debounce<(props: { queryText: string }) => void>( + ({ queryText }) => setSearchFilter(queryText), + 500 + ), + [setSearchFilter] + ); return ( - - - setSearchFilter(query ?? EuiSearchBar.Query.MATCH_ALL)} - box={{ - incremental: true, - placeholder: i18n.translate('xpack.infra.metrics.nodeDetails.searchForProcesses', { - defaultMessage: 'Search for processes…', - }), - }} - filters={[ - { - type: 'field_value_selection', - field: 'state', - name: 'State', - operator: 'exact', - multiSelect: false, - options: Object.entries(STATE_NAMES).map(([value, view]: [string, string]) => ({ - value, - view, - })), - }, - ]} - /> - - + + + + ({ + value, + view, + })), + }, + ]} + /> + + {!error ? ( + + ) : ( + + {i18n.translate('xpack.infra.metrics.nodeDetails.processListError', { + defaultMessage: 'Unable to show process data', + })} + + } + actions={ + + {i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', { + defaultMessage: 'Try again', + })} + + } + /> + )} + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_process_list.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_process_list.ts deleted file mode 100644 index 88584ef2987e1..0000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_process_list.ts +++ /dev/null @@ -1,55 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ProcessListAPIResponse } from '../../../../../../../../common/http_api'; -import { Process } from './types'; - -export const parseProcessList = (processList: ProcessListAPIResponse) => - processList.map((process) => { - const command = process.id; - let mostRecentPoint; - for (let i = process.rows.length - 1; i >= 0; i--) { - const point = process.rows[i]; - if (point && Array.isArray(point.meta) && point.meta?.length) { - mostRecentPoint = point; - break; - } - } - if (!mostRecentPoint) return { command, cpu: null, memory: null, startTime: null, state: null }; - - const { cpu, memory } = mostRecentPoint; - const { system, process: processMeta, user } = (mostRecentPoint.meta as any[])[0]; - const startTime = system.process.cpu.start_time; - const state = system.process.state; - - const timeseries = { - cpu: pickTimeseries(process.rows, 'cpu'), - memory: pickTimeseries(process.rows, 'memory'), - }; - - return { - command, - cpu, - memory, - startTime, - state, - pid: processMeta.pid, - user: user.name, - timeseries, - } as Process; - }); - -const pickTimeseries = (rows: any[], metricID: string) => ({ - rows: rows.map((row) => ({ - timestamp: row.timestamp, - metric_0: row[metricID], - })), - columns: [ - { name: 'timestamp', type: 'date' }, - { name: 'metric_0', type: 'number' }, - ], - id: metricID, -}); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_search_string.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_search_string.ts new file mode 100644 index 0000000000000..455656306c8d0 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_search_string.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export const parseSearchString = (query: string) => { + if (query.trim() === '') { + return [ + { + match_all: {}, + }, + ]; + } + const elements = query + .split(' ') + .map((s) => s.trim()) + .filter(Boolean); + const stateFilter = elements.filter((s) => s.startsWith('state=')); + const cmdlineFilters = elements.filter((s) => !s.startsWith('state=')); + return [ + ...cmdlineFilters.map((clause) => ({ + query_string: { + fields: ['system.process.cmdline'], + query: `*${escapeReservedCharacters(clause)}*`, + minimum_should_match: 1, + }, + })), + ...stateFilter.map((state) => ({ + match: { + 'system.process.state': state.replace('state=', ''), + }, + })), + ]; +}; + +const escapeReservedCharacters = (clause: string) => + clause.replace(/([+-=!\(\)\{\}\[\]^"~*?:\\/!]|&&|\|\|)/g, '\\$1'); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx index bbf4a25fc49a7..4718ed09dc9b2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useMemo } from 'react'; -import moment from 'moment'; -import { first, last } from 'lodash'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTableRow, @@ -22,18 +20,10 @@ import { EuiButton, EuiSpacer, } from '@elastic/eui'; -import { Axis, Chart, Settings, Position, TooltipValue, niceTimeFormatter } from '@elastic/charts'; import { AutoSizer } from '../../../../../../../components/auto_sizer'; -import { createFormatter } from '../../../../../../../../common/formatters'; -import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { getChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; -import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; -import { MetricsExplorerChartType } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; -import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; -import { MetricsExplorerAggregation } from '../../../../../../../../common/http_api'; -import { Color } from '../../../../../../../../common/color_palette'; import { euiStyled } from '../../../../../../../../../observability/public'; import { Process } from './types'; +import { ProcessRowCharts } from './process_row_charts'; interface Props { cells: React.ReactNode[]; @@ -118,26 +108,7 @@ export const ProcessRow = ({ cells, item }: Props) => { {item.user} - - {cpuMetricLabel} - - - - - - {memoryMetricLabel} - - - - + @@ -149,76 +120,6 @@ export const ProcessRow = ({ cells, item }: Props) => { ); }; -interface ProcessChartProps { - timeseries: Process['timeseries']['x']; - color: Color; - label: string; -} -const ProcessChart = ({ timeseries, color, label }: ProcessChartProps) => { - const chartMetric = { - color, - aggregation: 'avg' as MetricsExplorerAggregation, - label, - }; - const isDarkMode = useUiSetting('theme:darkMode'); - - const dateFormatter = useMemo(() => { - if (!timeseries) return () => ''; - const firstTimestamp = first(timeseries.rows)?.timestamp; - const lastTimestamp = last(timeseries.rows)?.timestamp; - - if (firstTimestamp == null || lastTimestamp == null) { - return (value: number) => `${value}`; - } - - return niceTimeFormatter([firstTimestamp, lastTimestamp]); - }, [timeseries]); - - const yAxisFormatter = createFormatter('percent'); - - const tooltipProps = { - headerFormatter: (tooltipValue: TooltipValue) => - moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), - }; - - const dataDomain = calculateDomain(timeseries, [chartMetric], false); - const domain = dataDomain - ? { - max: dataDomain.max * 1.1, // add 10% headroom. - min: dataDomain.min, - } - : { max: 0, min: 0 }; - - return ( - - - - - - - - - ); -}; - export const CodeLine = euiStyled(EuiCode).attrs({ transparentBackground: true, })` @@ -246,22 +147,3 @@ const ExpandedRowCell = euiStyled(EuiTableRowCell).attrs({ padding: 0 ${(props) => props.theme.eui.paddingSizes.m}; background-color: ${(props) => props.theme.eui.euiColorLightestShade}; `; - -const ChartContainer = euiStyled.div` - width: 300px; - height: 140px; -`; - -const cpuMetricLabel = i18n.translate( - 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelCPU', - { - defaultMessage: 'CPU', - } -); - -const memoryMetricLabel = i18n.translate( - 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelMemory', - { - defaultMessage: 'Memory', - } -); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx new file mode 100644 index 0000000000000..7b7a285b5d6b8 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import moment from 'moment'; +import { first, last } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlexItem, + EuiLoadingChart, + EuiEmptyPrompt, + EuiText, +} from '@elastic/eui'; +import { Axis, Chart, Settings, Position, TooltipValue, niceTimeFormatter } from '@elastic/charts'; +import { createFormatter } from '../../../../../../../../common/formatters'; +import { useUiSetting } from '../../../../../../../../../../../src/plugins/kibana_react/public'; +import { getChartTheme } from '../../../../../metrics_explorer/components/helpers/get_chart_theme'; +import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; +import { MetricsExplorerChartType } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; +import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; +import { MetricsExplorerAggregation } from '../../../../../../../../common/http_api'; +import { Color } from '../../../../../../../../common/color_palette'; +import { euiStyled } from '../../../../../../../../../observability/public'; +import { useProcessListRowChart } from '../../../../hooks/use_process_list_row_chart'; +import { Process } from './types'; + +interface Props { + command: string; +} + +export const ProcessRowCharts = ({ command }: Props) => { + const { loading, error, response } = useProcessListRowChart(command); + + const isLoading = loading || !response; + + const cpuChart = error ? ( + {failedToLoadChart}} /> + ) : isLoading ? ( + + ) : ( + + ); + const memoryChart = error ? ( + {failedToLoadChart}} /> + ) : isLoading ? ( + + ) : ( + + ); + + return ( + <> + + {cpuMetricLabel} + {cpuChart} + + + {memoryMetricLabel} + {memoryChart} + + + ); +}; + +interface ProcessChartProps { + timeseries: Process['timeseries']['x']; + color: Color; + label: string; +} +const ProcessChart = ({ timeseries, color, label }: ProcessChartProps) => { + const chartMetric = { + color, + aggregation: 'avg' as MetricsExplorerAggregation, + label, + }; + const isDarkMode = useUiSetting('theme:darkMode'); + + const dateFormatter = useMemo(() => { + if (!timeseries) return () => ''; + const firstTimestamp = first(timeseries.rows)?.timestamp; + const lastTimestamp = last(timeseries.rows)?.timestamp; + + if (firstTimestamp == null || lastTimestamp == null) { + return (value: number) => `${value}`; + } + + return niceTimeFormatter([firstTimestamp, lastTimestamp]); + }, [timeseries]); + + const yAxisFormatter = createFormatter('percent'); + + const tooltipProps = { + headerFormatter: (tooltipValue: TooltipValue) => + moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), + }; + + const dataDomain = calculateDomain(timeseries, [chartMetric], false); + const domain = dataDomain + ? { + max: dataDomain.max * 1.1, // add 10% headroom. + min: dataDomain.min, + } + : { max: 0, min: 0 }; + + return ( + + + + + + + + + ); +}; + +const ChartContainer = euiStyled.div` + width: 300px; + height: 140px; +`; + +const cpuMetricLabel = i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelCPU', + { + defaultMessage: 'CPU', + } +); + +const memoryMetricLabel = i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelMemory', + { + defaultMessage: 'Memory', + } +); + +const failedToLoadChart = i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.failedToLoadChart', + { + defaultMessage: 'Unable to load chart', + } +); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx index 43f3a333fda83..1952ba947761c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { omit } from 'lodash'; import { i18n } from '@kbn/i18n'; import { @@ -13,10 +13,8 @@ import { EuiTableBody, EuiTableHeaderCell, EuiTableRowCell, - EuiSpacer, - EuiTablePagination, EuiLoadingChart, - Query, + EuiEmptyPrompt, SortableProperties, LEFT_ALIGNMENT, RIGHT_ALIGNMENT, @@ -24,17 +22,18 @@ import { import { ProcessListAPIResponse } from '../../../../../../../../common/http_api'; import { FORMATTERS } from '../../../../../../../../common/formatters'; import { euiStyled } from '../../../../../../../../../observability/public'; +import { SortBy } from '../../../../hooks/use_process_list'; import { Process } from './types'; import { ProcessRow, CodeLine } from './process_row'; -import { parseProcessList } from './parse_process_list'; import { StateBadge } from './state_badge'; import { STATE_ORDER } from './states'; interface TableProps { - processList: ProcessListAPIResponse; + processList: ProcessListAPIResponse['processList']; currentTime: number; isLoading: boolean; - searchFilter: Query; + sortBy: SortBy; + setSortBy: (s: SortBy) => void; } function useSortableProperties( @@ -43,25 +42,21 @@ function useSortableProperties( getValue: (obj: T) => any; isAscending: boolean; }>, - defaultSortProperty: string + defaultSortProperty: string, + callback: (s: SortBy) => void ) { const [sortableProperties] = useState>( new SortableProperties(sortablePropertyItems, defaultSortProperty) ); - const [sortedColumn, setSortedColumn] = useState( - omit(sortableProperties.getSortedProperty(), 'getValue') - ); return { - setSortedColumn: useCallback( + updateSortableProperties: useCallback( (property) => { sortableProperties.sortOn(property); - setSortedColumn(omit(sortableProperties.getSortedProperty(), 'getValue')); + callback(omit(sortableProperties.getSortedProperty(), 'getValue')); }, - [sortableProperties] + [sortableProperties, callback] ), - sortedColumn, - sortItems: (items: T[]) => sortableProperties.sortItems(items), }; } @@ -69,28 +64,15 @@ export const ProcessesTable = ({ processList, currentTime, isLoading, - searchFilter, + sortBy, + setSortBy, }: TableProps) => { - const [currentPage, setCurrentPage] = useState(0); - const [itemsPerPage, setItemsPerPage] = useState(10); - useEffect(() => setCurrentPage(0), [processList, searchFilter, itemsPerPage]); - - const { sortedColumn, sortItems, setSortedColumn } = useSortableProperties( + const { updateSortableProperties } = useSortableProperties( [ - { - name: 'state', - getValue: (item: any) => STATE_ORDER.indexOf(item.state), - isAscending: true, - }, - { - name: 'command', - getValue: (item: any) => item.command.toLowerCase(), - isAscending: true, - }, { name: 'startTime', getValue: (item: any) => Date.parse(item.startTime), - isAscending: false, + isAscending: true, }, { name: 'cpu', @@ -103,32 +85,34 @@ export const ProcessesTable = ({ isAscending: false, }, ], - 'state' + 'cpu', + setSortBy ); - const currentItems = useMemo(() => { - const filteredItems = Query.execute(searchFilter, parseProcessList(processList)) as Process[]; - if (!filteredItems.length) return []; - const sortedItems = sortItems(filteredItems); - return sortedItems; - }, [processList, searchFilter, sortItems]); - - const pageCount = useMemo(() => Math.ceil(currentItems.length / itemsPerPage), [ - itemsPerPage, - currentItems, - ]); - - const pageStartIdx = useMemo(() => currentPage * itemsPerPage + (currentPage > 0 ? 1 : 0), [ - currentPage, - itemsPerPage, - ]); - const currentItemsPage = useMemo( - () => currentItems.slice(pageStartIdx, pageStartIdx + itemsPerPage), - [pageStartIdx, currentItems, itemsPerPage] + const currentItems = useMemo( + () => + processList.sort( + (a, b) => STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state) + ) as Process[], + [processList] ); if (isLoading) return ; + if (currentItems.length === 0) + return ( + + {i18n.translate('xpack.infra.metrics.nodeDetails.noProcesses', { + defaultMessage: 'No processes matched these search terms', + })} + + } + /> + ); + return ( <> @@ -139,27 +123,18 @@ export const ProcessesTable = ({ key={`${String(column.field)}-header`} align={column.align ?? LEFT_ALIGNMENT} width={column.width} - onSort={column.sortable ? () => setSortedColumn(column.field) : undefined} - isSorted={sortedColumn.name === column.field} - isSortAscending={sortedColumn.name === column.field && sortedColumn.isAscending} + onSort={column.sortable ? () => updateSortableProperties(column.field) : undefined} + isSorted={sortBy.name === column.field} + isSortAscending={sortBy.name === column.field && sortBy.isAscending} > {column.name} ))} - + - - ); }; @@ -213,8 +188,8 @@ const StyledTableBody = euiStyled(EuiTableBody)` const ONE_MINUTE = 60 * 1000; const ONE_HOUR = ONE_MINUTE * 60; -const RuntimeCell = ({ startTime, currentTime }: { startTime: string; currentTime: number }) => { - const runtimeLength = currentTime - Date.parse(startTime); +const RuntimeCell = ({ startTime, currentTime }: { startTime: number; currentTime: number }) => { + const runtimeLength = currentTime - startTime; let remainingRuntimeMS = runtimeLength; const runtimeHours = Math.floor(remainingRuntimeMS / ONE_HOUR); remainingRuntimeMS -= runtimeHours * ONE_HOUR; @@ -244,7 +219,7 @@ const columns: Array<{ name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelState', { defaultMessage: 'State', }), - sortable: true, + sortable: false, render: (state: string) => , width: 84, textOnly: false, @@ -254,7 +229,7 @@ const columns: Array<{ name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCommand', { defaultMessage: 'Command', }), - sortable: true, + sortable: false, width: '40%', render: (command: string) => {command}, }, @@ -265,7 +240,7 @@ const columns: Array<{ }), align: RIGHT_ALIGNMENT, sortable: true, - render: (startTime: string, currentTime: number) => ( + render: (startTime: number, currentTime: number) => ( ), }, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx index 59becb0bf534d..6efabf1b8c0ae 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx @@ -5,16 +5,15 @@ */ import React, { useMemo } from 'react'; -import { mapValues, countBy } from 'lodash'; +import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiLoadingSpinner, EuiBasicTableColumn } from '@elastic/eui'; import { euiStyled } from '../../../../../../../../../observability/public'; import { ProcessListAPIResponse } from '../../../../../../../../common/http_api'; -import { parseProcessList } from './parse_process_list'; import { STATE_NAMES } from './states'; interface Props { - processList: ProcessListAPIResponse; + processSummary: ProcessListAPIResponse['summary']; isLoading: boolean; } @@ -22,18 +21,17 @@ type SummaryColumn = { total: number; } & Record; -export const SummaryTable = ({ processList, isLoading }: Props) => { - const parsedList = parseProcessList(processList); +export const SummaryTable = ({ processSummary, isLoading }: Props) => { const processCount = useMemo( () => [ { - total: isLoading ? -1 : parsedList.length, + total: isLoading ? -1 : processSummary.total, ...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)), - ...(isLoading ? [] : countBy(parsedList, 'state')), + ...(isLoading ? {} : processSummary), }, ] as SummaryColumn[], - [parsedList, isLoading] + [processSummary, isLoading] ); return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts index 8e0843fe8b278..888c4321a1905 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts @@ -3,20 +3,32 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import createContainter from 'constate'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { ProcessListAPIResponse, ProcessListAPIResponseRT } from '../../../../../common/http_api'; import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; import { useHTTPRequest } from '../../../../hooks/use_http_request'; +import { useSourceContext } from '../../../../containers/source'; + +export interface SortBy { + name: string; + isAscending: boolean; +} export function useProcessList( hostTerm: Record, - indexPattern: string, timefield: string, - to: number + to: number, + sortBy: SortBy, + searchFilter: object ) { + const { createDerivedIndexPattern } = useSourceContext(); + const indexPattern = createDerivedIndexPattern('metrics').title; + + const [inErrorState, setInErrorState] = useState(false); const decodeResponse = (response: any) => { return pipe( ProcessListAPIResponseRT.decode(response), @@ -24,32 +36,52 @@ export function useProcessList( ); }; - const timerange = { - field: timefield, - interval: 'modules', - to, - from: to - 15 * 60 * 1000, // 15 minutes - }; + const parsedSortBy = + sortBy.name === 'runtimeLength' + ? { + ...sortBy, + name: 'startTime', + } + : sortBy; const { error, loading, response, makeRequest } = useHTTPRequest( '/api/metrics/process_list', 'POST', JSON.stringify({ hostTerm, - timerange, + timefield, indexPattern, + to, + sortBy: parsedSortBy, + searchFilter, }), decodeResponse ); + useEffect(() => setInErrorState(true), [error]); + useEffect(() => setInErrorState(false), [loading]); + useEffect(() => { makeRequest(); }, [makeRequest]); return { - error, + error: inErrorState, loading, response, makeRequest, }; } + +function useProcessListParams(props: { + hostTerm: Record; + timefield: string; + to: number; +}) { + const { hostTerm, timefield, to } = props; + const { createDerivedIndexPattern } = useSourceContext(); + const indexPattern = createDerivedIndexPattern('metrics').title; + return { hostTerm, indexPattern, timefield, to }; +} +const ProcessListContext = createContainter(useProcessListParams); +export const [ProcessListContextProvider, useProcessListContext] = ProcessListContext; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list_row_chart.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list_row_chart.ts new file mode 100644 index 0000000000000..ef638319fd9f4 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list_row_chart.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { useEffect, useState } from 'react'; +import { + ProcessListAPIChartResponse, + ProcessListAPIChartResponseRT, +} from '../../../../../common/http_api'; +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; +import { useHTTPRequest } from '../../../../hooks/use_http_request'; +import { useProcessListContext } from './use_process_list'; + +export function useProcessListRowChart(command: string) { + const [inErrorState, setInErrorState] = useState(false); + const decodeResponse = (response: any) => { + return pipe( + ProcessListAPIChartResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); + }; + const { hostTerm, timefield, indexPattern, to } = useProcessListContext(); + + const { error, loading, response, makeRequest } = useHTTPRequest( + '/api/metrics/process_list/chart', + 'POST', + JSON.stringify({ + hostTerm, + timefield, + indexPattern, + to, + command, + }), + decodeResponse + ); + + useEffect(() => setInErrorState(true), [error]); + useEffect(() => setInErrorState(false), [loading]); + + useEffect(() => { + makeRequest(); + }, [makeRequest]); + + return { + error: inErrorState, + loading, + response, + makeRequest, + }; +} diff --git a/x-pack/plugins/infra/server/lib/host_details/common.ts b/x-pack/plugins/infra/server/lib/host_details/common.ts new file mode 100644 index 0000000000000..ddf606e417126 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/host_details/common.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export const CMDLINE_FIELD = 'system.process.cmdline'; diff --git a/x-pack/plugins/infra/server/lib/host_details/process_list.ts b/x-pack/plugins/infra/server/lib/host_details/process_list.ts index 99e8b2e8f6ab1..e9d35f3601634 100644 --- a/x-pack/plugins/infra/server/lib/host_details/process_list.ts +++ b/x-pack/plugins/infra/server/lib/host_details/process_list.ts @@ -4,61 +4,136 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ProcessListAPIRequest, MetricsAPIRequest } from '../../../common/http_api'; -import { getAllMetricsData } from '../../utils/get_all_metrics_data'; -import { query } from '../metrics'; +import { ProcessListAPIRequest, ProcessListAPIQueryAggregation } from '../../../common/http_api'; import { ESSearchClient } from '../metrics/types'; +import { CMDLINE_FIELD } from './common'; + +const TOP_N = 10; export const getProcessList = async ( - client: ESSearchClient, - { hostTerm, timerange, indexPattern }: ProcessListAPIRequest + search: ESSearchClient, + { hostTerm, timefield, indexPattern, to, sortBy, searchFilter }: ProcessListAPIRequest ) => { - const queryBody = { - timerange, - modules: ['system.cpu', 'system.memory'], - groupBy: ['system.process.cmdline'], - filters: [{ term: hostTerm }], - indexPattern, - limit: 9, - metrics: [ - { - id: 'cpu', - aggregations: { - cpu: { - avg: { - field: 'system.process.cpu.total.norm.pct', + const body = { + size: 0, + query: { + bool: { + filter: [ + { + range: { + [timefield]: { + gte: to - 60 * 1000, // 1 minute + lte: to, + }, }, }, - }, + { + term: hostTerm, + }, + ], }, - { - id: 'memory', - aggregations: { - memory: { - avg: { - field: 'system.process.memory.rss.pct', - }, + }, + aggs: { + summaryEvent: { + filter: { + term: { + 'event.dataset': 'system.process.summary', }, }, - }, - { - id: 'meta', - aggregations: { - meta: { + aggs: { + summary: { top_hits: { size: 1, - sort: [{ [timerange.field]: { order: 'desc' } }], - _source: [ - 'system.process.cpu.start_time', - 'system.process.state', - 'process.pid', - 'user.name', + sort: [ + { + [timefield]: { + order: 'desc', + }, + }, ], + _source: ['system.process.summary'], + }, + }, + }, + }, + processes: { + filter: { + bool: { + must: searchFilter ?? [{ match_all: {} }], + }, + }, + aggs: { + filteredProcs: { + terms: { + field: CMDLINE_FIELD, + size: TOP_N, + order: { + [sortBy.name]: sortBy.isAscending ? 'asc' : 'desc', + }, + }, + aggs: { + cpu: { + avg: { + field: 'system.process.cpu.total.pct', + }, + }, + memory: { + avg: { + field: 'system.process.memory.rss.pct', + }, + }, + startTime: { + max: { + field: 'system.process.cpu.start_time', + }, + }, + meta: { + top_hits: { + size: 1, + sort: [ + { + [timefield]: { + order: 'desc', + }, + }, + ], + _source: ['system.process.state', 'user.name', 'process.pid'], + }, + }, }, }, }, }, - ], - } as MetricsAPIRequest; - return await getAllMetricsData((body: MetricsAPIRequest) => query(client, body), queryBody); + }, + }; + try { + const result = await search<{}, ProcessListAPIQueryAggregation>({ + body, + index: indexPattern, + }); + const { buckets: processListBuckets } = result.aggregations!.processes.filteredProcs; + const processList = processListBuckets.map((bucket) => { + const meta = bucket.meta.hits.hits[0]._source; + + return { + cpu: bucket.cpu.value, + memory: bucket.memory.value, + startTime: Date.parse(bucket.startTime.value_as_string), + pid: meta.process.pid, + state: meta.system.process.state, + user: meta.user.name, + command: bucket.key, + }; + }); + const { + summary, + } = result.aggregations!.summaryEvent.summary.hits.hits[0]._source.system.process; + + return { + processList, + summary, + }; + } catch (e) { + throw e; + } }; diff --git a/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts b/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts new file mode 100644 index 0000000000000..11df1937764c8 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/host_details/process_list_chart.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first } from 'lodash'; +import { + ProcessListAPIChartRequest, + ProcessListAPIChartQueryAggregation, + ProcessListAPIRow, + ProcessListAPIChartResponse, +} from '../../../common/http_api'; +import { ESSearchClient } from '../metrics/types'; +import { CMDLINE_FIELD } from './common'; + +export const getProcessListChart = async ( + search: ESSearchClient, + { hostTerm, timefield, indexPattern, to, command }: ProcessListAPIChartRequest +) => { + const body = { + size: 0, + query: { + bool: { + filter: [ + { + range: { + [timefield]: { + gte: to - 60 * 1000, // 1 minute + lte: to, + }, + }, + }, + { + term: hostTerm, + }, + ], + }, + }, + aggs: { + process: { + filter: { + bool: { + must: [ + { + match: { + [CMDLINE_FIELD]: command, + }, + }, + ], + }, + }, + aggs: { + filteredProc: { + terms: { + field: CMDLINE_FIELD, + size: 1, + }, + aggs: { + timeseries: { + date_histogram: { + field: timefield, + fixed_interval: '1m', + extended_bounds: { + min: to - 60 * 15 * 1000, // 15 minutes, + max: to, + }, + }, + aggs: { + cpu: { + avg: { + field: 'system.process.cpu.total.pct', + }, + }, + memory: { + avg: { + field: 'system.process.memory.rss.pct', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + try { + const result = await search<{}, ProcessListAPIChartQueryAggregation>({ + body, + index: indexPattern, + }); + const { buckets } = result.aggregations!.process.filteredProc; + const timeseries = first( + buckets.map((bucket) => + bucket.timeseries.buckets.reduce( + (tsResult, tsBucket) => { + tsResult.cpu.rows.push({ + metric_0: tsBucket.cpu.value, + timestamp: tsBucket.key, + }); + tsResult.memory.rows.push({ + metric_0: tsBucket.memory.value, + timestamp: tsBucket.key, + }); + return tsResult; + }, + { + cpu: { + id: 'cpu', + columns: TS_COLUMNS, + rows: [] as ProcessListAPIRow[], + }, + memory: { + id: 'memory', + columns: TS_COLUMNS, + rows: [] as ProcessListAPIRow[], + }, + } + ) + ) + ); + return timeseries as ProcessListAPIChartResponse; + } catch (e) { + throw e; + } +}; + +const TS_COLUMNS = [ + { + name: 'timestamp', + type: 'date', + }, + { + name: 'metric_0', + type: 'number', + }, +]; diff --git a/x-pack/plugins/infra/server/routes/process_list/index.ts b/x-pack/plugins/infra/server/routes/process_list/index.ts index 9851613255d8d..cf7765737e78b 100644 --- a/x-pack/plugins/infra/server/routes/process_list/index.ts +++ b/x-pack/plugins/infra/server/routes/process_list/index.ts @@ -13,7 +13,13 @@ import { InfraBackendLibs } from '../../lib/infra_types'; import { throwErrors } from '../../../common/runtime_types'; import { createSearchClient } from '../../lib/create_search_client'; import { getProcessList } from '../../lib/host_details/process_list'; -import { ProcessListAPIRequestRT, ProcessListAPIResponseRT } from '../../../common/http_api'; +import { getProcessListChart } from '../../lib/host_details/process_list_chart'; +import { + ProcessListAPIRequestRT, + ProcessListAPIResponseRT, + ProcessListAPIChartRequestRT, + ProcessListAPIChartResponseRT, +} from '../../../common/http_api'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); @@ -47,4 +53,33 @@ export const initProcessListRoute = (libs: InfraBackendLibs) => { } } ); + + framework.registerRoute( + { + method: 'post', + path: '/api/metrics/process_list/chart', + validate: { + body: escapeHatch, + }, + }, + async (requestContext, request, response) => { + try { + const options = pipe( + ProcessListAPIChartRequestRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = createSearchClient(requestContext, framework); + const processListResponse = await getProcessListChart(client, options); + + return response.ok({ + body: ProcessListAPIChartResponseRT.encode(processListResponse), + }); + } catch (error) { + return response.internalError({ + body: error.message, + }); + } + } + ); };