diff --git a/x-pack/plugins/infra/common/http_api/metadata_api.ts b/x-pack/plugins/infra/common/http_api/metadata_api.ts index 5ee96b479be8..41b599c31041 100644 --- a/x-pack/plugins/infra/common/http_api/metadata_api.ts +++ b/x-pack/plugins/infra/common/http_api/metadata_api.ts @@ -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({ diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx index 0943ced5e5be..be953ded70d7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/overlay.tsx @@ -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 = ({ - - - + + + + + + + + + + + + diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx index b5628b0a7c9b..789658c06040 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/metrics.tsx @@ -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
; + return ; } 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 }) => (
); +const LoadingPlaceholder = () => { + return ( +
+ +
+ ); +}; + export const MetricsTab = { id: 'metrics', name: i18n.translate('xpack.infra.nodeDetails.tabs.metrics', { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties.tsx deleted file mode 100644 index 8157aca9b141..000000000000 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { TabContent, TabProps } from './shared'; - -const TabComponent = (props: TabProps) => { - return Properties Placeholder; -}; - -export const PropertiesTab = { - id: 'properties', - name: i18n.translate('xpack.infra.nodeDetails.tabs.properties', { - defaultMessage: 'Properties', - }), - content: TabComponent, -}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/build_fields.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/build_fields.ts new file mode 100644 index 000000000000..79610ba3eef0 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/build_fields.ts @@ -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); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx new file mode 100644 index 000000000000..b901c3748438 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/index.tsx @@ -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 ; + } + + return ( + + {hostFields && hostFields.length > 0 && ( + + + + )} + {cloudFields && cloudFields.length > 0 && ( + +
+ + )} + {agentFields && agentFields.length > 0 && ( + +
+ + )} + + ); +}; + +const TableWrapper = euiStyled.div` + margin-bottom: 20px +`; + +const LoadingPlaceholder = () => { + return ( +
+ +
+ ); +}; + +export const PropertiesTab = { + id: 'properties', + name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', { + defaultMessage: 'Metadata', + }), + content: TabComponent, +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx new file mode 100644 index 000000000000..c3e47b6084eb --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/properties/table.tsx @@ -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) => ( + + {item.name} + + ), + }, + { + field: 'value', + name: '', + width: '65%', + sortable: false, + render: (_name: string, item: Row) => { + return ( + + + + + onClick(item)} + /> + + + {!Array.isArray(item.value) && item.value} + {Array.isArray(item.value) && } + + + + + ); + }, + }, + ], + [onClick] + ); + + return ( + <> + + +

{title}

+
+
+ + + ); +}; + +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 && ( + + + {first(values)} + {' ... '} + + + + + + + + )} + {isExpanded && ( +
+ {values.map((v) => ( +
{v}
+ ))} + + {i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', { + defaultMessage: 'See less', + })} + +
+ )} + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx index f2d9da960df8..03fb53898e31 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node.tsx @@ -54,6 +54,7 @@ export const Node = class extends React.PureComponent { defaultMessage: '{nodeName}, click to open menu', values: { nodeName: node.name }, }); + return ( <> { } 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 = () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 91c6ad801000..3179d4aa0526 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -161,14 +161,6 @@ export const NodeContextMenu: React.FC = 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 ( <> = withTheme - diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts b/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts index f1341c7ec810..b378b42e2ff5 100644 --- a/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts +++ b/x-pack/plugins/infra/server/routes/metadata/lib/get_node_info.ts @@ -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: { diff --git a/x-pack/test/api_integration/apis/metrics_ui/metadata.ts b/x-pack/test/api_integration/apis/metrics_ui/metadata.ts index 349b0dcbd9cf..e319e59045d2 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metadata.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metadata.ts @@ -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',