Skip to content

Commit

Permalink
[APM] Service map popover (elastic#53524)
Browse files Browse the repository at this point in the history
Add a popover when clicking on service map nodes and an endpoint to fetch metrics to show in the popover.

Closes elastic#52869.
  • Loading branch information
smith authored and jkelastic committed Jan 17, 2020
1 parent d611512 commit dfaadc0
Show file tree
Hide file tree
Showing 15 changed files with 815 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
serviceName?: string;
style: CSSProperties;
style?: CSSProperties;
}

function useCytoscape(options: cytoscape.CytoscapeOptions) {
Expand Down Expand Up @@ -69,8 +69,8 @@ export function Cytoscape({

// Set up cytoscape event handlers
useEffect(() => {
if (cy) {
cy.on('data', event => {
const dataHandler: cytoscape.EventHandler = event => {
if (cy) {
// Add the "primary" class to the node if its id matches the serviceName.
if (cy.nodes().length > 0 && serviceName) {
cy.nodes().removeClass('primary');
Expand All @@ -80,8 +80,30 @@ export function Cytoscape({
if (event.cy.elements().length > 0) {
cy.layout(cytoscapeOptions.layout as cytoscape.LayoutOptions).run();
}
});
}
};
const mouseoverHandler: cytoscape.EventHandler = event => {
event.target.addClass('hover');
event.target.connectedEdges().addClass('nodeHover');
};
const mouseoutHandler: cytoscape.EventHandler = event => {
event.target.removeClass('hover');
event.target.connectedEdges().removeClass('nodeHover');
};

if (cy) {
cy.on('data', dataHandler);
cy.on('mouseover', 'edge, node', mouseoverHandler);
cy.on('mouseout', 'edge, node', mouseoutHandler);
}

return () => {
if (cy) {
cy.removeListener('data', undefined, dataHandler);
cy.removeListener('mouseover', 'edge, node', mouseoverHandler);
cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
}
};
}, [cy, serviceName]);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.
*/

/* eslint-disable @elastic/eui/href-or-on-click */

import { EuiButton, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { MouseEvent } from 'react';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { getAPMHref } from '../../../shared/Links/apm/APMLink';

interface ButtonsProps {
focusedServiceName?: string;
onFocusClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
selectedNodeServiceName: string;
}

export function Buttons({
focusedServiceName,
onFocusClick = () => {},
selectedNodeServiceName
}: ButtonsProps) {
const currentSearch = useUrlParams().urlParams.kuery ?? '';
const detailsUrl = getAPMHref(
`/services/${selectedNodeServiceName}/transactions`,
currentSearch
);
const focusUrl = getAPMHref(
`/services/${selectedNodeServiceName}/service-map`,
currentSearch
);

const isAlreadyFocused = focusedServiceName === selectedNodeServiceName;

return (
<>
<EuiFlexItem>
<EuiButton href={detailsUrl} fill={true}>
{i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', {
defaultMessage: 'Service Details'
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
isDisabled={isAlreadyFocused}
color="secondary"
href={focusUrl}
onClick={onFocusClick}
title={
isAlreadyFocused
? i18n.translate('xpack.apm.serviceMap.alreadyFocusedTitleText', {
defaultMessage: 'Map is already focused'
})
: undefined
}
>
{i18n.translate('xpack.apm.serviceMap.focusMapButtonText', {
defaultMessage: 'Focus map'
})}
</EuiButton>
</EuiFlexItem>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 styled from 'styled-components';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';

const ItemRow = styled.div`
line-height: 2;
`;

const ItemTitle = styled.dt`
color: ${lightTheme.textColors.subdued};
`;

const ItemDescription = styled.dd``;

interface InfoProps {
type: string;
subtype?: string;
}

export function Info({ type, subtype }: InfoProps) {
const listItems = [
{
title: i18n.translate('xpack.apm.serviceMap.typePopoverMetric', {
defaultMessage: 'Type'
}),
description: type
},
{
title: i18n.translate('xpack.apm.serviceMap.subtypePopoverMetric', {
defaultMessage: 'Subtype'
}),
description: subtype
}
];

return (
<>
{listItems.map(
({ title, description }) =>
description && (
<ItemRow key={title}>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemRow>
)
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* 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 {
EuiFlexGroup,
EuiLoadingSpinner,
EuiFlexItem,
EuiBadge
} from '@elastic/eui';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { isNumber } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { ServiceNodeMetrics } from '../../../../../server/lib/service_map/get_service_map_service_node_info';
import {
asDuration,
asPercent,
toMicroseconds,
tpmUnit
} from '../../../../utils/formatters';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { useFetcher } from '../../../../hooks/useFetcher';

function LoadingSpinner() {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceAround"
style={{ height: 170 }}
>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
);
}

const ItemRow = styled('tr')`
line-height: 2;
`;

const ItemTitle = styled('td')`
color: ${lightTheme.textColors.subdued};
padding-right: 1rem;
`;

const ItemDescription = styled('td')`
text-align: right;
`;

const na = i18n.translate('xpack.apm.serviceMap.NotAvailableMetric', {
defaultMessage: 'N/A'
});

interface MetricListProps {
serviceName: string;
}

export function ServiceMetricList({ serviceName }: MetricListProps) {
const {
urlParams: { start, end, environment }
} = useUrlParams();

const { data = {} as ServiceNodeMetrics, status } = useFetcher(
callApmApi => {
if (serviceName && start && end) {
return callApmApi({
pathname: '/api/apm/service-map/service/{serviceName}',
params: {
path: {
serviceName
},
query: {
start,
end,
environment
}
}
});
}
},
[serviceName, start, end, environment],
{
preservePreviousData: false
}
);

const {
avgTransactionDuration,
avgRequestsPerMinute,
avgErrorsPerMinute,
avgCpuUsage,
avgMemoryUsage,
numInstances
} = data;
const isLoading = status === 'loading';

const listItems = [
{
title: i18n.translate(
'xpack.apm.serviceMap.avgTransDurationPopoverMetric',
{
defaultMessage: 'Trans. duration (avg.)'
}
),
description: isNumber(avgTransactionDuration)
? asDuration(toMicroseconds(avgTransactionDuration, 'milliseconds'))
: na
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
{
defaultMessage: 'Req. per minute (avg.)'
}
),
description: isNumber(avgRequestsPerMinute)
? `${avgRequestsPerMinute.toFixed(2)} ${tpmUnit('request')}`
: na
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric',
{
defaultMessage: 'Errors per minute (avg.)'
}
),
description: avgErrorsPerMinute?.toFixed(2) ?? na
},
{
title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverMetric', {
defaultMessage: 'CPU usage (avg.)'
}),
description: isNumber(avgCpuUsage) ? asPercent(avgCpuUsage, 1) : na
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgMemoryUsagePopoverMetric',
{
defaultMessage: 'Memory usage (avg.)'
}
),
description: isNumber(avgMemoryUsage) ? asPercent(avgMemoryUsage, 1) : na
}
];
return isLoading ? (
<LoadingSpinner />
) : (
<>
{numInstances && numInstances > 1 && (
<EuiFlexItem>
<div>
<EuiBadge iconType="apps" color="hollow">
{i18n.translate('xpack.apm.serviceMap.numInstancesMetric', {
values: { numInstances },
defaultMessage: '{numInstances} instances'
})}
</EuiBadge>
</div>
</EuiFlexItem>
)}

<table>
<tbody>
{listItems.map(({ title, description }) => (
<ItemRow key={title}>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemRow>
))}
</tbody>
</table>
</>
);
}
Loading

0 comments on commit dfaadc0

Please sign in to comment.