[Metrics UI] Refactor Process List API to fetch only top processes (#84716)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Zacqary Adam Xeper 2020-12-07 14:46:26 -06:00 committed by GitHub
parent a7c5b49343
commit de289de6c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 859 additions and 372 deletions

View file

@ -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<typeof ProcessListAPIQueryAggregationRT>;
export type ProcessListAPIRequest = rt.TypeOf<typeof ProcessListAPIRequestRT>;
export type ProcessListAPIResponse = rt.TypeOf<typeof ProcessListAPIResponseRT>;
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<typeof ProcessListAPIChartRequestRT>;
export type ProcessListAPIChartResponse = rt.TypeOf<typeof ProcessListAPIChartResponseRT>;
export type ProcessListAPIRow = MetricsAPIRow;

View file

@ -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<Query>(EuiSearchBar.Query.MATCH_ALL);
const [searchFilter, setSearchFilter] = useState<string>('');
const [sortBy, setSortBy] = useState<SortBy>({
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 (
<TabContent>
<EuiEmptyPrompt
iconType="tableDensityNormal"
title={
<h4>
{i18n.translate('xpack.infra.metrics.nodeDetails.processListError', {
defaultMessage: 'Unable to show process data',
})}
</h4>
}
actions={
<EuiButton color="primary" fill onClick={reload}>
{i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', {
defaultMessage: 'Try again',
})}
</EuiButton>
}
/>
</TabContent>
);
}
const debouncedSearchOnChange = useMemo(
() =>
debounce<(props: { queryText: string }) => void>(
({ queryText }) => setSearchFilter(queryText),
500
),
[setSearchFilter]
);
return (
<TabContent>
<SummaryTable isLoading={loading} processList={response ?? []} />
<EuiSpacer size="m" />
<EuiSearchBar
query={searchFilter}
onChange={({ query }) => 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,
})),
},
]}
/>
<EuiSpacer size="m" />
<ProcessesTable
currentTime={currentTime}
isLoading={loading || !response}
processList={response ?? []}
searchFilter={searchFilter}
/>
<ProcessListContextProvider hostTerm={hostTerm} to={currentTime} timefield={timefield}>
<SummaryTable
isLoading={loading}
processSummary={(!error ? response?.summary : null) ?? { total: 0 }}
/>
<EuiSpacer size="m" />
<EuiSearchBar
onChange={debouncedSearchOnChange}
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,
})),
},
]}
/>
<EuiSpacer size="m" />
{!error ? (
<ProcessesTable
currentTime={currentTime}
isLoading={loading || !response}
processList={response?.processList ?? []}
sortBy={sortBy}
setSortBy={setSortBy}
/>
) : (
<EuiEmptyPrompt
iconType="tableDensityNormal"
title={
<h4>
{i18n.translate('xpack.infra.metrics.nodeDetails.processListError', {
defaultMessage: 'Unable to show process data',
})}
</h4>
}
actions={
<EuiButton color="primary" fill onClick={reload}>
{i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', {
defaultMessage: 'Try again',
})}
</EuiButton>
}
/>
)}
</ProcessListContextProvider>
</TabContent>
);
};

View file

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

View file

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

View file

@ -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) => {
<CodeLine>{item.user}</CodeLine>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>{cpuMetricLabel}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<ProcessChart
timeseries={item.timeseries.cpu}
color={Color.color2}
label={cpuMetricLabel}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>{memoryMetricLabel}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<ProcessChart
timeseries={item.timeseries.memory}
color={Color.color0}
label={memoryMetricLabel}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
<ProcessRowCharts command={item.command} />
</EuiFlexGrid>
</EuiDescriptionList>
</ExpandedRowCell>
@ -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<boolean>('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 (
<ChartContainer>
<Chart>
<MetricExplorerSeriesChart
type={MetricsExplorerChartType.area}
metric={chartMetric}
id="0"
series={timeseries}
stack={false}
/>
<Axis
id={'timestamp'}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis
id={'values'}
position={Position.Left}
tickFormat={yAxisFormatter}
domain={domain}
ticks={6}
showGridLines
/>
<Settings tooltip={tooltipProps} theme={getChartTheme(isDarkMode)} />
</Chart>
</ChartContainer>
);
};
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',
}
);

