[Metrics UI] Add metadata tab to node details flyout (#84454)

* Add properties tab to flyout

* Better id for i18n title

* Update i18n ids

* Fix test and styling

* Style changes, add support for collapsing array fields

* Add loading indicators

* Fix type check

* Fix another test

* Fix tests for pods

* Add link to node details page

* Only show the overlay when viewing hosts

* Take into account cores when showing cpu

* Make it easier to read

* Remove unnecessary cast

* Fix PR feedback

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Phillip Burch 2020-12-03 12:14:02 -06:00 committed by GitHub
parent 74ef540e42
commit 61fdec2486
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 538 additions and 39 deletions

View file

@ -29,10 +29,15 @@ export const InfraMetadataOSRT = rt.partial({
name: rt.string,
platform: rt.string,
version: rt.string,
build: rt.string,
});
export const InfraMetadataHostRT = rt.partial({
name: rt.string,
hostname: rt.string,
id: rt.string,
ip: rt.array(rt.string),
mac: rt.array(rt.string),
os: InfraMetadataOSRT,
architecture: rt.string,
containerized: rt.boolean,
@ -43,25 +48,40 @@ export const InfraMetadataInstanceRT = rt.partial({
name: rt.string,
});
export const InfraMetadataAccountRT = rt.partial({
id: rt.string,
name: rt.string,
});
export const InfraMetadataProjectRT = rt.partial({
id: rt.string,
});
export const InfraMetadataMachineRT = rt.partial({
interface: rt.string,
type: rt.string,
});
export const InfraMetadataCloudRT = rt.partial({
instance: InfraMetadataInstanceRT,
provider: rt.string,
account: InfraMetadataAccountRT,
availability_zone: rt.string,
project: InfraMetadataProjectRT,
machine: InfraMetadataMachineRT,
region: rt.string,
});
export const InfraMetadataAgentRT = rt.partial({
id: rt.string,
version: rt.string,
policy: rt.string,
});
export const InfraMetadataInfoRT = rt.partial({
cloud: InfraMetadataCloudRT,
host: InfraMetadataHostRT,
agent: InfraMetadataAgentRT,
});
const InfraMetadataRequiredRT = rt.type({

View file

@ -14,8 +14,11 @@ import { InventoryItemType } from '../../../../../../common/inventory_models/typ
import { MetricsTab } from './tabs/metrics/metrics';
import { LogsTab } from './tabs/logs';
import { ProcessesTab } from './tabs/processes';
import { PropertiesTab } from './tabs/properties';
import { PropertiesTab } from './tabs/properties/index';
import { OVERLAY_Y_START, OVERLAY_BOTTOM_MARGIN, OVERLAY_HEADER_SIZE } from './tabs/shared';
import { useLinkProps } from '../../../../../hooks/use_link_props';
import { getNodeDetailUrl } from '../../../../link_to';
import { findInventoryModel } from '../../../../../../common/inventory_models';
interface Props {
isOpen: boolean;
@ -35,6 +38,8 @@ export const NodeContextPopover = ({
}: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const tabConfigs = [MetricsTab, LogsTab, ProcessesTab, PropertiesTab];
const inventoryModel = findInventoryModel(nodeType);
const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000;
const tabs = useMemo(() => {
return tabConfigs.map((m) => {
@ -50,6 +55,15 @@ export const NodeContextPopover = ({
const [selectedTab, setSelectedTab] = useState(0);
const nodeDetailMenuItemLinkProps = useLinkProps({
...getNodeDetailUrl({
nodeType,
nodeId: node.id,
from: nodeDetailFrom,
to: currentTime,
}),
});
if (!isOpen) {
return null;
}
@ -65,9 +79,28 @@ export const NodeContextPopover = ({
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} iconType={'cross'}>
<FormattedMessage id="xpack.infra.infra.nodeDetails.close" defaultMessage="Close" />
</EuiButtonEmpty>
<EuiFlexGroup gutterSize={'xs'} alignItems={'flexEnd'}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconSide={'left'}
iconType={'popout'}
href={nodeDetailMenuItemLinkProps.href}
>
<FormattedMessage
id="xpack.infra.infra.nodeDetails.openAsPage"
defaultMessage="Open as page"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} iconType={'cross'}>
<FormattedMessage
id="xpack.infra.infra.nodeDetails.close"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</OverlayHeaderTitleWrapper>
<EuiTabs>

View file

@ -17,6 +17,7 @@ import {
PointerEvent,
} from '@elastic/charts';
import moment from 'moment';
import { EuiLoadingChart } from '@elastic/eui';
import { TabContent, TabProps } from '../shared';
import { useSnapshot } from '../../../../hooks/use_snaphot';
import { useWaffleOptionsContext } from '../../../../hooks/use_waffle_options';
@ -82,9 +83,9 @@ const TabComponent = (props: TabProps) => {
}
const buildCustomMetric = useCallback(
(field: string, id: string) => ({
(field: string, id: string, aggregation: string = 'avg') => ({
type: 'custom' as SnapshotMetricType,
aggregation: 'avg',
aggregation,
field,
id,
}),
@ -110,6 +111,7 @@ const TabComponent = (props: TabProps) => {
buildCustomMetric('system.load.15', 'load15m'),
buildCustomMetric('system.memory.actual.used.bytes', 'usedMemory'),
buildCustomMetric('system.memory.actual.free', 'freeMemory'),
buildCustomMetric('system.cpu.cores', 'cores', 'max'),
],
[],
nodeType,
@ -223,6 +225,7 @@ const TabComponent = (props: TabProps) => {
const load15mMetricsTs = useMemo(() => getTimeseries('load15m'), [getTimeseries]);
const usedMemoryMetricsTs = useMemo(() => getTimeseries('usedMemory'), [getTimeseries]);
const freeMemoryMetricsTs = useMemo(() => getTimeseries('freeMemory'), [getTimeseries]);
const coresMetricsTs = useMemo(() => getTimeseries('cores'), [getTimeseries]);
useEffect(() => {
reload();
@ -239,7 +242,7 @@ const TabComponent = (props: TabProps) => {
!usedMemoryMetricsTs ||
!freeMemoryMetricsTs
) {
return <div />;
return <LoadingPlaceholder />;
}
const cpuChartMetrics = buildChartMetricLabels([SYSTEM_METRIC_NAME, USER_METRIC_NAME], 'avg');
@ -253,6 +256,23 @@ const TabComponent = (props: TabProps) => {
'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 networkTimeseries = mergeTimeseries(rxMetricsTs, txMetricsTs);
const loadTimeseries = mergeTimeseries(load1mMetricsTs, load5mMetricsTs, load15mMetricsTs);
@ -467,6 +487,23 @@ const ChartContainer: React.FC = ({ children }) => (
</div>
);
const LoadingPlaceholder = () => {
return (
<div
style={{
width: '100%',
height: '100%',
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', {

View file

@ -1,21 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { TabContent, TabProps } from './shared';
const TabComponent = (props: TabProps) => {
return <TabContent>Properties Placeholder</TabContent>;
};
export const PropertiesTab = {
id: 'properties',
name: i18n.translate('xpack.infra.nodeDetails.tabs.properties', {
defaultMessage: 'Properties',
}),
content: TabComponent,
};

View file

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

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useContext, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLoadingChart } from '@elastic/eui';
import { TabContent, TabProps } from '../shared';
import { Source } from '../../../../../../../containers/source';
import { findInventoryModel } from '../../../../../../../../common/inventory_models';
import { InventoryItemType } from '../../../../../../../../common/inventory_models/types';
import { useMetadata } from '../../../../../metric_detail/hooks/use_metadata';
import { getFields } from './build_fields';
import { useWaffleTimeContext } from '../../../../hooks/use_waffle_time';
import { Table } from './table';
import { euiStyled } from '../../../../../../../../../observability/public';
import { useWaffleFiltersContext } from '../../../../hooks/use_waffle_filters';
const TabComponent = (props: TabProps) => {
const nodeId = props.node.id;
const nodeType = props.nodeType as InventoryItemType;
const inventoryModel = findInventoryModel(nodeType);
const { sourceId } = useContext(Source.Context);
const { currentTimeRange } = useWaffleTimeContext();
const { applyFilterQuery } = useWaffleFiltersContext();
const { loading: metadataLoading, metadata } = useMetadata(
nodeId,
nodeType,
inventoryModel.requiredMetrics,
sourceId,
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`
margin-bottom: 20px
`;
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

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiText } from '@elastic/eui';
import { EuiToolTip } from '@elastic/eui';
import { EuiButtonIcon } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiLink } from '@elastic/eui';
import { EuiBasicTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { first } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { euiStyled } from '../../../../../../../../../observability/public';
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>
<EuiToolTip
content={i18n.translate('xpack.infra.nodeDetails.tabs.metadata.setFilterTooltip', {
defaultMessage: 'View event with filter',
})}
>
<EuiFlexGroup gutterSize={'xs'}>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="text"
iconType="filter"
aria-label={i18n.translate(
'xpack.infra.nodeDetails.tabs.metadata.filterAriaLabel',
{
defaultMessage: 'Filter',
}
)}
onClick={() => onClick(item)}
/>
</EuiFlexItem>
<EuiFlexItem>
{!Array.isArray(item.value) && item.value}
{Array.isArray(item.value) && <ArrayValue values={item.value} />}
</EuiFlexItem>
</EuiFlexGroup>
</EuiToolTip>
</span>
);
},
},
],
[onClick]
);
return (
<>
<TitleWrapper>
<EuiText>
<h3>{title}</h3>
</EuiText>
</TitleWrapper>
<TableWithoutHeader tableLayout="fixed" compressed columns={columns} items={rows} />
</>
);
};
const TitleWrapper = euiStyled.div`
margin-bottom: 10px
`;
class TableWithoutHeader extends EuiBasicTable {
renderTableHead() {
return <></>;
}
}
interface MoreProps {
values: string[];
}
const ArrayValue = (props: MoreProps) => {
const { values } = props;
const [isExpanded, setIsExpanded] = useState(false);
const expand = useCallback(() => {
setIsExpanded(true);
}, []);
const collapse = useCallback(() => {
setIsExpanded(false);
}, []);
return (
<>
{!isExpanded && (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
{first(values)}
{' ... '}
</EuiFlexItem>
<EuiFlexItem>
<EuiLink onClick={expand}>
<FormattedMessage
id="xpack.infra.nodeDetails.tabs.metadata.seeMore"
defaultMessage="+{count} more"
values={{
count: values.length,
}}
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
)}
{isExpanded && (
<div>
{values.map((v) => (
<div key={v}>{v}</div>
))}
<EuiLink onClick={collapse}>
{i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', {
defaultMessage: 'See less',
})}
</EuiLink>
</div>
)}
</>
);
};

View file

@ -54,6 +54,7 @@ export const Node = class extends React.PureComponent<Props, State> {
defaultMessage: '{nodeName}, click to open menu',
values: { nodeName: node.name },
});
return (
<>
<NodeContextMenu
@ -102,7 +103,12 @@ export const Node = class extends React.PureComponent<Props, State> {
}
private togglePopover = () => {
this.setState((prevState) => ({ isPopoverOpen: !prevState.isPopoverOpen }));
const { nodeType } = this.props;
if (nodeType === 'host') {
this.toggleNewOverlay();
} else {
this.setState((prevState) => ({ isPopoverOpen: !prevState.isPopoverOpen }));
}
};
private toggleNewOverlay = () => {

View file

@ -161,14 +161,6 @@ export const NodeContextMenu: React.FC<Props & { theme?: EuiTheme }> = withTheme
},
};
const openNewOverlayMenuItem: SectionLinkProps = {
label: i18n.translate('xpack.infra.nodeContextMenu.openNewOverlay', {
defaultMessage: '**** [NEW] Overlay ***',
}),
style: { color: theme?.eui.euiLinkColor || '#006BB4', fontWeight: 500, padding: 0 },
onClick: openNewOverlay,
};
return (
<>
<ActionMenu
@ -204,7 +196,6 @@ export const NodeContextMenu: React.FC<Props & { theme?: EuiTheme }> = withTheme
<SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} />
<SectionLink {...uptimeMenuItem} />
<SectionLink {...createAlertMenuItem} />
<SectionLink {...openNewOverlayMenuItem} />
</SectionLinks>
</Section>
</div>

View file

@ -58,7 +58,7 @@ export const getNodeInfo = async (
index: sourceConfiguration.metricAlias,
body: {
size: 1,
_source: ['host.*', 'cloud.*'],
_source: ['host.*', 'cloud.*', 'agent.*'],
sort: [{ [timestampField]: 'desc' }],
query: {
bool: {

View file

@ -109,6 +109,13 @@ export default function ({ getService }: FtrProviderContext) {
machine: { type: 'n1-standard-4' },
project: { id: 'elastic-observability' },
},
agent: {
hostname: 'gke-observability-8--observability-8--bc1afd95-f0zc',
id: 'c91c0d2b-6483-46bb-9731-f06afd32bb59',
ephemeral_id: '7cb259b1-795c-4c76-beaf-2eb8f18f5b02',
type: 'metricbeat',
version: '8.0.0',
},
host: {
hostname: 'gke-observability-8--observability-8--bc1afd95-f0zc',
os: {
@ -150,6 +157,13 @@ export default function ({ getService }: FtrProviderContext) {
region: 'us-east-2',
account: { id: '015351775590' },
},
agent: {
hostname: 'ip-172-31-47-9.us-east-2.compute.internal',
id: 'd0943b36-d0d3-426d-892b-7d79c071b44b',
ephemeral_id: '64c94244-88b8-4a37-adc0-30428fefaf53',
type: 'metricbeat',
version: '8.0.0',
},
host: {
hostname: 'ip-172-31-47-9.us-east-2.compute.internal',
os: {
@ -197,6 +211,13 @@ export default function ({ getService }: FtrProviderContext) {
id: 'elastic-observability',
},
},
agent: {
hostname: 'gke-observability-8--observability-8--bc1afd95-ngmh',
id: '66dc19e6-da36-49d2-9471-2c9475503178',
ephemeral_id: 'a0c3a9ff-470a-41a0-bf43-d1af6b7a3b5b',
type: 'metricbeat',
version: '8.0.0',
},
host: {
hostname: 'gke-observability-8--observability-8--bc1afd95-ngmh',
name: 'gke-observability-8--observability-8--bc1afd95-ngmh',
@ -244,6 +265,13 @@ export default function ({ getService }: FtrProviderContext) {
id: 'elastic-observability',
},
},
agent: {
hostname: 'gke-observability-8--observability-8--bc1afd95-nhhw',
id: 'c58a514c-e971-4590-8206-385400e184dd',
ephemeral_id: 'e9d46cb0-2e89-469d-bd3b-6f32d7c96cc0',
type: 'metricbeat',
version: '8.0.0',
},
host: {
hostname: 'gke-observability-8--observability-8--bc1afd95-nhhw',
name: 'gke-observability-8--observability-8--bc1afd95-nhhw',