Skip to content

Commit

Permalink
[Infrastructure UI] Show metadata for a single host on the host UI (#…
Browse files Browse the repository at this point in the history
…152956)

Closes [#150893](#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](#151010),
[#150907](#150907) and
[#150985](#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 <[email protected]>
Co-authored-by: Marco Antonio Ghiani <[email protected]>
  • Loading branch information
3 people authored Mar 23, 2023
1 parent be71713 commit 6591aa9
Show file tree
Hide file tree
Showing 7 changed files with 462 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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);
Loading

0 comments on commit 6591aa9

Please sign in to comment.