View file

@ -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 ? (
<EuiEmptyPrompt iconType="alert" title={<EuiText>{failedToLoadChart}</EuiText>} />
) : isLoading ? (
<EuiLoadingChart />
) : (
<ProcessChart timeseries={response!.cpu} color={Color.color2} label={cpuMetricLabel} />
);
const memoryChart = error ? (
<EuiEmptyPrompt iconType="alert" title={<EuiText>{failedToLoadChart}</EuiText>} />
) : isLoading ? (
<EuiLoadingChart />
) : (
<ProcessChart timeseries={response!.memory} color={Color.color0} label={memoryMetricLabel} />
);
return (
<>
<EuiFlexItem>
<EuiDescriptionListTitle>{cpuMetricLabel}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{cpuChart}</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>{memoryMetricLabel}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{memoryChart}</EuiDescriptionListDescription>
</EuiFlexItem>
</>
);
};
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<boolean>('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 (
<ChartContainer>
<Chart>
<MetricExplorerSeriesChart
type={MetricsExplorerChartType.area}
metric={chartMetric}
id="0"
series={timeseries}
stack={false}
/>
<Axis
id={'timestamp'}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis
id={'values'}
position={Position.Left}
tickFormat={yAxisFormatter}
domain={domain}
ticks={6}
showGridLines
/>
<Settings tooltip={tooltipProps} theme={getChartTheme(isDarkMode)} />
</Chart>
</ChartContainer>
);
};
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',
}
);

View file

@ -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<T>(
@ -43,25 +42,21 @@ function useSortableProperties<T>(
getValue: (obj: T) => any;
isAscending: boolean;
}>,
defaultSortProperty: string
defaultSortProperty: string,
callback: (s: SortBy) => void
) {
const [sortableProperties] = useState<SortableProperties<T>>(
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<Process>(
const { updateSortableProperties } = useSortableProperties<Process>(
[
{
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 <LoadingPlaceholder />;
if (currentItems.length === 0)
return (
<EuiEmptyPrompt
iconType="tableDensityNormal"
title={
<h4>
{i18n.translate('xpack.infra.metrics.nodeDetails.noProcesses', {
defaultMessage: 'No processes matched these search terms',
})}
</h4>
}
/>
);
return (
<>
<EuiTable>
@ -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}
</EuiTableHeaderCell>
))}
</EuiTableHeader>
<StyledTableBody>
<ProcessesTableBody items={currentItemsPage} currentTime={currentTime} />
<ProcessesTableBody items={currentItems} currentTime={currentTime} />
</StyledTableBody>
</EuiTable>
<EuiSpacer size="m" />
<EuiTablePagination
itemsPerPage={itemsPerPage}
activePage={currentPage}
pageCount={pageCount}
itemsPerPageOptions={[10, 20, 50]}
onChangePage={setCurrentPage}
onChangeItemsPerPage={setItemsPerPage}
/>
</>
);
};
@ -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) => <StateBadge state={state} />,
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) => <CodeLine>{command}</CodeLine>,
},
@ -265,7 +240,7 @@ const columns: Array<{
}),
align: RIGHT_ALIGNMENT,
sortable: true,
render: (startTime: string, currentTime: number) => (
render: (startTime: number, currentTime: number) => (
<RuntimeCell startTime={startTime} currentTime={currentTime} />
),
},

View file

@ -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<keyof typeof STATE_NAMES, number>;
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 (
<StyleWrapper>

View file

@ -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<string, string>,
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<ProcessListAPIResponse>(
'/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<string, string>;
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;

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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<ProcessListAPIChartResponse>(
'/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,
};
}

View file

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

View file

@ -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'],
},
},
},
},
],
} as MetricsAPIRequest;
return await getAllMetricsData((body: MetricsAPIRequest) => query(client, body), queryBody);
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'],
},
},
},
},
},
},
},
};
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;
}
};

View file

@ -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',
},
];

View file

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