[Infra UI] Replace node details flyout with asset details flyout in the inventory page (#166965)

Closes #161754 
Closes #166807

To make the testing and review easier I merged the old components
[cleanup PR](https://github.com/jennypavlova/kibana/pull/5) into this
one

## Summary
This PR replaces the old node details view with the asset details flyout

### Old

![image](ffbead5b-6f89-4397-b1a4-2ade74f7f227)

### New 

![image](89fa52cd-a462-499e-900a-e26c70d17791)

### Testing

1. Go to inventory
2. Click on a host in the waffle map
3. Click on any **host**
- These changes are related only if a `Host` is selected- in the case of
a pod the view shouldn't be changed:

![image](d1b90b65-1bbf-4fbf-a0c6-6b95afe6162e)

4. Check the new flyout functionality 



3557821c-7964-466e-8514-84c2f81bc2fd

Note: the selected host should have a border like in the previous
version (this I fixed in the [last
commit](ff4753aa06))
so it should be added if there is a selected node:
<img width="1193" alt="image"
src="6646fe47-6333-435a-a5ec-248339402224">

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
jennypavlova 2023-09-28 19:02:34 +02:00 committed by GitHub
parent 726f212d0c
commit 549195ce4f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 673 additions and 2725 deletions

View file

@ -6,9 +6,9 @@
*/
import { i18n } from '@kbn/i18n';
import { ContentTabIds, type Tab } from '../../../../../components/asset_details/types';
import { ContentTabIds, type Tab } from '../../components/asset_details/types';
export const orderedFlyoutTabs: Tab[] = [
export const commonFlyoutTabs: Tab[] = [
{
id: ContentTabIds.OVERVIEW,
name: i18n.translate('xpack.infra.nodeDetails.tabs.overview.title', {

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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 { ProcessListAPIResponse, ProcessListAPIResponseRT } from '../../../../common/http_api';
import { throwErrors, createPlainError } from '../../../../common/runtime_types';
import { useHTTPRequest } from '../../../hooks/use_http_request';
import { useSourceContext } from '../../../containers/metrics_source';
export interface SortBy {
name: string;
isAscending: boolean;
}
export function useProcessList(
hostTerm: Record<string, string>,
to: number,
sortBy: SortBy,
searchFilter: object
) {
const { createDerivedIndexPattern } = useSourceContext();
const indexPattern = createDerivedIndexPattern().title;
const decodeResponse = (response: any) => {
return pipe(
ProcessListAPIResponseRT.decode(response),
fold(throwErrors(createPlainError), identity)
);
};
const parsedSortBy =
sortBy.name === 'runtimeLength'
? {
...sortBy,
name: 'startTime',
}
: sortBy;
const { error, loading, response, makeRequest } = useHTTPRequest<ProcessListAPIResponse>(
'/api/metrics/process_list',
'POST',
JSON.stringify({
hostTerm,
indexPattern,
to,
sortBy: parsedSortBy,
searchFilter,
}),
decodeResponse
);
useEffect(() => {
makeRequest();
}, [makeRequest]);
return {
error: (error && error.message) || null,
loading,
response,
makeRequest,
};
}
function useProcessListParams(props: { hostTerm: Record<string, string>; to: number }) {
const { hostTerm, to } = props;
const { createDerivedIndexPattern } = useSourceContext();
const indexPattern = createDerivedIndexPattern().title;
return { hostTerm, indexPattern, to };
}
const ProcessListContext = createContainter(useProcessListParams);
export const [ProcessListContextProvider, useProcessListContext] = ProcessListContext;

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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, indexPattern, to } = useProcessListContext();
const { error, loading, response, makeRequest } = useHTTPRequest<ProcessListAPIChartResponse>(
'/api/metrics/process_list/chart',
'POST',
JSON.stringify({
hostTerm,
indexPattern,
to,
command,
}),
decodeResponse
);
useEffect(() => setInErrorState(true), [error]);
useEffect(() => setInErrorState(false), [loading]);
useEffect(() => {
makeRequest();
}, [makeRequest]);
return {
error: inErrorState,
loading,
response,
makeRequest,
};
}

View file

@ -19,15 +19,15 @@ import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { first, last } from 'lodash';
import moment from 'moment';
import React, { useMemo } from 'react';
import { useTimelineChartTheme } from '../../../../../../../utils/use_timeline_chart_theme';
import { Color } from '../../../../../../../../common/color_palette';
import { createFormatter } from '../../../../../../../../common/formatters';
import { MetricsExplorerAggregation } from '../../../../../../../../common/http_api';
import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain';
import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart';
import { MetricsExplorerChartType } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options';
import { useProcessListRowChart } from '../../../../hooks/use_process_list_row_chart';
import { calculateDomain } from '../../../../pages/metrics/metrics_explorer/components/helpers/calculate_domain';
import { useProcessListRowChart } from '../../hooks/use_process_list_row_chart';
import { useTimelineChartTheme } from '../../../../utils/use_timeline_chart_theme';
import { MetricExplorerSeriesChart } from '../../../../pages/metrics/metrics_explorer/components/series_chart';
import { Color } from '../../../../../common/color_palette';
import { createFormatter } from '../../../../../common/formatters';
import { MetricsExplorerAggregation } from '../../../../../common/http_api';
import { Process } from './types';
import { MetricsExplorerChartType } from '../../../../../common/metrics_explorer_views/types';
interface Props {
command: string;

View file

@ -30,9 +30,9 @@ import { css } from '@emotion/react';
import { EuiTableRow } from '@elastic/eui';
import { EuiIcon } from '@elastic/eui';
import { FORMATTERS } from '../../../../../common/formatters';
import type { SortBy } from '../../../../pages/metrics/inventory_view/hooks/use_process_list';
import type { SortBy } from '../../hooks/use_process_list';
import type { Process } from './types';
import { ProcessRow } from '../../../../pages/metrics/inventory_view/components/node_details/tabs/processes/process_row';
import { ProcessRow } from './process_row';
import { StateBadge } from './state_badge';
import { STATE_ORDER } from './states';
import type { ProcessListAPIResponse } from '../../../../../common/http_api';

View file

@ -10,7 +10,7 @@ import { useSourceContext } from '../../../../../containers/metrics_source';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search';
import type { HostNodeRow } from '../../hooks/use_hosts_table';
import { AssetDetails } from '../../../../../components/asset_details/asset_details';
import { orderedFlyoutTabs } from './tabs';
import { commonFlyoutTabs } from '../../../../../common/asset_details_config/asset_details_tabs';
export interface Props {
node: HostNodeRow;
@ -31,7 +31,7 @@ export const FlyoutWrapper = ({ node: { name }, closeFlyout }: Props) => {
showActionsColumn: true,
},
}}
tabs={orderedFlyoutTabs}
tabs={commonFlyoutTabs}
links={['apmServices', 'nodeDetails']}
renderMode={{
mode: 'flyout',

View file

@ -1,233 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiPortal, EuiTabs, EuiTab, EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { EuiOutsideClickDetector } from '@elastic/eui';
import { EuiIcon, EuiButtonIcon } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useLinkProps } from '@kbn/observability-shared-plugin/public';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib';
import { InventoryItemType } from '../../../../../../common/inventory_models/types';
import { MetricsTab } from './tabs/metrics/metrics';
import { LogsTab } from './tabs/logs';
import { ProcessesTab } from './tabs/processes';
import { PropertiesTab } from './tabs/properties';
import { AnomaliesTab } from './tabs/anomalies/anomalies';
import { OsqueryTab } from './tabs/osquery';
import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN } from './tabs/shared';
import { useNodeDetailsRedirect } from '../../../../link_to';
import { findInventoryModel } from '../../../../../../common/inventory_models';
import { navigateToUptime } from '../../lib/navigate_to_uptime';
import { InfraClientCoreStart, InfraClientStartDeps } from '../../../../../types';
interface Props {
isOpen: boolean;
onClose(): void;
options: InfraWaffleMapOptions;
currentTime: number;
node: InfraWaffleMapNode;
nodeType: InventoryItemType;
openAlertFlyout(): void;
}
export const NodeContextPopover = ({
isOpen,
node,
nodeType,
currentTime,
options,
onClose,
openAlertFlyout,
}: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const tabConfigs = [MetricsTab, LogsTab, ProcessesTab, PropertiesTab, AnomaliesTab, OsqueryTab];
const inventoryModel = findInventoryModel(nodeType);
const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000;
const { application, share } = useKibana<InfraClientCoreStart & InfraClientStartDeps>().services;
const { getNodeDetailUrl } = useNodeDetailsRedirect();
const uiCapabilities = application?.capabilities;
const canCreateAlerts = useMemo(
() => Boolean(uiCapabilities?.infrastructure?.save),
[uiCapabilities]
);
const tabs = useMemo(() => {
return tabConfigs.map((m) => {
const TabContent = m.content;
return {
...m,
content: (
<TabContent
onClose={onClose}
node={node}
nodeType={nodeType}
currentTime={currentTime}
options={options}
/>
),
};
});
}, [tabConfigs, node, nodeType, currentTime, onClose, options]);
const [selectedTab, setSelectedTab] = useState(0);
const nodeDetailMenuItemLinkProps = useLinkProps({
...getNodeDetailUrl({
assetType: nodeType,
assetId: node.id,
search: {
from: nodeDetailFrom,
to: currentTime,
name: node.name,
},
}),
});
const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id;
const apmTracesMenuItemLinkProps = useLinkProps({
app: 'apm',
hash: 'traces',
search: {
kuery: `${apmField}:"${node.id}"`,
},
});
if (!isOpen) {
return null;
}
return (
<EuiPortal>
<EuiOutsideClickDetector onOutsideClick={onClose}>
<OverlayPanel>
<OverlayHeader>
<EuiFlexGroup responsive={false} gutterSize="m">
<OverlayTitle grow={true}>
<EuiTitle size="xs">
<h4>{node.name}</h4>
</EuiTitle>
</OverlayTitle>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m" responsive={false}>
{canCreateAlerts && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="infraNodeContextPopoverCreateInventoryRuleButton"
onClick={openAlertFlyout}
size="xs"
iconSide={'left'}
flush="both"
iconType="bell"
>
<FormattedMessage
id="xpack.infra.infra.nodeDetails.createAlertLink"
defaultMessage="Create inventory rule"
/>
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="infraNodeContextPopoverOpenAsPageButton"
size="xs"
iconSide={'left'}
iconType={'popout'}
flush="both"
{...nodeDetailMenuItemLinkProps}
>
<FormattedMessage
id="xpack.infra.infra.nodeDetails.openAsPage"
defaultMessage="Open as page"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="infraNodeContextPopoverButton"
size="s"
onClick={onClose}
iconType="cross"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiTabs size="s">
{tabs.map((tab, i) => (
<EuiTab
key={tab.id}
isSelected={i === selectedTab}
onClick={() => setSelectedTab(i)}
>
{tab.name}
</EuiTab>
))}
<EuiTab {...apmTracesMenuItemLinkProps}>
<EuiIcon type="popout" />{' '}
<FormattedMessage
id="xpack.infra.infra.nodeDetails.apmTabLabel"
defaultMessage="APM"
/>
</EuiTab>
<EuiTab onClick={() => navigateToUptime(share.url.locators, nodeType, node)}>
<EuiIcon type="popout" />{' '}
<FormattedMessage
id="xpack.infra.infra.nodeDetails.updtimeTabLabel"
defaultMessage="Uptime"
/>
</EuiTab>
</EuiTabs>
</OverlayHeader>
{tabs[selectedTab].content}
</OverlayPanel>
</EuiOutsideClickDetector>
</EuiPortal>
);
};
const OverlayHeader = euiStyled.div`
padding-top: ${(props) => props.theme.eui.euiSizeM};
padding-right: ${(props) => props.theme.eui.euiSizeM};
padding-left: ${(props) => props.theme.eui.euiSizeM};
background-color: ${(props) => props.theme.eui.euiPageBackgroundColor};
box-shadow: inset 0 -1px ${(props) => props.theme.eui.euiBorderColor};
`;
const OverlayPanel = euiStyled(EuiPanel).attrs({ paddingSize: 'none' })`
display: flex;
flex-direction: column;
position: absolute;
right: 16px;
top: ${OVERLAY_Y_START}px;
width: 100%;
max-width: 720px;
z-index: 2;
max-height: calc(100vh - ${OVERLAY_Y_START + OVERLAY_BOTTOM_MARGIN}px);
overflow: hidden;
@media (max-width: 752px) {
border-radius: 0px !important;
left: 0px;
right: 0px;
top: 97px;
bottom: 0;
max-height: calc(100vh - 97px);
max-width: 100%;
}
`;
const OverlayTitle = euiStyled(EuiFlexItem)`
overflow: hidden;
& h4 {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
`;

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { AnomaliesTable } from '../../../ml/anomaly_detection/anomalies_table/anomalies_table';
import { TabContent, TabProps } from '../shared';
const TabComponent = (props: TabProps) => {
const { node, onClose } = props;
return (
<TabContent>
<AnomaliesTable closeFlyout={onClose} hostName={node.name} />
</TabContent>
);
};
export const AnomaliesTab = {
id: 'anomalies',
name: i18n.translate('xpack.infra.nodeDetails.tabs.anomalies', {
defaultMessage: 'Anomalies',
}),
content: TabComponent,
};

View file

@ -1,110 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiFieldSearch } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiButtonEmpty } from '@elastic/eui';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { LogStream } from '@kbn/logs-shared-plugin/public';
import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana';
import { TabContent, TabProps } from './shared';
import { useWaffleOptionsContext } from '../../../hooks/use_waffle_options';
import { findInventoryFields } from '../../../../../../../common/inventory_models';
const TabComponent = (props: TabProps) => {
const { services } = useKibanaContextForPlugin();
const { locators } = services;
const [textQuery, setTextQuery] = useState('');
const [textQueryDebounced, setTextQueryDebounced] = useState('');
const endTimestamp = props.currentTime;
const startTimestamp = endTimestamp - 60 * 60 * 1000; // 60 minutes
const { nodeType } = useWaffleOptionsContext();
const { node } = props;
useDebounce(() => setTextQueryDebounced(textQuery), textQueryThrottleInterval, [textQuery]);
const filter = useMemo(() => {
const query = [
`${findInventoryFields(nodeType).id}: "${node.id}"`,
...(textQueryDebounced !== '' ? [textQueryDebounced] : []),
].join(' and ');
return {
language: 'kuery',
query,
};
}, [nodeType, node.id, textQueryDebounced]);
const onQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTextQuery(e.target.value);
}, []);
const logsUrl = useMemo(() => {
return locators.nodeLogsLocator.getRedirectUrl({
nodeType,
nodeId: node.id,
time: startTimestamp,
filter: textQueryDebounced,
});
}, [locators.nodeLogsLocator, node.id, nodeType, startTimestamp, textQueryDebounced]);
return (
<TabContent>
<EuiFlexGroup gutterSize={'m'} alignItems={'center'} responsive={false}>
<EuiFlexItem>
<EuiFieldSearch
data-test-subj="infraTabComponentFieldSearch"
fullWidth
placeholder={i18n.translate('xpack.infra.nodeDetails.logs.textFieldPlaceholder', {
defaultMessage: 'Search for log entries...',
})}
value={textQuery}
isClearable
onChange={onQueryChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RedirectAppLinks coreStart={services}>
<EuiButtonEmpty
data-test-subj="infraTabComponentOpenInLogsButton"
size={'xs'}
flush={'both'}
iconType={'popout'}
href={logsUrl}
>
<FormattedMessage
id="xpack.infra.nodeDetails.logs.openLogsLink"
defaultMessage="Open in Logs"
/>
</EuiButtonEmpty>
</RedirectAppLinks>
</EuiFlexItem>
</EuiFlexGroup>
<LogStream
logView={{ type: 'log-view-reference', logViewId: 'default' }}
startTimestamp={startTimestamp}
endTimestamp={endTimestamp}
query={filter}
/>
</TabContent>
);
};
export const LogsTab = {
id: 'logs',
name: i18n.translate('xpack.infra.nodeDetails.tabs.logs', {
defaultMessage: 'Logs',
}),
content: TabComponent,
};
const textQueryThrottleInterval = 1000; // milliseconds

View file

@ -1,63 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiText } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiIcon } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { colorTransformer } from '../../../../../../../../common/color_palette';
import { MetricsExplorerOptionsMetric } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options';
interface Props {
title: string;
metrics: MetricsExplorerOptionsMetric[];
}
export const ChartHeader = ({ title, metrics }: Props) => {
return (
<EuiFlexGroup gutterSize={'s'} responsive={false}>
<HeaderItem grow={1}>
<EuiText size={'s'}>
<H4>{title}</H4>
</EuiText>
</HeaderItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize={'s'} alignItems={'center'} responsive={false}>
{metrics.map((chartMetric) => (
<EuiFlexItem key={chartMetric.label!}>
<EuiFlexGroup
key={chartMetric.label!}
gutterSize={'xs'}
alignItems={'center'}
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiIcon color={colorTransformer(chartMetric.color!)} type={'dot'} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size={'xs'}>{chartMetric.label}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const HeaderItem = euiStyled(EuiFlexItem).attrs({ grow: 1 })`
overflow: hidden;
`;
const H4 = euiStyled('h4')`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;

View file

@ -1,103 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
Axis,
Chart,
ChartSizeArray,
PointerUpdateListener,
Position,
Settings,
TickFormatter,
TooltipProps,
Tooltip,
} from '@elastic/charts';
import moment from 'moment';
import React from 'react';
import { useTimelineChartTheme } from '../../../../../../../utils/use_timeline_chart_theme';
import { MetricsExplorerSeries } from '../../../../../../../../common/http_api';
import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart';
import {
MetricsExplorerChartType,
MetricsExplorerOptionsMetric,
} from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options';
import { ChartHeader } from './chart_header';
const CHART_SIZE: ChartSizeArray = ['100%', 160];
interface Props {
title: string;
style: MetricsExplorerChartType;
chartRef: React.Ref<Chart>;
series: ChartSectionSeries[];
tickFormatterForTime: TickFormatter<any>;
tickFormatter: TickFormatter<any>;
onPointerUpdate: PointerUpdateListener;
domain: { max: number; min: number };
stack?: boolean;
}
export interface ChartSectionSeries {
metric: MetricsExplorerOptionsMetric;
series: MetricsExplorerSeries;
}
export const ChartSection = ({
title,
style,
chartRef,
series,
tickFormatterForTime,
tickFormatter,
onPointerUpdate,
domain,
stack = false,
}: Props) => {
const chartTheme = useTimelineChartTheme();
const metrics = series.map((chartSeries) => chartSeries.metric);
const tooltipProps: TooltipProps = {
headerFormatter: ({ value }) => moment(value).format('Y-MM-DD HH:mm:ss.SSS'),
};
return (
<>
<ChartHeader title={title} metrics={metrics} />
<Chart ref={chartRef} size={CHART_SIZE}>
{series.map((chartSeries, index) => (
<MetricExplorerSeriesChart
type={style}
metric={chartSeries.metric}
id="0"
key={chartSeries.series.id}
series={chartSeries.series}
stack={stack}
/>
))}
<Axis
id={'timestamp'}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={tickFormatterForTime}
/>
<Axis
id={'values'}
position={Position.Left}
tickFormat={tickFormatter}
domain={domain}
ticks={6}
gridLine={{ visible: true }}
/>
<Tooltip {...tooltipProps} />
<Settings
onPointerUpdate={onPointerUpdate}
baseTheme={chartTheme.baseTheme}
theme={chartTheme.theme}
/>
</Chart>
</>
);
};

View file

@ -1,8 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './metrics';

View file

@ -1,460 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { Chart, niceTimeFormatter, PointerEvent } from '@elastic/charts';
import { EuiLoadingChart, EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui';
import { first, last } from 'lodash';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { TabContent, TabProps } from '../shared';
import { useSnapshot } from '../../../../hooks/use_snaphot';
import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options';
import { useSourceContext } from '../../../../../../../containers/metrics_source';
import { findInventoryFields } from '../../../../../../../../common/inventory_models';
import { convertKueryToElasticSearchQuery } from '../../../../../../../utils/kuery';
import { SnapshotMetricType } from '../../../../../../../../common/inventory_models/types';
import {
MetricsExplorerChartType,
MetricsExplorerOptionsMetric,
} from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options';
import { Color } from '../../../../../../../../common/color_palette';
import {
MetricsExplorerAggregation,
MetricsExplorerSeries,
} from '../../../../../../../../common/http_api';
import { createInventoryMetricFormatter } from '../../../../lib/create_inventory_metric_formatter';
import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain';
import { ChartSection } from './chart_section';
import {
SYSTEM_METRIC_NAME,
USER_METRIC_NAME,
INBOUND_METRIC_NAME,
OUTBOUND_METRIC_NAME,
USED_MEMORY_METRIC_NAME,
FREE_MEMORY_METRIC_NAME,
CPU_CHART_TITLE,
LOAD_CHART_TITLE,
MEMORY_CHART_TITLE,
NETWORK_CHART_TITLE,
LOG_RATE_METRIC_NAME,
LOG_RATE_CHART_TITLE,
} from './translations';
import { TimeDropdown } from './time_dropdown';
import { getCustomMetricLabel } from '../../../../../../../../common/formatters/get_custom_metric_label';
import { createFormatterForMetric } from '../../../../../metrics_explorer/components/helpers/create_formatter_for_metric';
const ONE_HOUR = 60 * 60 * 1000;
const TabComponent = (props: TabProps) => {
const cpuChartRef = useRef<Chart>(null);
const networkChartRef = useRef<Chart>(null);
const memoryChartRef = useRef<Chart>(null);
const loadChartRef = useRef<Chart>(null);
const logRateChartRef = useRef<Chart>(null);
const customMetricRefs = useRef<Record<string, Chart | null>>({});
const [time, setTime] = useState(ONE_HOUR);
const chartRefs = useMemo(() => {
const refs = [cpuChartRef, networkChartRef, memoryChartRef, loadChartRef, logRateChartRef];
return [...refs, customMetricRefs];
}, [
cpuChartRef,
networkChartRef,
memoryChartRef,
loadChartRef,
logRateChartRef,
customMetricRefs,
]);
const { sourceId, createDerivedIndexPattern } = useSourceContext();
const { nodeType, accountId, region, customMetrics } = useWaffleOptionsContext();
const { currentTime, node } = props;
const derivedIndexPattern = useMemo(
() => createDerivedIndexPattern(),
[createDerivedIndexPattern]
);
let filter = `${findInventoryFields(nodeType).id}: "${node.id}"`;
if (filter) {
filter = convertKueryToElasticSearchQuery(filter, derivedIndexPattern);
}
const buildCustomMetric = useCallback(
(field: string, id: string, aggregation: string = 'avg') => ({
type: 'custom' as SnapshotMetricType,
aggregation,
field,
id,
}),
[]
);
const updateTime = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
setTime(Number(e.currentTarget.value));
},
[setTime]
);
const timeRange = {
interval: '1m',
to: currentTime,
from: currentTime - time,
ignoreLookback: true,
};
const defaultMetrics: Array<{ type: SnapshotMetricType }> = [
{ type: 'rx' },
{ type: 'tx' },
buildCustomMetric('system.cpu.user.pct', 'user'),
buildCustomMetric('system.cpu.system.pct', 'system'),
buildCustomMetric('system.load.1', 'load1m'),
buildCustomMetric('system.load.5', 'load5m'),
buildCustomMetric('system.load.15', 'load15m'),
buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'),
buildCustomMetric('system.memory.actual.free', 'freeMemory'),
buildCustomMetric('system.cpu.cores', 'cores', 'max'),
];
const { nodes, reload } = useSnapshot({
filterQuery: filter,
metrics: [...defaultMetrics, ...customMetrics],
groupBy: [],
nodeType,
sourceId,
currentTime,
accountId,
region,
sendRequestImmediately: false,
timerange: timeRange,
});
const { nodes: logRateNodes, reload: reloadLogRate } = useSnapshot({
filterQuery: filter,
metrics: [{ type: 'logRate' }],
groupBy: [],
nodeType,
sourceId,
currentTime,
accountId,
region,
sendRequestImmediately: false,
timerange: timeRange,
});
const getDomain = useCallback(
(timeseries: MetricsExplorerSeries, ms: MetricsExplorerOptionsMetric[]) => {
const dataDomain = timeseries ? calculateDomain(timeseries, ms, false) : null;
return dataDomain
? {
max: dataDomain.max * 1.1, // add 10% headroom.
min: dataDomain.min,
}
: { max: 0, min: 0 };
},
[]
);
const dateFormatter = useCallback((timeseries: MetricsExplorerSeries) => {
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]);
}, []);
const networkFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'rx' }), []);
const cpuFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'cpu' }), []);
const memoryFormatter = useMemo(
() => createInventoryMetricFormatter({ type: 's3BucketSize' }),
[]
);
const loadFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'load' }), []);
const logRateFormatter = useMemo(() => createInventoryMetricFormatter({ type: 'logRate' }), []);
const mergeTimeseries = useCallback((...series: MetricsExplorerSeries[]) => {
const base = series[0];
const otherSeries = series.slice(1);
base.rows = base.rows.map((b, rowIdx) => {
const newRow = { ...b };
otherSeries.forEach((o, idx) => {
newRow[`metric_${idx + 1}`] = o.rows[rowIdx].metric_0;
});
return newRow;
});
return base;
}, []);
const buildChartMetricLabels = useCallback(
(labels: string[], aggregation: MetricsExplorerAggregation) => {
const baseMetric = {
color: Color.color0,
aggregation,
label: 'System',
};
return labels.map((label, idx) => {
return { ...baseMetric, color: Color[`color${idx}` as Color], label };
});
},
[]
);
const pointerUpdate = useCallback(
(event: PointerEvent) => {
chartRefs.forEach((ref) => {
if (ref.current) {
if (ref.current instanceof Chart) {
ref.current.dispatchExternalPointerEvent(event);
} else {
const charts = Object.values(ref.current);
charts.forEach((c) => {
if (c) {
c.dispatchExternalPointerEvent(event);
}
});
}
}
});
},
[chartRefs]
);
const getTimeseries = useCallback(
(metricName: string) => {
if (!nodes || !nodes.length) {
return null;
}
return nodes[0].metrics.find((m) => m.name === metricName)!.timeseries!;
},
[nodes]
);
const getLogRateTimeseries = useCallback(() => {
if (!logRateNodes) {
return null;
}
if (logRateNodes.length === 0) {
return { rows: [], columns: [], id: '0' };
}
return logRateNodes[0].metrics.find((m) => m.name === 'logRate')!.timeseries!;
}, [logRateNodes]);
const systemMetricsTs = useMemo(() => getTimeseries('system'), [getTimeseries]);
const userMetricsTs = useMemo(() => getTimeseries('user'), [getTimeseries]);
const rxMetricsTs = useMemo(() => getTimeseries('rx'), [getTimeseries]);
const txMetricsTs = useMemo(() => getTimeseries('tx'), [getTimeseries]);
const load1mMetricsTs = useMemo(() => getTimeseries('load1m'), [getTimeseries]);
const load5mMetricsTs = useMemo(() => getTimeseries('load5m'), [getTimeseries]);
const load15mMetricsTs = useMemo(() => getTimeseries('load15m'), [getTimeseries]);
const usedMemoryMetricsTs = useMemo(() => getTimeseries('usedMemory'), [getTimeseries]);
const freeMemoryMetricsTs = useMemo(() => getTimeseries('freeMemory'), [getTimeseries]);
const coresMetricsTs = useMemo(() => getTimeseries('cores'), [getTimeseries]);
const logRateMetricsTs = useMemo(() => getLogRateTimeseries(), [getLogRateTimeseries]);
useEffect(() => {
reload();
reloadLogRate();
}, [time, reload, reloadLogRate]);
if (
!systemMetricsTs ||
!userMetricsTs ||
!rxMetricsTs ||
!txMetricsTs ||
!load1mMetricsTs ||
!load5mMetricsTs ||
!load15mMetricsTs ||
!usedMemoryMetricsTs ||
!freeMemoryMetricsTs ||
!logRateMetricsTs
) {
return <LoadingPlaceholder />;
}
const cpuChartMetrics = buildChartMetricLabels([SYSTEM_METRIC_NAME, USER_METRIC_NAME], 'avg');
const logRateChartMetrics = buildChartMetricLabels([LOG_RATE_METRIC_NAME], 'rate');
const networkChartMetrics = buildChartMetricLabels(
[INBOUND_METRIC_NAME, OUTBOUND_METRIC_NAME],
'rate'
);
const loadChartMetrics = buildChartMetricLabels(['1m', '5m', '15m'], 'avg');
const memoryChartMetrics = buildChartMetricLabels(
[USED_MEMORY_METRIC_NAME, FREE_MEMORY_METRIC_NAME],
'rate'
);
systemMetricsTs.rows = systemMetricsTs.rows.slice().map((r, idx) => {
const metric = r.metric_0 as number | undefined;
const cores = coresMetricsTs!.rows[idx].metric_0 as number | undefined;
if (metric && cores) {
r.metric_0 = metric / cores;
}
return r;
});
userMetricsTs.rows = userMetricsTs.rows.slice().map((r, idx) => {
const metric = r.metric_0 as number | undefined;
const cores = coresMetricsTs!.rows[idx].metric_0 as number | undefined;
if (metric && cores) {
r.metric_0 = metric / cores;
}
return r;
});
const cpuTimeseries = mergeTimeseries(systemMetricsTs, userMetricsTs);
const logRateTimeseries = mergeTimeseries(logRateMetricsTs);
const networkTimeseries = mergeTimeseries(rxMetricsTs, txMetricsTs);
const loadTimeseries = mergeTimeseries(load1mMetricsTs, load5mMetricsTs, load15mMetricsTs);
const memoryTimeseries = mergeTimeseries(usedMemoryMetricsTs, freeMemoryMetricsTs);
const formatter = dateFormatter(rxMetricsTs);
return (
<TabContent>
<TimeDropdown value={time} onChange={updateTime} />
<EuiSpacer size={'l'} />
<EuiFlexGrid columns={2} gutterSize={'l'} responsive={false}>
<ChartGridItem>
<ChartSection
title={CPU_CHART_TITLE}
style={MetricsExplorerChartType.line}
chartRef={cpuChartRef}
series={[
{ metric: cpuChartMetrics[0], series: systemMetricsTs },
{ metric: cpuChartMetrics[1], series: userMetricsTs },
]}
tickFormatterForTime={formatter}
tickFormatter={cpuFormatter}
onPointerUpdate={pointerUpdate}
domain={getDomain(cpuTimeseries, cpuChartMetrics)}
/>
</ChartGridItem>
<ChartGridItem>
<ChartSection
title={LOAD_CHART_TITLE}
style={MetricsExplorerChartType.line}
chartRef={loadChartRef}
series={[
{ metric: loadChartMetrics[0], series: load1mMetricsTs },
{ metric: loadChartMetrics[1], series: load5mMetricsTs },
{ metric: loadChartMetrics[2], series: load15mMetricsTs },
]}
tickFormatterForTime={formatter}
tickFormatter={loadFormatter}
onPointerUpdate={pointerUpdate}
domain={getDomain(loadTimeseries, loadChartMetrics)}
/>
</ChartGridItem>
<ChartGridItem>
<ChartSection
title={MEMORY_CHART_TITLE}
style={MetricsExplorerChartType.line}
chartRef={memoryChartRef}
series={[
{ metric: memoryChartMetrics[0], series: usedMemoryMetricsTs },
{ metric: memoryChartMetrics[1], series: freeMemoryMetricsTs },
]}
tickFormatterForTime={formatter}
tickFormatter={memoryFormatter}
onPointerUpdate={pointerUpdate}
domain={getDomain(memoryTimeseries, memoryChartMetrics)}
/>
</ChartGridItem>
<ChartGridItem>
<ChartSection
title={NETWORK_CHART_TITLE}
style={MetricsExplorerChartType.line}
chartRef={networkChartRef}
series={[
{ metric: networkChartMetrics[0], series: rxMetricsTs },
{ metric: networkChartMetrics[1], series: txMetricsTs },
]}
tickFormatterForTime={formatter}
tickFormatter={networkFormatter}
onPointerUpdate={pointerUpdate}
domain={getDomain(networkTimeseries, networkChartMetrics)}
stack={true}
/>
</ChartGridItem>
<ChartGridItem>
<ChartSection
title={LOG_RATE_CHART_TITLE}
style={MetricsExplorerChartType.line}
chartRef={logRateChartRef}
series={[{ metric: logRateChartMetrics[0], series: logRateMetricsTs }]}
tickFormatterForTime={formatter}
tickFormatter={logRateFormatter}
onPointerUpdate={pointerUpdate}
domain={getDomain(logRateTimeseries, logRateChartMetrics)}
stack={true}
/>
</ChartGridItem>
{customMetrics.map((c) => {
const metricTS = getTimeseries(c.id);
const chartMetrics = buildChartMetricLabels([c.field], c.aggregation);
if (!metricTS) return null;
return (
<ChartGridItem>
<ChartSection
title={getCustomMetricLabel(c)}
style={MetricsExplorerChartType.line}
chartRef={(r) => {
customMetricRefs.current[c.id] = r;
}}
series={[{ metric: chartMetrics[0], series: metricTS }]}
tickFormatterForTime={formatter}
tickFormatter={createFormatterForMetric(c)}
onPointerUpdate={pointerUpdate}
domain={getDomain(mergeTimeseries(metricTS), chartMetrics)}
stack={true}
/>
</ChartGridItem>
);
})}
</EuiFlexGrid>
</TabContent>
);
};
const ChartGridItem = euiStyled(EuiFlexItem)`
overflow: hidden
`;
const LoadingPlaceholder = () => {
return (
<div
style={{
width: '100%',
height: '200px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiLoadingChart size="xl" />
</div>
);
};
export const MetricsTab = {
id: 'metrics',
name: i18n.translate('xpack.infra.nodeDetails.tabs.metrics', {
defaultMessage: 'Metrics',
}),
content: TabComponent,
};

View file

@ -1,56 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Props {
value: number;
onChange(event: React.ChangeEvent<HTMLSelectElement>): void;
}
export const TimeDropdown = (props: Props) => (
<EuiSelect
data-test-subj="infraTimeDropdownSelect"
fullWidth={true}
options={[
{
text: i18n.translate('xpack.infra.nodeDetails.metrics.last15Minutes', {
defaultMessage: 'Last 15 minutes',
}),
value: 15 * 60 * 1000,
},
{
text: i18n.translate('xpack.infra.nodeDetails.metrics.lastHour', {
defaultMessage: 'Last hour',
}),
value: 60 * 60 * 1000,
},
{
text: i18n.translate('xpack.infra.nodeDetails.metrics.last3Hours', {
defaultMessage: 'Last 3 hours',
}),
value: 3 * 60 * 60 * 1000,
},
{
text: i18n.translate('xpack.infra.nodeDetails.metrics.last24Hours', {
defaultMessage: 'Last 24 hours',
}),
value: 24 * 60 * 60 * 1000,
},
{
text: i18n.translate('xpack.infra.nodeDetails.metrics.last7Days', {
defaultMessage: 'Last 7 days',
}),
value: 7 * 24 * 60 * 60 * 1000,
},
]}
value={props.value}
onChange={props.onChange}
/>
);

View file

@ -1,66 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SYSTEM_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.system', {
defaultMessage: 'System',
});
export const USER_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.user', {
defaultMessage: 'User',
});
export const INBOUND_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.inbound', {
defaultMessage: 'Inbound',
});
export const OUTBOUND_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.outbound', {
defaultMessage: 'Outbound',
});
export const USED_MEMORY_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.used', {
defaultMessage: 'Used',
});
export const CACHED_MEMORY_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.cached', {
defaultMessage: 'Cached',
});
export const FREE_MEMORY_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.free', {
defaultMessage: 'Free',
});
export const NETWORK_CHART_TITLE = i18n.translate(
'xpack.infra.nodeDetails.metrics.charts.networkTitle',
{
defaultMessage: 'Network',
}
);
export const MEMORY_CHART_TITLE = i18n.translate(
'xpack.infra.nodeDetails.metrics.charts.memoryTitle',
{
defaultMessage: 'Memory',
}
);
export const CPU_CHART_TITLE = i18n.translate('xpack.infra.nodeDetails.metrics.fcharts.cpuTitle', {
defaultMessage: 'CPU',
});
export const LOAD_CHART_TITLE = i18n.translate('xpack.infra.nodeDetails.metrics.charts.loadTitle', {
defaultMessage: 'Load',
});
export const LOG_RATE_METRIC_NAME = i18n.translate('xpack.infra.nodeDetails.metrics.logRate', {
defaultMessage: 'Log Rate',
});
export const LOG_RATE_CHART_TITLE = i18n.translate(
'xpack.infra.nodeDetails.metrics.charts.logRateTitle',
{
defaultMessage: 'Log Rate',
}
);

View file

@ -1,63 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiSkeletonText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { useKibanaContextForPlugin } from '../../../../../../../hooks/use_kibana';
import { TabContent, TabProps } from '../shared';
import { useSourceContext } from '../../../../../../../containers/metrics_source';
import { InventoryItemType } from '../../../../../../../../common/inventory_models/types';
import { useMetadata } from '../../../../../../../components/asset_details/hooks/use_metadata';
import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time';
const TabComponent = (props: TabProps) => {
const nodeId = props.node.id;
const nodeType = props.nodeType as InventoryItemType;
const { sourceId } = useSourceContext();
const { currentTimeRange } = useWaffleTimeContext();
const { loading, metadata } = useMetadata({
assetId: nodeId,
assetType: nodeType,
sourceId,
timeRange: currentTimeRange,
});
const {
services: { osquery },
} = useKibanaContextForPlugin();
// @ts-expect-error
const OsqueryAction = osquery?.OsqueryAction;
// avoids component rerender when resizing the popover
const content = useMemo(() => {
// TODO: Add info when Osquery plugin is not available
if (loading || !OsqueryAction) {
return (
<TabContent>
<EuiSkeletonText lines={10} />
</TabContent>
);
}
return (
<TabContent>
<OsqueryAction agentId={metadata?.info?.agent?.id} hideAgentsField formType="simple" />
</TabContent>
);
}, [OsqueryAction, loading, metadata]);
return content;
};
export const OsqueryTab = {
id: 'osquery',
name: i18n.translate('xpack.infra.nodeDetails.tabs.osquery', {
defaultMessage: 'Osquery',
}),
content: TabComponent,
};

View file

@ -1,171 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState, useCallback } from 'react';
import { debounce } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiSearchBar,
EuiSpacer,
EuiEmptyPrompt,
EuiButton,
EuiText,
EuiIconTip,
Query,
} from '@elastic/eui';
import { getFieldByType } from '../../../../../../../../common/inventory_models';
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 }: TabProps) => {
const [searchBarState, setSearchBarState] = useState<Query>(Query.MATCH_ALL);
const [searchFilter, setSearchFilter] = useState<string>('');
const [sortBy, setSortBy] = useState<SortBy>({
name: 'cpu',
isAscending: false,
});
const hostTerm = useMemo(() => {
const field = getFieldByType(nodeType) ?? nodeType;
return { [field]: node.name };
}, [node, nodeType]);
const {
loading,
error,
response,
makeRequest: reload,
} = useProcessList(hostTerm, currentTime, sortBy, parseSearchString(searchFilter));
const debouncedSearchOnChange = useMemo(
() => debounce<(queryText: string) => void>((queryText) => setSearchFilter(queryText), 500),
[setSearchFilter]
);
const searchBarOnChange = useCallback(
({ query, queryText }) => {
setSearchBarState(query);
debouncedSearchOnChange(queryText);
},
[setSearchBarState, debouncedSearchOnChange]
);
const clearSearchBar = useCallback(() => {
setSearchBarState(Query.MATCH_ALL);
setSearchFilter('');
}, [setSearchBarState, setSearchFilter]);
return (
<TabContent>
<ProcessListContextProvider hostTerm={hostTerm} to={currentTime}>
<SummaryTable
isLoading={loading}
processSummary={(!error ? response?.summary : null) ?? { total: 0 }}
/>
<EuiSpacer size="m" />
<EuiText>
<h4>
{i18n.translate('xpack.infra.metrics.nodeDetails.processesHeader', {
defaultMessage: 'Top processes',
})}{' '}
<EuiIconTip
aria-label={i18n.translate(
'xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel',
{
defaultMessage: 'More info',
}
)}
size="m"
type="iInCircle"
content={i18n.translate(
'xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody',
{
defaultMessage:
'The table below aggregates the top CPU and top memory consuming processes. It does not display all processes.',
}
)}
/>
</h4>
</EuiText>
<EuiSpacer size="m" />
<EuiSearchBar
query={searchBarState}
onChange={searchBarOnChange}
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}
clearSearchBar={clearSearchBar}
/>
) : (
<EuiEmptyPrompt
iconType="warning"
title={
<h4>
{i18n.translate('xpack.infra.metrics.nodeDetails.processListError', {
defaultMessage: 'Unable to load process data',
})}
</h4>
}
actions={
<EuiButton
data-test-subj="infraTabComponentTryAgainButton"
color="primary"
fill
onClick={reload}
>
{i18n.translate('xpack.infra.metrics.nodeDetails.processListRetry', {
defaultMessage: 'Try again',
})}
</EuiButton>
}
/>
)}
</ProcessListContextProvider>
</TabContent>
);
};
export const ProcessesTab = {
id: 'processes',
name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', {
defaultMessage: 'Processes',
}),
content: TabComponent,
};

View file

@ -1,39 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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

@ -1,311 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState, useCallback } from 'react';
import { omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiTable,
EuiTableHeader,
EuiTableBody,
EuiTableHeaderCell,
EuiTableRowCell,
EuiLoadingChart,
EuiEmptyPrompt,
EuiText,
EuiLink,
EuiButton,
SortableProperties,
LEFT_ALIGNMENT,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ProcessListAPIResponse } from '../../../../../../../../common/http_api';
import { FORMATTERS } from '../../../../../../../../common/formatters';
import { SortBy } from '../../../../hooks/use_process_list';
import { Process } from './types';
import { ProcessRow } from './process_row';
import { StateBadge } from './state_badge';
import { STATE_ORDER } from './states';
interface TableProps {
processList: ProcessListAPIResponse['processList'];
currentTime: number;
isLoading: boolean;
sortBy: SortBy;
setSortBy: (s: SortBy) => void;
clearSearchBar: () => void;
}
function useSortableProperties<T>(
sortablePropertyItems: Array<{
name: string;
getValue: (obj: T) => any;
isAscending: boolean;
}>,
defaultSortProperty: string,
callback: (s: SortBy) => void
) {
const [sortableProperties] = useState<SortableProperties<T>>(
new SortableProperties(sortablePropertyItems, defaultSortProperty)
);
return {
updateSortableProperties: useCallback(
(property) => {
sortableProperties.sortOn(property);
callback(omit(sortableProperties.getSortedProperty(), 'getValue'));
},
[sortableProperties, callback]
),
};
}
export const ProcessesTable = ({
processList,
currentTime,
isLoading,
sortBy,
setSortBy,
clearSearchBar,
}: TableProps) => {
const { updateSortableProperties } = useSortableProperties<Process>(
[
{
name: 'startTime',
getValue: (item: any) => Date.parse(item.startTime),
isAscending: true,
},
{
name: 'cpu',
getValue: (item: any) => item.cpu,
isAscending: false,
},
{
name: 'memory',
getValue: (item: any) => item.memory,
isAscending: false,
},
],
'cpu',
setSortBy
);
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="search"
titleSize="s"
title={
<strong>
{i18n.translate('xpack.infra.metrics.nodeDetails.noProcesses', {
defaultMessage: 'No processes found',
})}
</strong>
}
body={
<EuiText size="s">
<FormattedMessage
id="xpack.infra.metrics.nodeDetails.noProcessesBody"
defaultMessage="Try modifying your filter. Only processes that are within the configured {metricbeatDocsLink} will display here."
values={{
metricbeatDocsLink: (
<EuiLink
data-test-subj="infraProcessesTableTopNByCpuOrMemoryLink"
href="https://www.elastic.co/guide/en/beats/metricbeat/current/metricbeat-module-system.html"
target="_blank"
>
<FormattedMessage
id="xpack.infra.metrics.nodeDetails.noProcessesBody.metricbeatDocsLinkText"
defaultMessage="top N by CPU or Memory"
/>
</EuiLink>
),
}}
/>
</EuiText>
}
actions={
<EuiButton
data-test-subj="infraProcessesTableClearFiltersButton"
onClick={clearSearchBar}
>
{i18n.translate('xpack.infra.metrics.nodeDetails.noProcessesClearFilters', {
defaultMessage: 'Clear filters',
})}
</EuiButton>
}
/>
);
return (
<>
<EuiTable data-test-subj="infraProcessesTable" responsive={false}>
<EuiTableHeader>
<EuiTableHeaderCell width={24} />
{columns.map((column) => (
<EuiTableHeaderCell
key={`${String(column.field)}-header`}
align={column.align ?? LEFT_ALIGNMENT}
width={column.width}
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={currentItems} currentTime={currentTime} />
</StyledTableBody>
</EuiTable>
</>
);
};
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}`}
mobileOptions={{ 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: number; currentTime: number }) => {
const runtimeLength = currentTime - 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: false,
render: (state: string) => <StateBadge state={state} />,
width: 84,
textOnly: false,
},
{
field: 'command',
name: i18n.translate('xpack.infra.metrics.nodeDetails.processes.columnLabelCommand', {
defaultMessage: 'Command',
}),
sortable: false,
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: number, 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),
},
];
const CodeLine = euiStyled.div`
font-family: ${(props) => props.theme.eui.euiCodeFontFamily};
font-size: ${(props) => props.theme.eui.euiFontSizeS};
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
`;

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React 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="success">{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

@ -1,34 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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

@ -1,93 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { mapValues } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiHorizontalRule,
} from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ProcessListAPIResponse } from '../../../../../../../../common/http_api';
import { STATE_NAMES } from './states';
interface Props {
processSummary: ProcessListAPIResponse['summary'];
isLoading: boolean;
}
type SummaryRecord = {
total: number;
} & Record<keyof typeof STATE_NAMES, number>;
const NOT_AVAILABLE_LABEL = i18n.translate('xpack.infra.notAvailableLabel', {
defaultMessage: 'N/A',
});
const processSummaryNotAvailable = {
total: NOT_AVAILABLE_LABEL,
running: NOT_AVAILABLE_LABEL,
sleeping: NOT_AVAILABLE_LABEL,
dead: NOT_AVAILABLE_LABEL,
stopped: NOT_AVAILABLE_LABEL,
idle: NOT_AVAILABLE_LABEL,
zombie: NOT_AVAILABLE_LABEL,
unknown: NOT_AVAILABLE_LABEL,
};
export const SummaryTable = ({ processSummary, isLoading }: Props) => {
const summary = !processSummary?.total ? processSummaryNotAvailable : processSummary;
const processCount = useMemo(
() =>
({
total: isLoading ? -1 : summary.total,
...mapValues(STATE_NAMES, () => (isLoading ? -1 : 0)),
...(isLoading ? {} : summary),
} as SummaryRecord),
[summary, isLoading]
);
return (
<>
<EuiFlexGroup gutterSize="m" responsive={false} wrap={true}>
{Object.entries(processCount).map(([field, value]) => (
<EuiFlexItem key={field}>
<EuiDescriptionList data-test-subj="infraProcessesSummaryTableItem" compressed>
<ColumnTitle>{columnTitles[field as keyof SummaryRecord]}</ColumnTitle>
<EuiDescriptionListDescription>
{value === -1 ? <LoadingSpinner /> : value}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
))}
</EuiFlexGroup>
<EuiHorizontalRule margin="m" />
</>
);
};
const columnTitles = {
total: i18n.translate('xpack.infra.metrics.nodeDetails.processes.headingTotalProcesses', {
defaultMessage: 'Total processes',
}),
...STATE_NAMES,
};
const LoadingSpinner = euiStyled(EuiLoadingSpinner).attrs({ size: 'm' })`
margin-top: 2px;
margin-bottom: 3px;
`;
const ColumnTitle = euiStyled(EuiDescriptionListTitle)`
white-space: nowrap;
`;

View file

@ -1,23 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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

@ -1,117 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { InfraMetadata } from '../../../../../../../../common/http_api';
export const getFields = (metadata: InfraMetadata, group: 'cloud' | 'host' | 'agent') => {
switch (group) {
case 'host':
return prune([
{
name: 'host.architecture',
value: metadata.info?.host?.architecture,
},
{
name: 'host.hostname',
value: metadata.info?.host?.name,
},
{
name: 'host.id',
value: metadata.info?.host?.id,
},
{
name: 'host.ip',
value: metadata.info?.host?.ip,
},
{
name: 'host.mac',
value: metadata.info?.host?.mac,
},
{
name: 'host.name',
value: metadata.info?.host?.name,
},
{
name: 'host.os.build',
value: metadata.info?.host?.os?.build,
},
{
name: 'host.os.family',
value: metadata.info?.host?.os?.family,
},
{
name: 'host.os.name',
value: metadata.info?.host?.os?.name,
},
{
name: 'host.os.kernel',
value: metadata.info?.host?.os?.kernel,
},
{
name: 'host.os.platform',
value: metadata.info?.host?.os?.platform,
},
{
name: 'host.os.version',
value: metadata.info?.host?.os?.version,
},
]);
case 'cloud':
return prune([
{
name: 'cloud.account.id',
value: metadata.info?.cloud?.account?.id,
},
{
name: 'cloud.account.name',
value: metadata.info?.cloud?.account?.name,
},
{
name: 'cloud.availability_zone',
value: metadata.info?.cloud?.availability_zone,
},
{
name: 'cloud.instance.id',
value: metadata.info?.cloud?.instance?.id,
},
{
name: 'cloud.instance.name',
value: metadata.info?.cloud?.instance?.name,
},
{
name: 'cloud.machine.type',
value: metadata.info?.cloud?.machine?.type,
},
{
name: 'cloud.provider',
value: metadata.info?.cloud?.provider,
},
{
name: 'cloud.region',
value: metadata.info?.cloud?.region,
},
]);
case 'agent':
return prune([
{
name: 'agent.id',
value: metadata.info?.agent?.id,
},
{
name: 'agent.version',
value: metadata.info?.agent?.version,
},
{
name: 'agent.policy',
value: metadata.info?.agent?.policy,
},
]);
}
};
const prune = (fields: Array<{ name: string; value: string | string[] | undefined }>) =>
fields.filter((f) => !!f.value);

View file

@ -1,131 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLoadingChart } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { TabContent, TabProps } from '../shared';
import { useSourceContext } from '../../../../../../../containers/metrics_source';
import { InventoryItemType } from '../../../../../../../../common/inventory_models/types';
import { useMetadata } from '../../../../../../../components/asset_details/hooks/use_metadata';
import { getFields } from './build_fields';
import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time';
import { Table } from './table';
import { useWaffleFiltersContext } from '../../../../hooks/use_waffle_filters';
const TabComponent = (props: TabProps) => {
const nodeId = props.node.id;
const nodeType = props.nodeType as InventoryItemType;
const { sourceId } = useSourceContext();
const { currentTimeRange } = useWaffleTimeContext();
const { applyFilterQuery } = useWaffleFiltersContext();
const { loading: metadataLoading, metadata } = useMetadata({
assetId: nodeId,
assetType: nodeType,
sourceId,
timeRange: currentTimeRange,
});
const hostFields = useMemo(() => {
if (!metadata) return null;
return getFields(metadata, 'host');
}, [metadata]);
const cloudFields = useMemo(() => {
if (!metadata) return null;
return getFields(metadata, 'cloud');
}, [metadata]);
const agentFields = useMemo(() => {
if (!metadata) return null;
return getFields(metadata, 'agent');
}, [metadata]);
const onFilter = useCallback(
(item: { name: string; value: string }) => {
applyFilterQuery({
kind: 'kuery',
expression: `${item.name}: "${item.value}"`,
});
},
[applyFilterQuery]
);
if (metadataLoading) {
return <LoadingPlaceholder />;
}
return (
<TabContent>
{hostFields && hostFields.length > 0 && (
<TableWrapper>
<Table
title={i18n.translate('xpack.infra.nodeDetails.tabs.metadata.hostsHeader', {
defaultMessage: 'Hosts',
})}
onClick={onFilter}
rows={hostFields}
/>
</TableWrapper>
)}
{cloudFields && cloudFields.length > 0 && (
<TableWrapper>
<Table
title={i18n.translate('xpack.infra.nodeDetails.tabs.metadata.cloudHeader', {
defaultMessage: 'Cloud',
})}
onClick={onFilter}
rows={cloudFields}
/>
</TableWrapper>
)}
{agentFields && agentFields.length > 0 && (
<TableWrapper>
<Table
title={i18n.translate('xpack.infra.nodeDetails.tabs.metadata.agentHeader', {
defaultMessage: 'Agent',
})}
onClick={onFilter}
rows={agentFields}
/>
</TableWrapper>
)}
</TabContent>
);
};
const TableWrapper = euiStyled.div`
&:not(:last-child) {
margin-bottom: 16px
}
`;
const LoadingPlaceholder = () => {
return (
<div
style={{
width: '100%',
height: '200px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiLoadingChart size="xl" />
</div>
);
};
export const PropertiesTab = {
id: 'properties',
name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', {
defaultMessage: 'Metadata',
}),
content: TabComponent,
};

View file

@ -1,167 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiText,
EuiToolTip,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiBasicTable,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import useToggle from 'react-use/lib/useToggle';
interface Row {
name: string;
value: string | string[] | undefined;
}
interface Props {
rows: Row[];
title: string;
onClick(item: Row): void;
}
export const Table = (props: Props) => {
const { rows, title, onClick } = props;
const columns = useMemo(
() => [
{
field: 'name',
name: '',
width: '35%',
sortable: false,
render: (name: string, item: Row) => (
<EuiText size="xs">
<strong>{item.name}</strong>
</EuiText>
),
},
{
field: 'value',
name: '',
width: '65%',
sortable: false,
render: (_name: string, item: Row) => {
return (
<span>
<EuiFlexGroup gutterSize={'xs'} alignItems={'center'} responsive={false}>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate(
'xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip',
{
defaultMessage: 'View event with filter',
}
)}
>
<EuiButtonIcon
data-test-subj="infraColumnsButton"
color="text"
size="s"
iconType="filter"
aria-label={i18n.translate(
'xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel',
{
defaultMessage: 'Filter',
}
)}
onClick={() => onClick(item)}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<ExpandableContent values={item.value} />
</EuiFlexItem>
</EuiFlexGroup>
</span>
);
},
},
],
[onClick]
);
return (
<>
<EuiText>
<h4>{title}</h4>
</EuiText>
<EuiSpacer size={'s'} />
<TableWithoutHeader
tableLayout={'fixed'}
compressed
responsive={false}
columns={columns}
items={rows}
/>
</>
);
};
class TableWithoutHeader extends EuiBasicTable {
renderTableHead() {
return <></>;
}
}
interface ExpandableContentProps {
values: string | string[] | undefined;
}
const ExpandableContent = (props: ExpandableContentProps) => {
const { values } = props;
const [isExpanded, toggle] = useToggle(false);
const list = Array.isArray(values) ? values : [values];
const [first, ...others] = list;
const hasOthers = others.length > 0;
const shouldShowMore = hasOthers && !isExpanded;
return (
<EuiFlexGroup
gutterSize={'xs'}
responsive={false}
alignItems={'baseline'}
wrap={true}
direction="column"
>
<div>
{first}
{shouldShowMore && (
<>
{' ... '}
<EuiLink data-test-subj="infraArrayValueCountMoreLink" onClick={toggle}>
<FormattedMessage
id="xpack.infra.nodeDetails.tabs.metadata.seeMore"
defaultMessage="+{count} more"
values={{
count: others.length,
}}
/>
</EuiLink>
</>
)}
</div>
{isExpanded && others.map((item) => <EuiFlexItem key={item}>{item}</EuiFlexItem>)}
{hasOthers && isExpanded && (
<EuiFlexItem>
<EuiLink data-test-subj="infraArrayValueShowLessLink" onClick={toggle}>
{i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', {
defaultMessage: 'Show less',
})}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { InventoryItemType } from '../../../../../../../common/inventory_models/types';
import { InfraWaffleMapOptions, InfraWaffleMapNode } from '../../../../../../lib/lib';
export interface TabProps {
options: InfraWaffleMapOptions;
currentTime: number;
node: InfraWaffleMapNode;
nodeType: InventoryItemType;
onClose(): void;
}
export const OVERLAY_Y_START = 266;
export const OVERLAY_BOTTOM_MARGIN = 16;
export const TabContent = euiStyled.div`
padding: ${(props) => props.theme.eui.euiSizeM};
flex: 1;
overflow-y: auto;
overflow-x: hidden;
`;

View file

@ -19,6 +19,8 @@ import { TableView } from './table_view';
import { SnapshotNode } from '../../../../../common/http_api/snapshot_api';
import { calculateBoundsFromNodes } from '../lib/calculate_bounds_from_nodes';
import { Legend } from './waffle/legend';
import { useAssetDetailsFlyoutState } from '../hooks/use_asset_details_flyout_url_state';
import { AssetDetailsFlyout } from './waffle/asset_details_flyout';
export interface KueryFilterQuery {
kind: 'kuery';
@ -57,6 +59,12 @@ export const NodesOverview = ({
showLoading,
}: Props) => {
const currentBreakpoint = useCurrentEuiBreakpoint();
const [{ detailsItemId }, setFlyoutUrlState] = useAssetDetailsFlyoutState();
const closeFlyout = useCallback(
() => setFlyoutUrlState({ detailsItemId: null }),
[setFlyoutUrlState]
);
const handleDrilldown = useCallback(
(filter: string) => {
@ -123,6 +131,7 @@ export const NodesOverview = ({
<Map
nodeType={nodeType}
nodes={nodes}
detailsItemId={detailsItemId}
options={options}
formatter={formatter}
currentTime={currentTime}
@ -132,6 +141,14 @@ export const NodesOverview = ({
bottomMargin={bottomMargin}
staticHeight={isStatic}
/>
{nodeType === 'host' && detailsItemId && (
<AssetDetailsFlyout
closeFlyout={closeFlyout}
assetName={detailsItemId}
assetType={nodeType}
currentTime={currentTime}
/>
)}
<Legend
formatter={formatter}
bounds={bounds}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ContentTabIds } from '../../../../../components/asset_details/types';
import { InventoryItemType } from '../../../../../../common/inventory_models/types';
import AssetDetails from '../../../../../components/asset_details/asset_details';
import { useSourceContext } from '../../../../../containers/metrics_source';
import { commonFlyoutTabs } from '../../../../../common/asset_details_config/asset_details_tabs';
interface Props {
assetName: string;
assetType: InventoryItemType;
closeFlyout: () => void;
currentTime: number;
}
const ONE_HOUR = 60 * 60 * 1000;
const flyoutTabs = [
...commonFlyoutTabs,
{
id: ContentTabIds.LINK_TO_APM,
name: i18n.translate('xpack.infra.nodeDetails.tabs.linkToApm', {
defaultMessage: 'APM',
}),
},
];
export const AssetDetailsFlyout = ({ assetName, assetType, closeFlyout, currentTime }: Props) => {
const { source } = useSourceContext();
return source ? (
<AssetDetails
asset={{ id: assetName, name: assetName }}
assetType={assetType}
overrides={{
metadata: {
showActionsColumn: false,
},
}}
tabs={flyoutTabs}
links={['nodeDetails']}
renderMode={{
mode: 'flyout',
closeFlyout,
}}
metricAlias={source.configuration.metricAlias}
dateRange={{
from: new Date(currentTime - ONE_HOUR).toISOString(),
to: new Date(currentTime).toISOString(),
}}
/>
) : null;
};

View file

@ -25,6 +25,7 @@ interface Props {
bounds: InfraWaffleMapBounds;
nodeType: InventoryItemType;
currentTime: number;
detailsItemId: string | null;
}
export const GroupOfGroups: React.FC<Props> = (props) => {
@ -43,6 +44,7 @@ export const GroupOfGroups: React.FC<Props> = (props) => {
bounds={props.bounds}
nodeType={props.nodeType}
currentTime={props.currentTime}
detailsItemId={props.detailsItemId}
/>
))}
</Groups>

View file

@ -17,6 +17,7 @@ import {
import { GroupName } from './group_name';
import { Node } from './node';
import { InventoryItemType } from '../../../../../../common/inventory_models/types';
import { useAssetDetailsFlyoutState } from '../../hooks/use_asset_details_flyout_url_state';
interface Props {
onDrilldown: (filter: string) => void;
@ -27,6 +28,7 @@ interface Props {
bounds: InfraWaffleMapBounds;
nodeType: InventoryItemType;
currentTime: number;
detailsItemId: string | null;
}
// custom comparison function for rendering the nodes to prevent unncessary rerendering
@ -42,8 +44,20 @@ const isEqualGroupOfNodes = (prevProps: Props, nextProps: Props) => {
};
export const GroupOfNodes = React.memo<Props>(
({ group, options, formatter, onDrilldown, isChild = false, bounds, nodeType, currentTime }) => {
({
group,
options,
formatter,
onDrilldown,
isChild = false,
bounds,
nodeType,
currentTime,
detailsItemId,
}) => {
const width = group.width > 200 ? group.width : 200;
const [_, setFlyoutUrlState] = useAssetDetailsFlyoutState();
return (
<GroupOfNodesContainer style={{ width }}>
<GroupName group={group} onDrilldown={onDrilldown} isChild={isChild} options={options} />
@ -59,6 +73,8 @@ export const GroupOfNodes = React.memo<Props>(
bounds={bounds}
nodeType={nodeType}
currentTime={currentTime}
detailsItemId={detailsItemId}
setFlyoutUrlState={setFlyoutUrlState}
/>
))
) : (

View file

@ -30,6 +30,7 @@ interface Props {
dataBounds: InfraWaffleMapBounds;
bottomMargin: number;
staticHeight: boolean;
detailsItemId: string | null;
}
export const Map: React.FC<Props> = ({
@ -43,6 +44,7 @@ export const Map: React.FC<Props> = ({
dataBounds,
bottomMargin,
staticHeight,
detailsItemId,
}) => {
const sortedNodes = sortNodes(options.sort, nodes);
const map = nodesToWaffleMap(sortedNodes);
@ -70,6 +72,7 @@ export const Map: React.FC<Props> = ({
bounds={bounds}
nodeType={nodeType}
currentTime={currentTime}
detailsItemId={detailsItemId}
/>
);
}
@ -85,6 +88,7 @@ export const Map: React.FC<Props> = ({
bounds={bounds}
nodeType={nodeType}
currentTime={currentTime}
detailsItemId={detailsItemId}
/>
);
}

View file

@ -5,14 +5,11 @@
* 2.0.
*/
import { darken, readableColor } from 'polished';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { first } from 'lodash';
import { EuiPopover, EuiToolTip } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { useBoolean } from '../../../../../hooks/use_boolean';
import {
InfraWaffleMapBounds,
InfraWaffleMapNode,
@ -21,20 +18,10 @@ import {
import { ConditionalToolTip } from './conditional_tooltip';
import { colorFromValue } from '../../lib/color_from_value';
import { InventoryItemType } from '../../../../../../common/inventory_models/types';
import { NodeContextPopover } from '../node_details/overlay';
import { NodeContextMenu } from './node_context_menu';
import { AlertFlyout } from '../../../../../alerting/inventory/components/alert_flyout';
import { findInventoryFields } from '../../../../../../common/inventory_models';
const initialState = {
isPopoverOpen: false,
isOverlayOpen: false,
isAlertFlyoutVisible: false,
isToolTipOpen: false,
};
type State = Readonly<typeof initialState>;
import { NodeSquare } from './node_square';
import { type AssetDetailsFlyoutPropertiesUpdater } from '../../hooks/use_asset_details_flyout_url_state';
interface Props {
squareSize: number;
@ -44,247 +31,78 @@ interface Props {
bounds: InfraWaffleMapBounds;
nodeType: InventoryItemType;
currentTime: number;
setFlyoutUrlState: AssetDetailsFlyoutPropertiesUpdater;
detailsItemId: string | null;
}
export class Node extends React.PureComponent<Props, State> {
public readonly state: State = initialState;
public render() {
const { nodeType, node, options, squareSize, bounds, formatter, currentTime } = this.props;
const { isPopoverOpen, isAlertFlyoutVisible, isToolTipOpen } = this.state;
const metric = first(node.metrics);
const valueMode = squareSize > 70;
const ellipsisMode = squareSize > 30;
const rawValue = (metric && metric.value) || 0;
const color = colorFromValue(options.legend, rawValue, bounds);
const value = formatter(rawValue);
const nodeAriaLabel = i18n.translate('xpack.infra.node.ariaLabel', {
defaultMessage: '{nodeName}, click to open menu',
values: { nodeName: node.name },
});
export const Node = ({
nodeType,
node,
options,
squareSize,
bounds,
formatter,
currentTime,
setFlyoutUrlState,
detailsItemId,
}: Props) => {
const [isToolTipOpen, { off: hideToolTip, on: showToolTip }] = useBoolean(false);
const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false);
const nodeBorder = this.state.isOverlayOpen ? { border: 'solid 4px #000' } : undefined;
const metric = first(node.metrics);
const rawValue = (metric && metric.value) || 0;
const color = colorFromValue(options.legend, rawValue, bounds);
const value = formatter(rawValue);
const bigSquare = (
<NodeContainer
data-test-subj="nodeContainer"
style={{ width: squareSize || 0, height: squareSize || 0 }}
onClick={this.togglePopover}
onMouseOver={this.showToolTip}
onMouseLeave={this.hideToolTip}
className="buttonContainer"
>
<SquareOuter color={color} style={nodeBorder}>
<SquareInner color={color}>
{valueMode ? (
<ValueInner aria-label={nodeAriaLabel}>
<Label data-test-subj="nodeName" color={color}>
{node.name}
</Label>
<Value data-test-subj="nodeValue" color={color}>
{value}
</Value>
</ValueInner>
) : (
ellipsisMode && (
<ValueInner aria-label={nodeAriaLabel}>
<Label color={color}>...</Label>
</ValueInner>
)
)}
</SquareInner>
</SquareOuter>
</NodeContainer>
);
const toggleAssetPopover = () => {
if (nodeType === 'host') {
setFlyoutUrlState({ detailsItemId: node.name });
} else {
togglePopover();
}
};
const smallSquare = (
<NodeContainerSmall
data-test-subj="nodeContainer"
style={{ width: squareSize || 0, height: squareSize || 0, ...nodeBorder }}
onClick={this.togglePopover}
onMouseOver={this.showToolTip}
onMouseLeave={this.hideToolTip}
color={color}
/>
);
const nodeSquare = (
<NodeSquare
squareSize={squareSize}
togglePopover={toggleAssetPopover}
showToolTip={showToolTip}
hideToolTip={hideToolTip}
color={color}
nodeName={node.name}
value={value}
showBorder={detailsItemId === node.name || isPopoverOpen}
/>
);
const nodeSquare = valueMode || ellipsisMode ? bigSquare : smallSquare;
return (
<>
{isPopoverOpen ? (
<EuiPopover
button={nodeSquare}
isOpen={isPopoverOpen}
closePopover={this.closePopover}
anchorPosition="downCenter"
style={{ height: squareSize }}
>
<NodeContextMenu
node={node}
nodeType={nodeType}
options={options}
currentTime={currentTime}
/>
</EuiPopover>
) : isToolTipOpen ? (
<EuiToolTip
delay="regular"
position="right"
content={
<ConditionalToolTip currentTime={currentTime} node={node} nodeType={nodeType} />
}
>
{nodeSquare}
</EuiToolTip>
) : (
nodeSquare
)}
{this.state.isOverlayOpen && (
<NodeContextPopover
openAlertFlyout={this.openAlertFlyout}
return (
<>
{isPopoverOpen ? (
<EuiPopover
button={nodeSquare}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="downCenter"
style={{ height: squareSize }}
>
<NodeContextMenu
node={node}
nodeType={nodeType}
isOpen={this.state.isOverlayOpen}
options={options}
currentTime={currentTime}
onClose={this.toggleNewOverlay}
/>
)}
{isAlertFlyoutVisible && (
<AlertFlyout
filter={`${findInventoryFields(nodeType).id}: "${node.id}"`}
options={options}
nodeType={nodeType}
setVisible={this.setAlertFlyoutVisible}
visible={isAlertFlyoutVisible}
/>
)}
</>
);
}
private openAlertFlyout = () => {
this.setState({
isOverlayOpen: false,
isAlertFlyoutVisible: true,
});
};
private setAlertFlyoutVisible = (isOpen: boolean) => {
this.setState({
isAlertFlyoutVisible: isOpen,
});
};
private togglePopover = () => {
const { nodeType } = this.props;
if (nodeType === 'host') {
this.toggleNewOverlay();
} else {
this.setState((prevState) => ({ isPopoverOpen: !prevState.isPopoverOpen }));
}
};
private toggleNewOverlay = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isOverlayOpen === true ? false : prevState.isPopoverOpen,
isOverlayOpen: !prevState.isOverlayOpen,
}));
};
private closePopover = () => {
if (this.state.isPopoverOpen) {
this.setState({ isPopoverOpen: false });
}
};
private showToolTip = () => {
this.setState({ isToolTipOpen: true });
};
private hideToolTip = () => {
this.setState({ isToolTipOpen: false });
};
}
const NodeContainer = euiStyled.div`
position: relative;
cursor: pointer;
`;
const NodeContainerSmall = euiStyled.div<ColorProps>`
cursor: pointer;
position: relative;
background-color: ${(props) => darken(0.1, props.color)};
border-radius: 3px;
margin: 2px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
`;
interface ColorProps {
color: string;
}
const SquareOuter = euiStyled.div<ColorProps>`
position: absolute;
top: 4px;
left: 4px;
bottom: 4px;
right: 4px;
background-color: ${(props) => darken(0.1, props.color)};
border-radius: 3px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
`;
const SquareInner = euiStyled.div<ColorProps>`
position: absolute;
top: 0;
right: 0;
bottom: 2px;
left: 0;
border-radius: 3px;
background-color: ${(props) => props.color};
`;
const ValueInner = euiStyled.button`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
line-height: 1.2em;
align-items: center;
align-content: center;
padding: 1em;
overflow: hidden;
flex-wrap: wrap;
width: 100%;
border: none;
&:focus {
outline: none !important;
border: ${(params) => params.theme?.eui.euiFocusRingSize} solid
${(params) => params.theme?.eui.euiFocusRingColor};
box-shadow: none;
}
`;
const SquareTextContent = euiStyled.div<ColorProps>`
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
color: ${(props) => readableColor(props.color)};
`;
const Value = euiStyled(SquareTextContent)`
font-weight: bold;
font-size: 0.9em;
line-height: 1.2em;
`;
const Label = euiStyled(SquareTextContent)`
font-size: 0.7em;
margin-bottom: 0.7em;
`;
</EuiPopover>
) : isToolTipOpen ? (
<EuiToolTip
delay="regular"
position="right"
content={<ConditionalToolTip currentTime={currentTime} node={node} nodeType={nodeType} />}
>
{nodeSquare}
</EuiToolTip>
) : (
nodeSquare
)}
</>
);
};

View file

@ -0,0 +1,199 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { darken, readableColor } from 'polished';
import React, { CSSProperties } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { DispatchWithOptionalAction } from '../../../../../hooks/use_boolean';
const SquareTextContentStyles = (color: string) => `
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 0 auto;
color: ${readableColor(color)};
`;
const styles = {
nodeContainerSmall: (color: string) => `
cursor: pointer;
position: relative;
background-color: ${darken(0.1, color)};
border-radius: 3px;
margin: 2px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
`,
valueInner: `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
line-height: 1.2em;
align-items: center;
align-content: center;
padding: 1em;
overflow: hidden;
flex-wrap: wrap;
width: 100%;
border: none;
&:focus {
outline: none !important;
border: ${euiThemeVars.euiFocusRingSize} solid ${euiThemeVars.euiFocusRingColor};
box-shadow: none;
}
`,
squareOuter: (color: string) => `
position: absolute;
top: 4px;
left: 4px;
bottom: 4px;
right: 4px;
background-color: ${darken(0.1, color)};
border-radius: 3px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
`,
squareInner: (color: string) => `
position: absolute;
top: 0;
right: 0;
bottom: 2px;
left: 0;
border-radius: 3px;
background-color: ${color};
`,
label: (color: string) => `
font-size: 0.7em;
margin-bottom: 0.7em;
${SquareTextContentStyles(color)}
`,
value: (color: string) => `
font-weight: bold;
font-size: 0.9em;
line-height: 1.2em;
${SquareTextContentStyles(color)}
`,
};
export const NodeSquare = ({
squareSize,
togglePopover,
showToolTip,
hideToolTip,
color,
nodeName,
value,
showBorder,
}: {
squareSize: number;
togglePopover: DispatchWithOptionalAction<boolean>;
showToolTip: () => void;
hideToolTip: () => void;
color: string;
nodeName: string;
value: string;
showBorder?: boolean;
}) => {
const valueMode = squareSize > 70;
const ellipsisMode = squareSize > 30;
const nodeAriaLabel = i18n.translate('xpack.infra.node.ariaLabel', {
defaultMessage: '{nodeName}, click to open menu',
values: { nodeName },
});
const style: CSSProperties | undefined = showBorder ? { border: 'solid 4px #000' } : undefined;
return valueMode || ellipsisMode ? (
<div
css={css`
position: relative;
cursor: pointer;
`}
data-test-subj="nodeContainer"
style={{ width: squareSize || 0, height: squareSize || 0 }}
onClick={togglePopover}
onKeyPress={togglePopover}
onFocus={showToolTip}
onMouseOver={showToolTip}
onMouseLeave={hideToolTip}
className="buttonContainer"
>
<div
css={css`
${styles.squareOuter(color)}
`}
style={style}
>
<div
css={css`
${styles.squareInner(color)}
`}
>
{valueMode ? (
<button
css={css`
${styles.valueInner}
`}
aria-label={nodeAriaLabel}
>
<div
css={css`
${styles.label(color)}
`}
data-test-subj="nodeName"
>
{nodeName}
</div>
<div
css={css`
${styles.value(color)}
`}
data-test-subj="nodeValue"
>
{value}
</div>
</button>
) : (
ellipsisMode && (
<button
css={css`
${styles.valueInner}
`}
aria-label={nodeAriaLabel}
>
<div
css={css`
${styles.label(color)}
`}
>
...
</div>
</button>
)
)}
</div>
</div>
</div>
) : (
<div
css={styles.nodeContainerSmall(color)}
data-test-subj="nodeContainer"
style={{ width: squareSize || 0, height: squareSize || 0, ...style }}
onClick={togglePopover}
onKeyPress={togglePopover}
onMouseOver={showToolTip}
onFocus={showToolTip}
onMouseLeave={hideToolTip}
color={color}
/>
);
};

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useUrlState } from '../../../../utils/use_url_state';
export const GET_DEFAULT_PROPERTIES: AssetDetailsFlyoutProperties = {
detailsItemId: null,
};
const ASSET_DETAILS_FLYOUT_URL_STATE_KEY = 'assetDetailsFlyout';
export const useAssetDetailsFlyoutState = (): [
AssetDetailsFlyoutProperties,
AssetDetailsFlyoutPropertiesUpdater
] => {
const [urlState, setUrlState] = useUrlState<AssetDetailsFlyoutProperties>({
defaultState: {
...GET_DEFAULT_PROPERTIES,
},
decodeUrlState,
encodeUrlState,
urlStateKey: ASSET_DETAILS_FLYOUT_URL_STATE_KEY,
});
return [urlState, setUrlState];
};
const AssetDetailsFlyoutStateRT = rt.type({
detailsItemId: rt.union([rt.string, rt.null]),
});
export type AssetDetailsFlyoutState = rt.TypeOf<typeof AssetDetailsFlyoutStateRT>;
export type AssetDetailsFlyoutPropertiesUpdater = (params: AssetDetailsFlyoutState) => void;
type AssetDetailsFlyoutProperties = rt.TypeOf<typeof AssetDetailsFlyoutStateRT>;
const encodeUrlState = AssetDetailsFlyoutStateRT.encode;
const decodeUrlState = (value: unknown) => {
return pipe(AssetDetailsFlyoutStateRT.decode(value), fold(constant(undefined), identity));
};

View file

@ -7,54 +7,14 @@
import React from 'react';
import { useRouteMatch } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { NoRemoteCluster } from '../../../components/empty_states';
import { SourceErrorPage } from '../../../components/source_error_page';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useSourceContext } from '../../../containers/metrics_source';
import { ContentTabIds, type Tab } from '../../../components/asset_details/types';
import type { InventoryItemType } from '../../../../common/inventory_models/types';
import { AssetDetails } from '../../../components/asset_details/asset_details';
import { MetricsPageTemplate } from '../page_template';
const orderedFlyoutTabs: Tab[] = [
{
id: ContentTabIds.OVERVIEW,
name: i18n.translate('xpack.infra.nodeDetails.tabs.overview.title', {
defaultMessage: 'Overview',
}),
},
{
id: ContentTabIds.METADATA,
name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', {
defaultMessage: 'Metadata',
}),
},
{
id: ContentTabIds.PROCESSES,
name: i18n.translate('xpack.infra.metrics.nodeDetails.tabs.processes', {
defaultMessage: 'Processes',
}),
},
{
id: ContentTabIds.LOGS,
name: i18n.translate('xpack.infra.nodeDetails.tabs.logs.title', {
defaultMessage: 'Logs',
}),
},
{
id: ContentTabIds.ANOMALIES,
name: i18n.translate('xpack.infra.nodeDetails.tabs.anomalies', {
defaultMessage: 'Anomalies',
}),
},
{
id: ContentTabIds.OSQUERY,
name: i18n.translate('xpack.infra.nodeDetails.tabs.osquery', {
defaultMessage: 'Osquery',
}),
},
];
import { commonFlyoutTabs } from '../../../common/asset_details_config/asset_details_tabs';
export const AssetDetailPage = () => {
const { isLoading, loadSourceFailureMessage, loadSource, source } = useSourceContext();
@ -91,7 +51,7 @@ export const AssetDetailPage = () => {
id: nodeId,
}}
assetType={nodeType}
tabs={orderedFlyoutTabs}
tabs={commonFlyoutTabs}
links={['apmServices']}
renderMode={{
mode: 'page',

View file

@ -18741,9 +18741,7 @@
"xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "Réseau sortant (TX)",
"xpack.infra.hostsViewPage.tooltip.whatAreTheseMetricsLink": "Que sont ces indicateurs ?",
"xpack.infra.infra.nodeDetails.apmTabLabel": "APM",
"xpack.infra.infra.nodeDetails.createAlertLink": "Créer une règle d'inventaire",
"xpack.infra.infra.nodeDetails.openAsPage": "Ouvrir en tant que page",
"xpack.infra.infra.nodeDetails.updtimeTabLabel": "Uptime",
"xpack.infra.inventory.alerting.groupActionVariableDescription": "Nom des données de reporting du groupe",
"xpack.infra.inventoryModel.container.displayName": "Conteneurs Docker",
"xpack.infra.inventoryModel.container.singularDisplayName": "Conteneur Docker",
@ -19233,7 +19231,6 @@
"xpack.infra.metrics.nodeDetails.processes.stateZombie": "Zombie",
"xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM": "Afficher la trace dans APM",
"xpack.infra.metrics.nodeDetails.processesHeader": "Processus principaux",
"xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody": "Le tableau ci-dessous agrège les principaux processus de consommation de CPU et de mémoire. Il n'affiche pas tous les processus.",
"xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel": "Plus d'infos",
"xpack.infra.metrics.nodeDetails.processListError": "Impossible de charger les données de processus",
"xpack.infra.metrics.nodeDetails.processListRetry": "Réessayer",
@ -19381,41 +19378,16 @@
"xpack.infra.nodeDetails.labels.showMoreDetails": "Afficher plus de détails",
"xpack.infra.nodeDetails.logs.openLogsLink": "Ouvrir dans Logs",
"xpack.infra.nodeDetails.logs.textFieldPlaceholder": "Rechercher les entrées de logs...",
"xpack.infra.nodeDetails.metrics.cached": "En cache",
"xpack.infra.nodeDetails.metrics.charts.loadTitle": "Charge",
"xpack.infra.nodeDetails.metrics.charts.logRateTitle": "Taux de log",
"xpack.infra.nodeDetails.metrics.charts.memoryTitle": "Mémoire",
"xpack.infra.nodeDetails.metrics.charts.networkTitle": "Réseau",
"xpack.infra.nodeDetails.metrics.fcharts.cpuTitle": "CPU",
"xpack.infra.nodeDetails.metrics.free": "Gratuit",
"xpack.infra.nodeDetails.metrics.inbound": "Entrant",
"xpack.infra.nodeDetails.metrics.last15Minutes": "15 dernières minutes",
"xpack.infra.nodeDetails.metrics.last24Hours": "Dernières 24 heures",
"xpack.infra.nodeDetails.metrics.last3Hours": "3 dernières heures",
"xpack.infra.nodeDetails.metrics.last7Days": "7 derniers jours",
"xpack.infra.nodeDetails.metrics.lastHour": "Dernière heure",
"xpack.infra.nodeDetails.metrics.logRate": "Taux de log",
"xpack.infra.nodeDetails.metrics.outbound": "Sortant",
"xpack.infra.nodeDetails.metrics.system": "Système",
"xpack.infra.nodeDetails.metrics.used": "Utilisé",
"xpack.infra.nodeDetails.metrics.user": "Utilisateur",
"xpack.infra.nodeDetails.no": "Non",
"xpack.infra.nodeDetails.tabs.anomalies": "Anomalies",
"xpack.infra.nodeDetails.tabs.logs": "Logs",
"xpack.infra.nodeDetails.tabs.logs.title": "Logs",
"xpack.infra.nodeDetails.tabs.metadata.agentHeader": "Agent",
"xpack.infra.nodeDetails.tabs.metadata.cloudHeader": "Cloud",
"xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel": "Filtre",
"xpack.infra.nodeDetails.tabs.metadata.hostsHeader": "Hôtes",
"xpack.infra.nodeDetails.tabs.metadata.seeLess": "Afficher moins",
"xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip": "Afficher l'événement avec filtre",
"xpack.infra.nodeDetails.tabs.metadata.title": "Métadonnées",
"xpack.infra.nodeDetails.tabs.metrics": "Indicateurs",
"xpack.infra.nodeDetails.tabs.osquery": "Osquery",
"xpack.infra.nodeDetails.yes": "Oui",
"xpack.infra.nodesToWaffleMap.groupsWithGroups.allName": "Tous",
"xpack.infra.nodesToWaffleMap.groupsWithNodes.allName": "Tous",
"xpack.infra.notAvailableLabel": "S. O.",
"xpack.infra.openView.actionNames.deleteConfirmation": "Supprimer la vue ?",
"xpack.infra.openView.cancelButton": "Annuler",
"xpack.infra.openView.columnNames.actions": "Actions",

View file

@ -18755,9 +18755,7 @@
"xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "ネットワーク送信TX",
"xpack.infra.hostsViewPage.tooltip.whatAreTheseMetricsLink": "これらのメトリックは何か。",
"xpack.infra.infra.nodeDetails.apmTabLabel": "APM",
"xpack.infra.infra.nodeDetails.createAlertLink": "インベントリルールの作成",
"xpack.infra.infra.nodeDetails.openAsPage": "ページとして開く",
"xpack.infra.infra.nodeDetails.updtimeTabLabel": "アップタイム",
"xpack.infra.inventory.alerting.groupActionVariableDescription": "データを報告するグループの名前",
"xpack.infra.inventoryModel.container.displayName": "Dockerコンテナー",
"xpack.infra.inventoryModel.container.singularDisplayName": "Docker コンテナー",
@ -19247,7 +19245,6 @@
"xpack.infra.metrics.nodeDetails.processes.stateZombie": "ゾンビ",
"xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM": "APM でトレースを表示",
"xpack.infra.metrics.nodeDetails.processesHeader": "上位のプロセス",
"xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody": "次の表は、上位の CPU および上位のメモリ消費プロセスの集計です。一部のプロセスは表示されません。",
"xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel": "詳細",
"xpack.infra.metrics.nodeDetails.processListError": "プロセスデータを読み込めません",
"xpack.infra.metrics.nodeDetails.processListRetry": "再試行",
@ -19395,41 +19392,16 @@
"xpack.infra.nodeDetails.labels.showMoreDetails": "他の詳細を表示",
"xpack.infra.nodeDetails.logs.openLogsLink": "ログで開く",
"xpack.infra.nodeDetails.logs.textFieldPlaceholder": "ログエントリーを検索...",
"xpack.infra.nodeDetails.metrics.cached": "キャッシュ",
"xpack.infra.nodeDetails.metrics.charts.loadTitle": "読み込み",
"xpack.infra.nodeDetails.metrics.charts.logRateTitle": "ログレート",
"xpack.infra.nodeDetails.metrics.charts.memoryTitle": "メモリー",
"xpack.infra.nodeDetails.metrics.charts.networkTitle": "ネットワーク",
"xpack.infra.nodeDetails.metrics.fcharts.cpuTitle": "CPU",
"xpack.infra.nodeDetails.metrics.free": "空き",
"xpack.infra.nodeDetails.metrics.inbound": "受信",
"xpack.infra.nodeDetails.metrics.last15Minutes": "過去15分間",
"xpack.infra.nodeDetails.metrics.last24Hours": "過去 24 時間",
"xpack.infra.nodeDetails.metrics.last3Hours": "過去 3 時間",
"xpack.infra.nodeDetails.metrics.last7Days": "過去 7 日間",
"xpack.infra.nodeDetails.metrics.lastHour": "過去 1 時間",
"xpack.infra.nodeDetails.metrics.logRate": "ログレート",
"xpack.infra.nodeDetails.metrics.outbound": "送信",
"xpack.infra.nodeDetails.metrics.system": "システム",
"xpack.infra.nodeDetails.metrics.used": "使用中",
"xpack.infra.nodeDetails.metrics.user": "ユーザー",
"xpack.infra.nodeDetails.no": "いいえ",
"xpack.infra.nodeDetails.tabs.anomalies": "異常",
"xpack.infra.nodeDetails.tabs.logs": "ログ",
"xpack.infra.nodeDetails.tabs.logs.title": "ログ",
"xpack.infra.nodeDetails.tabs.metadata.agentHeader": "エージェント",
"xpack.infra.nodeDetails.tabs.metadata.cloudHeader": "クラウド",
"xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel": "フィルター",
"xpack.infra.nodeDetails.tabs.metadata.hostsHeader": "ホスト",
"xpack.infra.nodeDetails.tabs.metadata.seeLess": "簡易表示",
"xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip": "フィルターでイベントを表示",
"xpack.infra.nodeDetails.tabs.metadata.title": "メタデータ",
"xpack.infra.nodeDetails.tabs.metrics": "メトリック",
"xpack.infra.nodeDetails.tabs.osquery": "Osquery",
"xpack.infra.nodeDetails.yes": "はい",
"xpack.infra.nodesToWaffleMap.groupsWithGroups.allName": "すべて",
"xpack.infra.nodesToWaffleMap.groupsWithNodes.allName": "すべて",
"xpack.infra.notAvailableLabel": "N/A",
"xpack.infra.openView.actionNames.deleteConfirmation": "ビューを削除しますか?",
"xpack.infra.openView.cancelButton": "キャンセル",
"xpack.infra.openView.columnNames.actions": "アクション",

View file

@ -18755,9 +18755,7 @@
"xpack.infra.hostsViewPage.tabs.metricsCharts.tx": "网络出站数据 (TX)",
"xpack.infra.hostsViewPage.tooltip.whatAreTheseMetricsLink": "这些指标是什么?",
"xpack.infra.infra.nodeDetails.apmTabLabel": "APM",
"xpack.infra.infra.nodeDetails.createAlertLink": "创建库存规则",
"xpack.infra.infra.nodeDetails.openAsPage": "以页面形式打开",
"xpack.infra.infra.nodeDetails.updtimeTabLabel": "运行时间",
"xpack.infra.inventory.alerting.groupActionVariableDescription": "报告数据的组名称",
"xpack.infra.inventoryModel.container.displayName": "Docker 容器",
"xpack.infra.inventoryModel.container.singularDisplayName": "Docker 容器",
@ -19247,7 +19245,6 @@
"xpack.infra.metrics.nodeDetails.processes.stateZombie": "僵停",
"xpack.infra.metrics.nodeDetails.processes.viewTraceInAPM": "在 APM 中查看跟踪",
"xpack.infra.metrics.nodeDetails.processesHeader": "排序靠前的进程",
"xpack.infra.metrics.nodeDetails.processesHeader.tooltipBody": "下表聚合了 CPU 和内存消耗靠前的进程。不显示所有进程。",
"xpack.infra.metrics.nodeDetails.processesHeader.tooltipLabel": "更多信息",
"xpack.infra.metrics.nodeDetails.processListError": "无法加载进程数据",
"xpack.infra.metrics.nodeDetails.processListRetry": "重试",
@ -19395,41 +19392,16 @@
"xpack.infra.nodeDetails.labels.showMoreDetails": "显示更多详情",
"xpack.infra.nodeDetails.logs.openLogsLink": "在日志中打开",
"xpack.infra.nodeDetails.logs.textFieldPlaceholder": "搜索日志条目......",
"xpack.infra.nodeDetails.metrics.cached": "已缓存",
"xpack.infra.nodeDetails.metrics.charts.loadTitle": "加载",
"xpack.infra.nodeDetails.metrics.charts.logRateTitle": "日志速率",
"xpack.infra.nodeDetails.metrics.charts.memoryTitle": "内存",
"xpack.infra.nodeDetails.metrics.charts.networkTitle": "网络",
"xpack.infra.nodeDetails.metrics.fcharts.cpuTitle": "CPU",
"xpack.infra.nodeDetails.metrics.free": "可用",
"xpack.infra.nodeDetails.metrics.inbound": "入站",
"xpack.infra.nodeDetails.metrics.last15Minutes": "过去 15 分钟",
"xpack.infra.nodeDetails.metrics.last24Hours": "过去 24 小时",
"xpack.infra.nodeDetails.metrics.last3Hours": "过去 3 小时",
"xpack.infra.nodeDetails.metrics.last7Days": "过去 7 天",
"xpack.infra.nodeDetails.metrics.lastHour": "过去一小时",
"xpack.infra.nodeDetails.metrics.logRate": "日志速率",
"xpack.infra.nodeDetails.metrics.outbound": "出站",
"xpack.infra.nodeDetails.metrics.system": "系统",
"xpack.infra.nodeDetails.metrics.used": "已使用",
"xpack.infra.nodeDetails.metrics.user": "用户",
"xpack.infra.nodeDetails.no": "否",
"xpack.infra.nodeDetails.tabs.anomalies": "异常",
"xpack.infra.nodeDetails.tabs.logs": "日志",
"xpack.infra.nodeDetails.tabs.logs.title": "日志",
"xpack.infra.nodeDetails.tabs.metadata.agentHeader": "代理",
"xpack.infra.nodeDetails.tabs.metadata.cloudHeader": "云",
"xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel": "筛选",
"xpack.infra.nodeDetails.tabs.metadata.hostsHeader": "主机",
"xpack.infra.nodeDetails.tabs.metadata.seeLess": "显示更少",
"xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip": "使用筛选查看事件",
"xpack.infra.nodeDetails.tabs.metadata.title": "元数据",
"xpack.infra.nodeDetails.tabs.metrics": "指标",
"xpack.infra.nodeDetails.tabs.osquery": "Osquery",
"xpack.infra.nodeDetails.yes": "是",
"xpack.infra.nodesToWaffleMap.groupsWithGroups.allName": "全部",
"xpack.infra.nodesToWaffleMap.groupsWithNodes.allName": "全部",
"xpack.infra.notAvailableLabel": "不可用",
"xpack.infra.openView.actionNames.deleteConfirmation": "删除视图?",
"xpack.infra.openView.cancelButton": "取消",
"xpack.infra.openView.columnNames.actions": "操作",

View file

@ -6,6 +6,7 @@
*/
import expect from '@kbn/expect';
import { parse } from 'url';
import { KUBERNETES_TOUR_STORAGE_KEY } from '@kbn/infra-plugin/public/pages/metrics/inventory_view/components/kubernetes_tour';
import { FtrProviderContext } from '../../ftr_provider_context';
import { DATES, INVENTORY_PATH } from './constants';
@ -18,7 +19,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const browser = getService('browser');
const retry = getService('retry');
const pageObjects = getPageObjects(['common', 'header', 'infraHome', 'infraSavedViews']);
const pageObjects = getPageObjects([
'common',
'header',
'infraHome',
'timePicker',
'assetDetails',
'infraSavedViews',
]);
const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects');
@ -119,6 +127,92 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await pageObjects.infraHome.getWaffleMap();
// await pageObjects.infraHome.getWaffleMapTooltips(); see https://github.com/elastic/kibana/issues/137903
});
describe('Asset Details flyout', () => {
before(async () => {
await pageObjects.infraHome.goToTime(DATE_WITH_DATA);
await pageObjects.infraHome.getWaffleMap();
await pageObjects.infraHome.inputAddHostNameFilter('demo-stack-nginx-01');
await pageObjects.infraHome.clickOnNode();
});
describe('Overview Tab', () => {
before(async () => {
await pageObjects.assetDetails.clickOverviewTab();
});
[
{ metric: 'cpuUsage', value: '0.8%' },
{ metric: 'normalizedLoad1m', value: '1.4%' },
{ metric: 'memoryUsage', value: '18.0%' },
{ metric: 'diskSpaceUsage', value: '17.5%' },
].forEach(({ metric, value }) => {
it(`${metric} tile should show ${value}`, async () => {
await retry.tryForTime(3 * 1000, async () => {
const tileValue = await pageObjects.assetDetails.getAssetDetailsKPITileValue(
metric
);
expect(tileValue).to.eql(value);
});
});
});
it('should render 9 charts in the Metrics section', async () => {
const hosts = await pageObjects.assetDetails.getAssetDetailsMetricsCharts();
expect(hosts.length).to.equal(9);
});
it('should show alerts', async () => {
await pageObjects.header.waitUntilLoadingHasFinished();
await pageObjects.assetDetails.overviewAlertsTitleExists();
});
});
describe('Metadata Tab', () => {
before(async () => {
await pageObjects.assetDetails.clickMetadataTab();
});
it('should show metadata table', async () => {
await pageObjects.assetDetails.metadataTableExists();
});
});
describe('Logs Tab', () => {
before(async () => {
await pageObjects.assetDetails.clickLogsTab();
});
after(async () => {
await retry.try(async () => {
await pageObjects.infraHome.closeFlyout();
});
});
it('should render logs tab', async () => {
await pageObjects.assetDetails.logsExists();
});
});
describe('APM Link Tab', () => {
before(async () => {
await pageObjects.infraHome.clickOnNode();
await pageObjects.assetDetails.clickApmTabLink();
});
it('should navigate to APM traces', async () => {
const url = parse(await browser.getCurrentUrl());
const query = decodeURIComponent(url.query ?? '');
const kuery = 'kuery=host.hostname:"demo-stack-nginx-01"';
expect(url.pathname).to.eql('/app/apm/traces');
expect(query).to.contain(kuery);
await returnTo(INVENTORY_PATH);
});
});
});
it('shows query suggestions', async () => {
await pageObjects.infraHome.goToTime(DATE_WITH_DATA);
await pageObjects.infraHome.clickQueryBar();

View file

@ -180,5 +180,10 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) {
async clickOsqueryTab() {
return testSubjects.click('infraAssetDetailsOsqueryTab');
},
// APM Tab link
async clickApmTabLink() {
return testSubjects.click('infraAssetDetailsApmServicesLinkTab');
},
};
}

View file

@ -105,7 +105,7 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
async clickOnNodeDetailsFlyoutOpenAsPage() {
await retry.try(async () => {
await testSubjects.click('infraNodeContextPopoverOpenAsPageButton');
await testSubjects.click('infraAssetDetailsOpenAsPageButton');
});
},
@ -434,6 +434,14 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
await queryBar.type('h');
},
async inputAddHostNameFilter(hostName: string) {
await this.enterSearchTerm(`host.name:"${hostName}"`);
},
async clickOnNode() {
return testSubjects.click('nodeContainer');
},
async ensureSuggestionsPanelVisible() {
await testSubjects.find('infraSuggestionsPanel');
},