[Metrics UI] Add Process tab to Enhanced Node Details (#83477)

This commit is contained in:
Zacqary Adam Xeper 2020-11-18 10:41:28 -06:00 committed by GitHub
parent e07d6d0b38
commit 21c0258e6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1163 additions and 48 deletions

View file

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

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as rt from 'io-ts';
import { MetricsAPITimerangeRT, MetricsAPISeriesRT } from '../metrics_api';
export const ProcessListAPIRequestRT = rt.type({
hostTerm: rt.record(rt.string, rt.string),
timerange: MetricsAPITimerangeRT,
indexPattern: rt.string,
});
export const ProcessListAPIResponseRT = rt.array(MetricsAPISeriesRT);
export type ProcessListAPIRequest = rt.TypeOf<typeof ProcessListAPIRequestRT>;
export type ProcessListAPIResponse = rt.TypeOf<typeof ProcessListAPIResponseRT>;

View file

@ -11,3 +11,4 @@ export * from './metrics_explorer';
export * from './metrics_api';
export * from './log_alerts';
export * from './snapshot_api';
export * from './host_details';

View file

@ -38,7 +38,11 @@ export const BottomDrawer: React.FC<{
<BottomActionContainer ref={isOpen ? measureRef : null} isOpen={isOpen}>
<BottomActionTopBar ref={isOpen ? null : measureRef}>
<EuiFlexItem grow={false}>
<ShowHideButton iconType={isOpen ? 'arrowDown' : 'arrowRight'} onClick={onClick}>
<ShowHideButton
aria-expanded={isOpen}
iconType={isOpen ? 'arrowDown' : 'arrowRight'}
onClick={onClick}
>
{isOpen ? hideHistory : showHistory}
</ShowHideButton>
</EuiFlexItem>

View file

@ -4,11 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiTabbedContent } from '@elastic/eui';
import { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiPanel } from '@elastic/eui';
import React, { CSSProperties, useMemo } from 'react';
import { EuiText } from '@elastic/eui';
import React, { CSSProperties, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { euiStyled } from '../../../../../../../observability/public';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib';
@ -17,6 +15,7 @@ import { MetricsTab } from './tabs/metrics';
import { LogsTab } from './tabs/logs';
import { ProcessesTab } from './tabs/processes';
import { PropertiesTab } from './tabs/properties';
import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN, OVERLAY_HEADER_SIZE } from './tabs/shared';
interface Props {
isOpen: boolean;
@ -48,46 +47,63 @@ export const NodeContextPopover = ({
});
}, [tabConfigs, node, nodeType, currentTime, options]);
const [selectedTab, setSelectedTab] = useState(0);
if (!isOpen) {
return null;
}
return (
<EuiPanel hasShadow={true} paddingSize={'none'} style={panelStyle}>
<OverlayHeader>
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem grow={true}>
<EuiText>
<h4>{node.name}</h4>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} iconType={'cross'}>
<FormattedMessage id="xpack.infra.infra.nodeDetails.close" defaultMessage="Close" />
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</OverlayHeader>
<EuiTabbedContent tabs={tabs} />
</EuiPanel>
<EuiPortal>
<EuiPanel hasShadow={true} paddingSize={'none'} style={panelStyle}>
<OverlayHeader>
<OverlayHeaderTitleWrapper>
<EuiFlexItem grow={true}>
<EuiTitle size="s">
<h4>{node.name}</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} iconType={'cross'}>
<FormattedMessage id="xpack.infra.infra.nodeDetails.close" defaultMessage="Close" />
</EuiButtonEmpty>
</EuiFlexItem>
</OverlayHeaderTitleWrapper>
<EuiTabs>
{tabs.map((tab, i) => (
<EuiTab key={tab.id} isSelected={i === selectedTab} onClick={() => setSelectedTab(i)}>
{tab.name}
</EuiTab>
))}
</EuiTabs>
</OverlayHeader>
{tabs[selectedTab].content}
</EuiPanel>
</EuiPortal>
);
};
const OverlayHeader = euiStyled.div`
border-color: ${(props) => props.theme.eui.euiBorderColor};
border-bottom-width: ${(props) => props.theme.eui.euiBorderWidthThick};
padding: ${(props) => props.theme.eui.euiSizeS};
padding-bottom: 0;
overflow: hidden;
background-color: ${(props) => props.theme.eui.euiColorLightestShade};
height: ${OVERLAY_HEADER_SIZE}px;
`;
const OverlayHeaderTitleWrapper = euiStyled(EuiFlexGroup).attrs({ alignItems: 'center' })`
padding: ${(props) => props.theme.eui.paddingSizes.s} ${(props) =>
props.theme.eui.paddingSizes.m} 0;
`;
const panelStyle: CSSProperties = {
position: 'absolute',
right: 10,
top: -100,
top: OVERLAY_Y_START,
width: '50%',
maxWidth: 600,
maxWidth: 730,
zIndex: 2,
height: '50vh',
height: `calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px)`,
overflow: 'hidden',
};

