diff --git a/x-pack/plugins/infra/common/http_api/host_details/index.ts b/x-pack/plugins/infra/common/http_api/host_details/index.ts new file mode 100644 index 000000000000..b323ed8e9e32 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/host_details/index.ts @@ -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'; 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 new file mode 100644 index 000000000000..4b4a0a54b9d1 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/host_details/process_list.ts @@ -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; + +export type ProcessListAPIResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 4c729d11ba8c..914011454a73 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -11,3 +11,4 @@ export * from './metrics_explorer'; export * from './metrics_api'; export * from './log_alerts'; export * from './snapshot_api'; +export * from './host_details'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx index 7c6e58125b48..5c6e124914f3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx @@ -38,7 +38,11 @@ export const BottomDrawer: React.FC<{ - + {isOpen ? hideHistory : showHistory} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx index dd0060f773b4..af712c061157 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx @@ -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 ( - - - - - -

{node.name}

-
-
- - - - - -
-
- -
+ + + + + + +

{node.name}

+
+
+ + + + + +
+ + {tabs.map((tab, i) => ( + setSelectedTab(i)}> + {tab.name} + + ))} + +
+ {tabs[selectedTab].content} +
+
); }; 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', }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes.tsx deleted file mode 100644 index 94ba1150c20d..000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes.tsx +++ /dev/null @@ -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 Processes Placeholder; -}; - -export const ProcessesTab = { - id: 'processes', - name: i18n.translate('xpack.infra.nodeDetails.tabs.processes', { - defaultMessage: 'Processes', - }), - content: TabComponent, -}; 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 new file mode 100644 index 000000000000..836d491e6210 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/index.tsx @@ -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(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 ( + + + {i18n.translate('xpack.infra.metrics.nodeDetails.processListError', { + defaultMessage: 'Unable to show process data', + })} + + } + actions={ + + {i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', { + defaultMessage: 'Try again', + })} + + } + /> + + ); + } + + 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, + })), + }, + ]} + /> + + + + ); +}; + +export const ProcessesTab = { + id: 'processes', + name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', { + defaultMessage: 'Processes', + }), + content: TabComponent, +}; 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 new file mode 100644 index 000000000000..88584ef2987e --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/parse_process_list.ts @@ -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, +}); 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 new file mode 100644 index 000000000000..bbf4a25fc49a --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row.tsx @@ -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 ( + <> + + + setIsExpanded(!isExpanded)} + /> + + {cells} + + + {isExpanded && ( + + {({ measureRef, bounds: { height = 0 } }) => ( + + + + + +
+ + {i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelCommand', + { + defaultMessage: 'Command', + } + )} + + + {item.command} + +
+
+ {item.apmTrace && ( + + + {i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM', + { + defaultMessage: 'View trace in APM', + } + )} + + + )} +
+ + + + {i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelPID', + { + defaultMessage: 'PID', + } + )} + + + {item.pid} + + + + + {i18n.translate( + 'xpack.infra.metrics.nodeDetails.processes.expandedRowLabelUser', + { + defaultMessage: 'User', + } + )} + + + {item.user} + + + + {cpuMetricLabel} + + + + + + {memoryMetricLabel} + + + + + +
+
+ )} +
+ )} +
+ + ); +}; + +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, +})` + 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', + } +); 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 new file mode 100644 index 000000000000..43f3a333fda8 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/processes_table.tsx @@ -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( + sortablePropertyItems: Array<{ + name: string; + getValue: (obj: T) => any; + isAscending: boolean; + }>, + defaultSortProperty: string +) { + const [sortableProperties] = useState>( + 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( + [ + { + 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 ; + + return ( + <> + + + + {columns.map((column) => ( + setSortedColumn(column.field) : undefined} + isSorted={sortedColumn.name === column.field} + isSortAscending={sortedColumn.name === column.field && sortedColumn.isAscending} + > + {column.name} + + ))} + + + + + + + + + ); +}; + +const LoadingPlaceholder = () => { + return ( +
+ +
+ ); +}; + +interface TableBodyProps { + items: Process[]; + currentTime: number; +} +const ProcessesTableBody = ({ items, currentTime }: TableBodyProps) => ( + <> + {items.map((item, i) => { + const cells = columns.map((column) => ( + + {column.render ? column.render(item[column.field], currentTime) : item[column.field]} + + )); + return ; + })} + +); + +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) => , + width: 84, + textOnly: false, + }, + { + field: 'command', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCommand', { + defaultMessage: 'Command', + }), + sortable: true, + width: '40%', + render: (command: string) => {command}, + }, + { + field: 'startTime', + name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelTime', { + defaultMessage: 'Time', + }), + align: RIGHT_ALIGNMENT, + sortable: true, + render: (startTime: string, currentTime: number) => ( + + ), + }, + { + 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), + }, +]; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/state_badge.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/state_badge.tsx new file mode 100644 index 000000000000..17306abdb60a --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/state_badge.tsx @@ -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 {STATE_NAMES.running}; + case 'sleeping': + return {STATE_NAMES.sleeping}; + case 'dead': + return {STATE_NAMES.dead}; + case 'stopped': + return {STATE_NAMES.stopped}; + case 'idle': + return {STATE_NAMES.idle}; + case 'zombie': + return {STATE_NAMES.zombie}; + default: + return {STATE_NAMES.unknown}; + } +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/states.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/states.ts new file mode 100644 index 000000000000..b5e32420709e --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/states.ts @@ -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']; 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 new file mode 100644 index 000000000000..59becb0bf534 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/summary_table.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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; + +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 ( + + + + ); +}; + +const loadingRenderer = (value: number) => (value === -1 ? : 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>; + +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; + } + } +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/types.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/types.ts new file mode 100644 index 000000000000..d483fe510c94 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/types.ts @@ -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 +} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx index 241ad7104836..7386fa64aca9 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/shared.tsx @@ -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; `; 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 new file mode 100644 index 000000000000..8e0843fe8b27 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_process_list.ts @@ -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, + 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( + '/api/metrics/process_list', + 'POST', + JSON.stringify({ + hostTerm, + timerange, + indexPattern, + }), + decodeResponse + ); + + useEffect(() => { + makeRequest(); + }, [makeRequest]); + + return { + error, + loading, + response, + makeRequest, + }; +} diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 49fe55e3dee0..2bf5687da7e0 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -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); }; 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 new file mode 100644 index 000000000000..99e8b2e8f6ab --- /dev/null +++ b/x-pack/plugins/infra/server/lib/host_details/process_list.ts @@ -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); +}; diff --git a/x-pack/plugins/infra/server/routes/process_list/index.ts b/x-pack/plugins/infra/server/routes/process_list/index.ts new file mode 100644 index 000000000000..9851613255d8 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/process_list/index.ts @@ -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, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/utils/get_all_metrics_data.ts b/x-pack/plugins/infra/server/utils/get_all_metrics_data.ts new file mode 100644 index 000000000000..cec58494d1b9 --- /dev/null +++ b/x-pack/plugins/infra/server/utils/get_all_metrics_data.ts @@ -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 ( + query: (options: Options) => Promise, + options: Options, + previousBuckets: MetricsAPISeries[] = [] +): Promise => { + 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)); +};