[Infrastructure UI] Show metadata for a single host on the host UI (#152956)

Closes [#150893](https://github.com/elastic/kibana/issues/150893)
## Summary
This PR adds a flyout with single host metadata with an option to
open/close it with a click on an expand/minimize icon in the table.

⚠️ This PR doesn't include metadata filtering/actions, or processes tab
inside flyout (they will be added in follow-up issues). For now, the
metadata will be displayed and no actions will be available.
This PR will unblock
[#151010](https://github.com/elastic/kibana/issues/151010),
[#150907](https://github.com/elastic/kibana/issues/150907) and
[#150985](https://github.com/elastic/kibana/issues/150985)

## Testing
- Open the hosts view and click on the expand icon for a single host in
the table
  
<img width="1464" alt="image"
src="https://user-images.githubusercontent.com/14139027/224077010-71aece78-40d1-4a3a-90a6-8e699001b37a.png">

- The flyout should be visible with a preselected metadata tab
containing
  - Host name as the flyout title
  - Metadata in a table view with field and value columns
  
<img width="1807" alt="image"
src="https://user-images.githubusercontent.com/14139027/224048634-cd49aa0f-f1a5-4442-9fd0-f16cd4cb84da.png">
- The flyout can be closed using the close icon and the minimize icon or
can show a different host when another host is expanded
<img width="1727" alt="image"
src="https://user-images.githubusercontent.com/14139027/224084969-daa525c5-4ec4-4504-b072-4711db63fe18.png">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani01@gmail.com>
This commit is contained in:
jennypavlova 2023-03-23 17:07:43 +01:00 committed by GitHub
parent be71713035
commit 6591aa975e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 462 additions and 19 deletions

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 React, { useMemo, useState } from 'react';
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';
import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui';
import { MetadataTab } from './metadata/metadata';
import type { HostNodeRow } from '../../hooks/use_hosts_table';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search';
interface Props {
node: HostNodeRow;
closeFlyout: () => void;
}
const flyoutTabs = [MetadataTab];
export const Flyout = ({ node, closeFlyout }: Props) => {
const { getDateRangeAsTimestamp } = useUnifiedSearchContext();
const tabs = useMemo(() => {
const currentTimeRange = {
...getDateRangeAsTimestamp(),
interval: '1m',
};
return flyoutTabs.map((m) => {
const TabContent = m.content;
return {
...m,
content: <TabContent node={node} currentTimeRange={currentTimeRange} />,
};
});
}, [getDateRangeAsTimestamp, node]);
const [selectedTab, setSelectedTab] = useState(0);
return (
<EuiFlyout onClose={closeFlyout} ownFocus={false}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="xs">
<h2>{node.name}</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiTabs style={{ marginBottom: '-25px' }} size="s">
{tabs.map((tab, i) => (
<EuiTab key={tab.id} isSelected={i === selectedTab} onClick={() => setSelectedTab(i)}>
{tab.name}
</EuiTab>
))}
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>{tabs[selectedTab].content}</EuiFlyoutBody>
</EuiFlyout>
);
};

View file

@ -0,0 +1,110 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiLoadingChart } from '@elastic/eui';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useSourceContext } from '../../../../../../containers/metrics_source';
import { findInventoryModel } from '../../../../../../../common/inventory_models';
import type { InventoryItemType } from '../../../../../../../common/inventory_models/types';
import { useMetadata } from '../../../../metric_detail/hooks/use_metadata';
import { Table } from './table';
import { getAllFields } from './utils';
import type { HostNodeRow } from '../../../hooks/use_hosts_table';
import type { MetricsTimeInput } from '../../../../metric_detail/hooks/use_metrics_time';
const NODE_TYPE = 'host' as InventoryItemType;
export interface TabProps {
currentTimeRange: MetricsTimeInput;
node: HostNodeRow;
}
const Metadata = ({ node, currentTimeRange }: TabProps) => {
const nodeId = node.name;
const inventoryModel = findInventoryModel(NODE_TYPE);
const { sourceId } = useSourceContext();
const {
loading: metadataLoading,
error,
metadata,
} = useMetadata(nodeId, NODE_TYPE, inventoryModel.requiredMetrics, sourceId, currentTimeRange);
const fields = useMemo(() => getAllFields(metadata), [metadata]);
if (metadataLoading) {
return <LoadingPlaceholder />;
}
if (error) {
return (
<EuiCallOut
title={i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.errorTitle', {
defaultMessage: 'Sorry, there was an error',
})}
color="danger"
iconType="error"
>
<FormattedMessage
id="xpack.infra.hostsViewPage.hostDetail.metadata.errorMessage"
defaultMessage="There was an error loading your data. Try to {reload} and open the host details again."
values={{
reload: (
<EuiLink
data-test-subj="infraMetadataThisLinkCanHelpLink"
onClick={() => window.location.reload()}
>
{i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.errorAction', {
defaultMessage: 'reload the page',
})}
</EuiLink>
),
}}
/>
</EuiCallOut>
);
}
return fields.length > 0 ? (
<Table rows={fields} />
) : (
<EuiCallOut
title={i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.noMetadataFound', {
defaultMessage: 'Sorry, there is no metadata related to this host.',
})}
size="m"
iconType="iInCircle"
/>
);
};
const LoadingPlaceholder = () => {
return (
<div
style={{
width: '100%',
height: '200px',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EuiLoadingChart size="xl" />
</div>
);
};
export const MetadataTab = {
id: 'metadata',
name: i18n.translate('xpack.infra.nodeDetails.tabs.metadata.title', {
defaultMessage: 'Metadata',
}),
content: Metadata,
};

View file

@ -0,0 +1,114 @@
/*
* 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, EuiFlexGroup, EuiFlexItem, EuiLink, EuiBasicTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
interface Row {
name: string;
value: string | string[] | undefined;
}
interface Props {
rows: Row[];
}
/**
* Columns translations
*/
const FIELD_LABEL = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.field', {
defaultMessage: 'Field',
});
const VALUE_LABEL = i18n.translate('xpack.infra.hostsViewPage.hostDetail.metadata.value', {
defaultMessage: 'Value',
});
export const Table = (props: Props) => {
const { rows } = props;
const columns = useMemo(
() => [
{
field: 'name',
name: FIELD_LABEL,
width: '35%',
sortable: false,
render: (name: string) => <EuiText size="s">{name}</EuiText>,
},
{
field: 'value',
name: VALUE_LABEL,
width: '65%',
sortable: false,
render: (_name: string, item: Row) => <ExpandableContent values={item.value} />,
},
],
[]
);
return <EuiBasicTable tableLayout={'fixed'} responsive={false} columns={columns} items={rows} />;
};
interface ExpandableContentProps {
values: string | string[] | undefined;
}
const ExpandableContent = (props: ExpandableContentProps) => {
const { values } = props;
const [isExpanded, setIsExpanded] = useState(false);
const expand = useCallback(() => {
setIsExpanded(true);
}, []);
const collapse = useCallback(() => {
setIsExpanded(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="infraExpandableContentCountMoreLink" onClick={expand}>
<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="infraExpandableContentShowLessLink" onClick={collapse}>
{i18n.translate('xpack.infra.nodeDetails.tabs.metadata.seeLess', {
defaultMessage: 'Show less',
})}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,109 @@
/*
* 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 type { InfraMetadata } from '../../../../../../../common/http_api';
export const getAllFields = (metadata: InfraMetadata | null) => {
if (!metadata?.info) return [];
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,
},
{
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,
},
{
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

@ -15,13 +15,18 @@ import { useHostsTable } from '../hooks/use_hosts_table';
import { useTableProperties } from '../hooks/use_table_properties_url_state';
import { useHostsViewContext } from '../hooks/use_hosts_view';
import { useUnifiedSearchContext } from '../hooks/use_unified_search';
import { Flyout } from './host_details_flyout/flyout';
export const HostsTable = () => {
const { hostNodes, loading } = useHostsViewContext();
const { onSubmit, searchCriteria } = useUnifiedSearchContext();
const [properties, setProperties] = useTableProperties();
const { columns, items } = useHostsTable(hostNodes, { time: searchCriteria.dateRange });
const { columns, items, isFlyoutOpen, closeFlyout, clickedItemUuid } = useHostsTable(hostNodes, {
time: searchCriteria.dateRange,
});
const clickedItem = items.find(({ uuid }) => uuid === clickedItemUuid);
const noData = items.length === 0;
@ -74,18 +79,23 @@ export const HostsTable = () => {
}
return (
<EuiInMemoryTable
data-test-subj="hostsView-table"
pagination={properties.pagination}
sorting={
typeof properties.sorting === 'boolean' ? properties.sorting : { sort: properties.sorting }
}
rowProps={{
'data-test-subj': 'hostsView-tableRow',
}}
items={items}
columns={columns}
onTableChange={onTableChange}
/>
<>
<EuiInMemoryTable
data-test-subj="hostsView-table"
pagination={properties.pagination}
sorting={
typeof properties.sorting === 'boolean'
? properties.sorting
: { sort: properties.sorting }
}
rowProps={{
'data-test-subj': 'hostsView-tableRow',
}}
items={items}
columns={columns}
onTableChange={onTableChange}
/>
{isFlyoutOpen && clickedItem && <Flyout node={clickedItem} closeFlyout={closeFlyout} />}
</>
);
};

View file

@ -9,6 +9,10 @@ import { useHostsTable } from './use_hosts_table';
import { renderHook } from '@testing-library/react-hooks';
import { SnapshotNode } from '../../../../../common/http_api';
jest.mock('uuid', () => ({
v4: () => 'uuidv4',
}));
describe('useHostTable hook', () => {
it('it should map the nodes returned from the snapshot api to a format matching eui table items', () => {
const nodes: SnapshotNode[] = [
@ -73,6 +77,7 @@ describe('useHostTable hook', () => {
{
name: 'host-0',
os: '-',
uuid: 'uuidv4',
title: {
cloudProvider: 'aws',
name: 'host-0',
@ -102,6 +107,7 @@ describe('useHostTable hook', () => {
{
name: 'host-1',
os: 'macOS',
uuid: 'uuidv4',
title: {
cloudProvider: null,
name: 'host-1',

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { EuiBasicTableColumn, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { TimeRange } from '@kbn/es-query';
import { v4 as uuidv4 } from 'uuid';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { createInventoryMetricFormatter } from '../../inventory_view/lib/create_inventory_metric_formatter';
@ -33,10 +34,9 @@ export interface HostNodeRow extends HostMetrics {
servicesOnHost?: number | null;
title: { name: string; cloudProvider?: CloudProvider | null };
name: string;
uuid: string;
}
// type MappedMetrics = Record<keyof HostNodeRow, SnapshotNodeMetric>;
interface HostTableParams {
time: TimeRange;
}
@ -50,6 +50,7 @@ const formatMetric = (type: SnapshotMetricInput['type'], value: number | undefin
const buildItemsList = (nodes: SnapshotNode[]) => {
return nodes.map(({ metrics, path, name }) => ({
uuid: uuidv4(),
name,
os: path.at(-1)?.os ?? '-',
title: {
@ -107,6 +108,13 @@ const averageMemoryUsageLabel = i18n.translate(
}
);
const toggleDialogActionLabel = i18n.translate(
'xpack.infra.hostsViewPage.table.toggleDialogWithDetails',
{
defaultMessage: 'Toggle dialog with details',
}
);
/**
* Build a table columns and items starting from the snapshot nodes.
*/
@ -115,6 +123,11 @@ export const useHostsTable = (nodes: SnapshotNode[], { time }: HostTableParams)
services: { telemetry },
} = useKibanaContextForPlugin();
const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
const [clickedItemUuid, setClickedItemUuid] = useState(() => uuidv4());
const closeFlyout = () => setIsFlyoutOpen(false);
const reportHostEntryClick = useCallback(
({ name, cloudProvider }: HostNodeRow['title']) => {
telemetry.reportHostEntryClicked({
@ -129,6 +142,27 @@ export const useHostsTable = (nodes: SnapshotNode[], { time }: HostTableParams)
const columns: Array<EuiBasicTableColumn<HostNodeRow>> = useMemo(
() => [
{
name: '',
width: '40px',
field: 'uuid',
actions: [
{
name: toggleDialogActionLabel,
description: toggleDialogActionLabel,
icon: ({ uuid }) => (isFlyoutOpen && uuid === clickedItemUuid ? 'minimize' : 'expand'),
type: 'icon',
onClick: ({ uuid }) => {
setClickedItemUuid(uuid);
if (isFlyoutOpen && uuid === clickedItemUuid) {
setIsFlyoutOpen(false);
} else {
setIsFlyoutOpen(true);
}
},
},
],
},
{
name: titleLabel,
field: 'title',
@ -191,8 +225,8 @@ export const useHostsTable = (nodes: SnapshotNode[], { time }: HostTableParams)
align: 'right',
},
],
[reportHostEntryClick, time]
[clickedItemUuid, isFlyoutOpen, reportHostEntryClick, time]
);
return { columns, items };
return { columns, items, isFlyoutOpen, closeFlyout, clickedItemUuid };
};