View file

@ -1,21 +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 React from 'react';
import { i18n } from '@kbn/i18n';
import { TabContent, TabProps } from './shared';
const TabComponent = (props: TabProps) => {
return <TabContent>Processes Placeholder</TabContent>;
};
export const ProcessesTab = {
id: 'processes',
name: i18n.translate('xpack.infra.nodeDetails.tabs.processes', {
defaultMessage: 'Processes',
}),
content: TabComponent,
};

View file

@ -0,0 +1,102 @@
/*
* 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, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSearchBar, EuiSpacer, EuiEmptyPrompt, EuiButton, Query } from '@elastic/eui';
import { useProcessList } 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';
const TabComponent = ({ currentTime, node, nodeType, options }: TabProps) => {
const [searchFilter, setSearchFilter] = useState<Query>(EuiSearchBar.Query.MATCH_ALL);
const hostTerm = useMemo(() => {
const field =
options.fields && Reflect.has(options.fields, nodeType)
? Reflect.get(options.fields, nodeType)
: nodeType;
return { [field]: node.name };
}, [options, node, nodeType]);
const { loading, error, response, makeRequest: reload } = useProcessList(
hostTerm,
'metricbeat-*',
options.fields!.timestamp,
currentTime
);
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>
);
}
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}
/>
</TabContent>
);
};
export const ProcessesTab = {
id: 'processes',
name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', {
defaultMessage: 'Processes',
}),
content: TabComponent,
};

View file

@ -0,0 +1,55 @@
/*
* 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,267 @@
/*
* 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, { useState, useMemo } from 'react';
import moment from 'moment';
import { first, last } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiTableRow,
EuiTableRowCell,
EuiButtonEmpty,
EuiCode,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
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';
interface Props {
cells: React.ReactNode[];
item: Process;
}
export const ProcessRow = ({ cells, item }: Props) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<>
<EuiTableRow>
<EuiTableRowCell isExpander textOnly={false}>
<EuiButtonEmpty
iconType={isExpanded ? 'arrowDown' : 'arrowRight'}
aria-expanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
/>
</EuiTableRowCell>
{cells}
</EuiTableRow>
<EuiTableRow isExpandable isExpandedRow={isExpanded}>
{isExpanded && (
<AutoSizer bounds>
{({ measureRef, bounds: { height = 0 } }) => (
<ExpandedRowCell commandHeight={height}>
<EuiSpacer size="s" />
<EuiDescriptionList compressed>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<div ref={measureRef}>
<EuiDescriptionListTitle>
{i18n.translate(
'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelCommand',
{
defaultMessage: 'Command',
}
)}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<ExpandedCommandLine>{item.command}</ExpandedCommandLine>
</EuiDescriptionListDescription>
</div>
</EuiFlexItem>
{item.apmTrace && (
<EuiFlexItem grow={false}>
<EuiButton>
{i18n.translate(
'xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM',
{
defaultMessage: 'View trace in APM',
}
)}
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem>
<EuiDescriptionListTitle>
{i18n.translate(
'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelPID',
{
defaultMessage: 'PID',
}
)}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<CodeLine>{item.pid}</CodeLine>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
{i18n.translate(
'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelUser',
{
defaultMessage: 'User',
}
)}
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<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>
</EuiFlexGrid>
</EuiDescriptionList>
</ExpandedRowCell>
)}
</AutoSizer>
)}
</EuiTableRow>
</>
);
};
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,
})`
text-overflow: ellipsis;
overflow: hidden;
padding: 0 !important;
& code.euiCodeBlock__code {
white-space: nowrap !important;
vertical-align: middle;
}
`;
const ExpandedCommandLine = euiStyled(EuiCode).attrs({
transparentBackground: true,
})`
padding: 0 !important;
margin-bottom: ${(props) => props.theme.eui.euiSizeS};
`;
const ExpandedRowCell = euiStyled(EuiTableRowCell).attrs({
textOnly: false,
colSpan: 6,
})<{ commandHeight: number }>`
height: ${(props) => props.commandHeight + 240}px;
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,288 @@
/*
* 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, useState, useEffect, useCallback } from 'react';
import { omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiTable,
EuiTableHeader,
EuiTableBody,
EuiTableHeaderCell,
EuiTableRowCell,
EuiSpacer,
EuiTablePagination,
EuiLoadingChart,
Query,
SortableProperties,
LEFT_ALIGNMENT,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { ProcessListAPIResponse } from '../../../../../../../../common/http_api';
import { FORMATTERS } from '../../../../../../../../common/formatters';
import { euiStyled } from '../../../../../../../../../observability/public';
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;
currentTime: number;
isLoading: boolean;
searchFilter: Query;
}
function useSortableProperties<T>(
sortablePropertyItems: Array<{
name: string;
getValue: (obj: T) => any;
isAscending: boolean;
}>,
defaultSortProperty: string
) {
const [sortableProperties] = useState<SortableProperties<T>>(
new SortableProperties(sortablePropertyItems, defaultSortProperty)
);
const [sortedColumn, setSortedColumn] = useState(
omit(sortableProperties.getSortedProperty(), 'getValue')
);
return {
setSortedColumn: useCallback(
(property) => {
sortableProperties.sortOn(property);
setSortedColumn(omit(sortableProperties.getSortedProperty(), 'getValue'));
},
[sortableProperties]
),
sortedColumn,
sortItems: (items: T[]) => sortableProperties.sortItems(items),
};
}
export const ProcessesTable = ({
processList,
currentTime,
isLoading,
searchFilter,
}: TableProps) => {
const [currentPage, setCurrentPage] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => setCurrentPage(0), [processList, searchFilter, itemsPerPage]);
const { sortedColumn, sortItems, setSortedColumn } = 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,
},
{
name: 'cpu',
getValue: (item: any) => item.cpu,
isAscending: false,
},
{
name: 'memory',
getValue: (item: any) => item.memory,
isAscending: false,
},
],
'state'
);
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]
);
if (isLoading) return <LoadingPlaceholder />;
return (
<>
<EuiTable>
<EuiTableHeader>
<EuiTableHeaderCell width={24} />
{columns.map((column) => (
<EuiTableHeaderCell
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}
>
{column.name}
</EuiTableHeaderCell>
))}
</EuiTableHeader>
<StyledTableBody>
<ProcessesTableBody items={currentItemsPage} currentTime={currentTime} />
</StyledTableBody>
</EuiTable>
<EuiSpacer size="m" />
<EuiTablePagination
itemsPerPage={itemsPerPage}
activePage={currentPage}
pageCount={pageCount}
itemsPerPageOptions={[10, 20, 50]}
onChangePage={setCurrentPage}
onChangeItemsPerPage={setItemsPerPage}
/>
</>
);
};
const LoadingPlaceholder = () => {
return (
<div
style={{
width: '100%',
height: '200px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiLoadingChart size="xl" />
</div>
);
};
interface TableBodyProps {
items: Process[];
currentTime: number;
}
const ProcessesTableBody = ({ items, currentTime }: TableBodyProps) => (
<>
{items.map((item, i) => {
const cells = columns.map((column) => (
<EuiTableRowCell
key={`${String(column.field)}-${i}`}
header={column.name}
align={column.align ?? LEFT_ALIGNMENT}
textOnly={column.textOnly ?? true}
>
{column.render ? column.render(item[column.field], currentTime) : item[column.field]}
</EuiTableRowCell>
));
return <ProcessRow cells={cells} item={item} key={`row-${i}`} />;
})}
</>
);
const StyledTableBody = euiStyled(EuiTableBody)`
& .euiTableCellContent {
padding-top: 0;
padding-bottom: 0;
}
`;
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);
let remainingRuntimeMS = runtimeLength;
const runtimeHours = Math.floor(remainingRuntimeMS / ONE_HOUR);
remainingRuntimeMS -= runtimeHours * ONE_HOUR;
const runtimeMinutes = Math.floor(remainingRuntimeMS / ONE_MINUTE);
remainingRuntimeMS -= runtimeMinutes * ONE_MINUTE;
const runtimeSeconds = Math.floor(remainingRuntimeMS / 1000);
remainingRuntimeMS -= runtimeSeconds * 1000;
const runtimeDisplayHours = runtimeHours ? `${runtimeHours}:` : '';
const runtimeDisplayMinutes = runtimeMinutes < 10 ? `0${runtimeMinutes}:` : `${runtimeMinutes}:`;
const runtimeDisplaySeconds = runtimeSeconds < 10 ? `0${runtimeSeconds}` : runtimeSeconds;
return <>{`${runtimeDisplayHours}${runtimeDisplayMinutes}${runtimeDisplaySeconds}`}</>;
};
const columns: Array<{
field: keyof Process;
name: string;
sortable: boolean;
render?: Function;
width?: string | number;
textOnly?: boolean;
align?: typeof RIGHT_ALIGNMENT | typeof LEFT_ALIGNMENT;
}> = [
{
field: 'state',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelState', {
defaultMessage: 'State',
}),
sortable: true,
render: (state: string) => <StateBadge state={state} />,
width: 84,
textOnly: false,
},
{
field: 'command',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCommand', {
defaultMessage: 'Command',
}),
sortable: true,
width: '40%',
render: (command: string) => <CodeLine>{command}</CodeLine>,
},
{
field: 'startTime',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelTime', {
defaultMessage: 'Time',
}),
align: RIGHT_ALIGNMENT,
sortable: true,
render: (startTime: string, currentTime: number) => (
<RuntimeCell startTime={startTime} currentTime={currentTime} />
),
},
{
field: 'cpu',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCPU', {
defaultMessage: 'CPU',
}),
sortable: true,
render: (value: number) => FORMATTERS.percent(value),
},
{
field: 'memory',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelMemory', {
defaultMessage: 'Mem.',
}),
sortable: true,
render: (value: number) => FORMATTERS.percent(value),
},
];

View file

@ -0,0 +1,28 @@
/*
* 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 from 'react';
import { EuiBadge } from '@elastic/eui';
import { STATE_NAMES } from './states';
export const StateBadge = ({ state }: { state: string }) => {
switch (state) {
case 'running':
return <EuiBadge color="secondary">{STATE_NAMES.running}</EuiBadge>;
case 'sleeping':
return <EuiBadge color="default">{STATE_NAMES.sleeping}</EuiBadge>;
case 'dead':
return <EuiBadge color="danger">{STATE_NAMES.dead}</EuiBadge>;
case 'stopped':
return <EuiBadge color="warning">{STATE_NAMES.stopped}</EuiBadge>;
case 'idle':
return <EuiBadge color="primary">{STATE_NAMES.idle}</EuiBadge>;
case 'zombie':
return <EuiBadge color="danger">{STATE_NAMES.zombie}</EuiBadge>;
default:
return <EuiBadge color="hollow">{STATE_NAMES.unknown}</EuiBadge>;
}
};

View file

@ -0,0 +1,33 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const STATE_NAMES = {
running: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateRunning', {
defaultMessage: 'Running',
}),
sleeping: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateSleeping', {
defaultMessage: 'Sleeping',
}),
dead: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateDead', {
defaultMessage: 'Dead',
}),
stopped: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateStopped', {
defaultMessage: 'Stopped',
}),
idle: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateIdle', {
defaultMessage: 'Idle',
}),
zombie: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateZombie', {
defaultMessage: 'Zombie',
}),
unknown: i18n.translate('xpack.infra.metrics.nodeDetails.processes.stateUnknown', {
defaultMessage: 'Unknown',
}),
};
export const STATE_ORDER = ['running', 'sleeping', 'stopped', 'idle', 'dead', 'zombie', 'unknown'];

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { mapValues, countBy } 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;
isLoading: boolean;
}
type SummaryColumn = {
total: number;
} & Record<keyof typeof STATE_NAMES, number>;
export const SummaryTable = ({ processList, isLoading }: Props) => {
const parsedList = parseProcessList(processList);
const processCount = useMemo(
() =>
[
{
total: isLoading ? -1 : parsedList.length,
...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)),
...(isLoading ? [] : countBy(parsedList, 'state')),
},
] as SummaryColumn[],
[parsedList, isLoading]
);
return (
<StyleWrapper>
<EuiBasicTable items={processCount} columns={columns} />
</StyleWrapper>
);
};
const loadingRenderer = (value: number) => (value === -1 ? <LoadingSpinner /> : value);
const columns = [
{
field: 'total',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.headingTotalProcesses', {
defaultMessage: 'Total processes',
}),
width: 125,
render: loadingRenderer,
},
...Object.entries(STATE_NAMES).map(([field, name]) => ({ field, name, render: loadingRenderer })),
] as Array<EuiBasicTableColumn<SummaryColumn>>;
const LoadingSpinner = euiStyled(EuiLoadingSpinner).attrs({ size: 'm' })`
margin-top: 2px;
margin-bottom: 3px;
`;
const StyleWrapper = euiStyled.div`
& .euiTableHeaderCell {
border-bottom: none;
& .euiTableCellContent {
padding-bottom: 0;
}
& .euiTableCellContent__text {
font-size: ${(props) => props.theme.eui.euiFontSizeS};
}
}
& .euiTableRowCell {
border-top: none;
& .euiTableCellContent {
padding-top: 0;
}
}
`;

View file

@ -0,0 +1,22 @@
/*
* 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 { MetricsExplorerSeries } from '../../../../../../../../common/http_api';
import { STATE_NAMES } from './states';
export interface Process {
command: string;
cpu: number;
memory: number;
startTime: number;
state: keyof typeof STATE_NAMES;
pid: number;
user: string;
timeseries: {
[x: string]: MetricsExplorerSeries;
};
apmTrace?: string; // Placeholder
}

View file

@ -15,6 +15,13 @@ export interface TabProps {
nodeType: InventoryItemType;
}
export const OVERLAY_Y_START = 266;
export const OVERLAY_BOTTOM_MARGIN = 16;
export const OVERLAY_HEADER_SIZE = 96;
const contentHeightOffset = OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN + OVERLAY_HEADER_SIZE;
export const TabContent = euiStyled.div`
padding: ${(props) => props.theme.eui.paddingSizes.l};
padding: ${(props) => props.theme.eui.paddingSizes.s};
height: calc(100vh - ${contentHeightOffset}px);
overflow-y: auto;
overflow-x: hidden;
`;

View file

@ -0,0 +1,55 @@
/*
* 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 } from 'react';
import { ProcessListAPIResponse, ProcessListAPIResponseRT } from '../../../../../common/http_api';
import { throwErrors, createPlainError } from '../../../../../common/runtime_types';
import { useHTTPRequest } from '../../../../hooks/use_http_request';
export function useProcessList(
hostTerm: Record<string, string>,
indexPattern: string,
timefield: string,
to: number
) {
const decodeResponse = (response: any) => {
return pipe(
ProcessListAPIResponseRT.decode(response),
fold(throwErrors(createPlainError), identity)
);
};
const timerange = {
field: timefield,
interval: 'modules',
to,
from: to - 15 * 60 * 1000, // 15 minutes
};
const { error, loading, response, makeRequest } = useHTTPRequest<ProcessListAPIResponse>(
'/api/metrics/process_list',
'POST',
JSON.stringify({
hostTerm,
timerange,
indexPattern,
}),
decodeResponse
);
useEffect(() => {
makeRequest();
}, [makeRequest]);
return {
error,
loading,
response,
makeRequest,
};
}

View file

@ -41,6 +41,7 @@ import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './r
import { initSourceRoute } from './routes/source';
import { initAlertPreviewRoute } from './routes/alerting';
import { initGetLogAlertsChartPreviewDataRoute } from './routes/log_alerts';
import { initProcessListRoute } from './routes/process_list';
export const initInfraServer = (libs: InfraBackendLibs) => {
const schema = makeExecutableSchema({
@ -82,4 +83,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => {
initLogSourceStatusRoutes(libs);
initAlertPreviewRoute(libs);
initGetLogAlertsChartPreviewDataRoute(libs);
initProcessListRoute(libs);
};

View file

@ -0,0 +1,64 @@
/*
* 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 { ProcessListAPIRequest, MetricsAPIRequest } from '../../../common/http_api';
import { getAllMetricsData } from '../../utils/get_all_metrics_data';
import { query } from '../metrics';
import { ESSearchClient } from '../metrics/types';
export const getProcessList = async (
client: ESSearchClient,
{ hostTerm, timerange, indexPattern }: 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',
},
},
},
},
{
id: 'memory',
aggregations: {
memory: {
avg: {
field: 'system.process.memory.rss.pct',
},
},
},
},
{
id: 'meta',
aggregations: {
meta: {
top_hits: {
size: 1,
sort: [{ [timerange.field]: { order: 'desc' } }],
_source: [
'system.process.cpu.start_time',
'system.process.state',
'process.pid',
'user.name',
],
},
},
},
},
],
} as MetricsAPIRequest;
return await getAllMetricsData((body: MetricsAPIRequest) => query(client, body), queryBody);
};

View file

@ -0,0 +1,50 @@
/*
* 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 Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { schema } from '@kbn/config-schema';
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';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
export const initProcessListRoute = (libs: InfraBackendLibs) => {
const { framework } = libs;
framework.registerRoute(
{
method: 'post',
path: '/api/metrics/process_list',
validate: {
body: escapeHatch,
},
},
async (requestContext, request, response) => {
try {
const options = pipe(
ProcessListAPIRequestRT.decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);
const client = createSearchClient(requestContext, framework);
const processListResponse = await getProcessList(client, options);
return response.ok({
body: ProcessListAPIResponseRT.encode(processListResponse),
});
} catch (error) {
return response.internalError({
body: error.message,
});
}
}
);
};

View file

@ -0,0 +1,34 @@
/*
* 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 { MetricsAPIResponse, MetricsAPISeries } from '../../common/http_api/metrics_api';
export const getAllMetricsData = async <Options extends object = {}>(
query: (options: Options) => Promise<MetricsAPIResponse>,
options: Options,
previousBuckets: MetricsAPISeries[] = []
): Promise<MetricsAPISeries[]> => {
const response = await query(options);
// Nothing available, return the previous buckets.
if (response.series.length === 0) {
return previousBuckets;
}
const currentBuckets = response.series;
// if there are no currentBuckets then we are finished paginating through the results
if (!response.info.afterKey) {
return previousBuckets.concat(currentBuckets);
}
// There is possibly more data, concat previous and current buckets and call ourselves recursively.
const newOptions = {
...options,
afterKey: response.info.afterKey,
};
return getAllMetricsData(query, newOptions, previousBuckets.concat(currentBuckets));
};