[APM] Service map popover (#53524)
Add a popover when clicking on service map nodes and an endpoint to fetch metrics to show in the popover. Closes #52869.
This commit is contained in:
parent
4869e02b62
commit
52709b8deb
|
@ -26,7 +26,7 @@ interface CytoscapeProps {
|
|||
children?: ReactNode;
|
||||
elements: cytoscape.ElementDefinition[];
|
||||
serviceName?: string;
|
||||
style: CSSProperties;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
function useCytoscape(options: cytoscape.CytoscapeOptions) {
|
||||
|
@ -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');
|
||||
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* 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,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiPopover,
|
||||
EuiTitle
|
||||
} from '@elastic/eui';
|
||||
import cytoscape from 'cytoscape';
|
||||
import React, {
|
||||
CSSProperties,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback
|
||||
} from 'react';
|
||||
import { CytoscapeContext } from '../Cytoscape';
|
||||
import { Buttons } from './Buttons';
|
||||
import { Info } from './Info';
|
||||
import { ServiceMetricList } from './ServiceMetricList';
|
||||
|
||||
const popoverMinWidth = 280;
|
||||
|
||||
interface PopoverProps {
|
||||
focusedServiceName?: string;
|
||||
}
|
||||
|
||||
export function Popover({ focusedServiceName }: PopoverProps) {
|
||||
const cy = useContext(CytoscapeContext);
|
||||
const [selectedNode, setSelectedNode] = useState<
|
||||
cytoscape.NodeSingular | undefined
|
||||
>(undefined);
|
||||
const onFocusClick = useCallback(() => setSelectedNode(undefined), [
|
||||
setSelectedNode
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectHandler: cytoscape.EventHandler = event => {
|
||||
setSelectedNode(event.target);
|
||||
};
|
||||
const unselectHandler: cytoscape.EventHandler = () => {
|
||||
setSelectedNode(undefined);
|
||||
};
|
||||
|
||||
if (cy) {
|
||||
cy.on('select', 'node', selectHandler);
|
||||
cy.on('unselect', 'node', unselectHandler);
|
||||
cy.on('data viewport', unselectHandler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cy) {
|
||||
cy.removeListener('select', 'node', selectHandler);
|
||||
cy.removeListener('unselect', 'node', unselectHandler);
|
||||
cy.removeListener('data viewport', undefined, unselectHandler);
|
||||
}
|
||||
};
|
||||
}, [cy]);
|
||||
|
||||
const renderedHeight = selectedNode?.renderedHeight() ?? 0;
|
||||
const renderedWidth = selectedNode?.renderedWidth() ?? 0;
|
||||
const { x, y } = selectedNode?.renderedPosition() ?? { x: 0, y: 0 };
|
||||
const isOpen = !!selectedNode;
|
||||
const selectedNodeServiceName: string = selectedNode?.data('id');
|
||||
const isService = selectedNode?.data('type') === 'service';
|
||||
const triggerStyle: CSSProperties = {
|
||||
background: 'transparent',
|
||||
height: renderedHeight,
|
||||
position: 'absolute',
|
||||
width: renderedWidth
|
||||
};
|
||||
const trigger = <div className="trigger" style={triggerStyle} />;
|
||||
|
||||
const zoom = cy?.zoom() ?? 1;
|
||||
const height = selectedNode?.height() ?? 0;
|
||||
const translateY = y - (zoom + 1) * (height / 2);
|
||||
const popoverStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
transform: `translate(${x}px, ${translateY}px)`
|
||||
};
|
||||
const data = selectedNode?.data() ?? {};
|
||||
const label = data.label || selectedNodeServiceName;
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
anchorPosition={'upCenter'}
|
||||
button={trigger}
|
||||
closePopover={() => {}}
|
||||
isOpen={isOpen}
|
||||
style={popoverStyle}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
direction="column"
|
||||
gutterSize="s"
|
||||
style={{ minWidth: popoverMinWidth }}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h3>{label}</h3>
|
||||
</EuiTitle>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
{isService ? (
|
||||
<ServiceMetricList serviceName={selectedNodeServiceName} />
|
||||
) : (
|
||||
<Info {...data} />
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
{isService && (
|
||||
<Buttons
|
||||
focusedServiceName={focusedServiceName}
|
||||
onFocusClick={onFocusClick}
|
||||
selectedNodeServiceName={selectedNodeServiceName}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
|
@ -3,9 +3,9 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import cytoscape from 'cytoscape';
|
||||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { icons, defaultIcon } from './icons';
|
||||
import cytoscape from 'cytoscape';
|
||||
import { defaultIcon, iconForNode } from './icons';
|
||||
|
||||
const layout = {
|
||||
name: 'dagre',
|
||||
|
@ -13,8 +13,8 @@ const layout = {
|
|||
rankDir: 'LR'
|
||||
};
|
||||
|
||||
function isDatabaseOrExternal(agentName: string) {
|
||||
return !agentName;
|
||||
function isService(el: cytoscape.NodeSingular) {
|
||||
return el.data('type') === 'service';
|
||||
}
|
||||
|
||||
const style: cytoscape.Stylesheet[] = [
|
||||
|
@ -27,11 +27,11 @@ const style: cytoscape.Stylesheet[] = [
|
|||
//
|
||||
// @ts-ignore
|
||||
'background-image': (el: cytoscape.NodeSingular) =>
|
||||
icons[el.data('agentName')] || defaultIcon,
|
||||
iconForNode(el) ?? defaultIcon,
|
||||
'background-height': (el: cytoscape.NodeSingular) =>
|
||||
isDatabaseOrExternal(el.data('agentName')) ? '40%' : '80%',
|
||||
isService(el) ? '80%' : '40%',
|
||||
'background-width': (el: cytoscape.NodeSingular) =>
|
||||
isDatabaseOrExternal(el.data('agentName')) ? '40%' : '80%',
|
||||
isService(el) ? '80%' : '40%',
|
||||
'border-color': (el: cytoscape.NodeSingular) =>
|
||||
el.hasClass('primary')
|
||||
? theme.euiColorSecondary
|
||||
|
@ -47,7 +47,7 @@ const style: cytoscape.Stylesheet[] = [
|
|||
'min-zoomed-font-size': theme.euiSizeL,
|
||||
'overlay-opacity': 0,
|
||||
shape: (el: cytoscape.NodeSingular) =>
|
||||
isDatabaseOrExternal(el.data('agentName')) ? 'diamond' : 'ellipse',
|
||||
isService(el) ? 'ellipse' : 'diamond',
|
||||
'text-background-color': theme.euiColorLightestShade,
|
||||
'text-background-opacity': 0,
|
||||
'text-background-padding': theme.paddingSizes.xs,
|
||||
|
@ -90,7 +90,6 @@ const style: cytoscape.Stylesheet[] = [
|
|||
|
||||
export const cytoscapeOptions: cytoscape.CytoscapeOptions = {
|
||||
autoungrabify: true,
|
||||
autounselectify: true,
|
||||
boxSelectionEnabled: false,
|
||||
layout,
|
||||
maxZoom: 3,
|
||||
|
|
|
@ -101,7 +101,17 @@ export function getCytoscapeElements(
|
|||
`/services/${node['service.name']}/service-map`,
|
||||
search
|
||||
),
|
||||
agentName: node['agent.name'] || node['agent.name']
|
||||
agentName: node['agent.name'] || node['agent.name'],
|
||||
type: 'service'
|
||||
};
|
||||
}
|
||||
|
||||
if ('span.type' in node) {
|
||||
data = {
|
||||
// For nodes with span.type "db", convert it to "database". Otherwise leave it as-is.
|
||||
type: node['span.type'] === 'db' ? 'database' : node['span.type'],
|
||||
// Externals should not have a subtype so make it undefined if the type is external.
|
||||
subtype: node['span.type'] !== 'external' && node['span.subtype']
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
*/
|
||||
|
||||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import cytoscape from 'cytoscape';
|
||||
import databaseIcon from './icons/database.svg';
|
||||
import documentsIcon from './icons/documents.svg';
|
||||
import globeIcon from './icons/globe.svg';
|
||||
|
||||
function getAvatarIcon(
|
||||
|
@ -24,10 +26,16 @@ function getAvatarIcon(
|
|||
}
|
||||
|
||||
// The colors here are taken from the logos of the corresponding technologies
|
||||
export const icons: { [key: string]: string } = {
|
||||
const icons: { [key: string]: string } = {
|
||||
cache: databaseIcon,
|
||||
database: databaseIcon,
|
||||
dotnet: getAvatarIcon('.N', '#8562AD'),
|
||||
external: globeIcon,
|
||||
messaging: documentsIcon,
|
||||
resource: globeIcon
|
||||
};
|
||||
|
||||
const serviceIcons: { [key: string]: string } = {
|
||||
dotnet: getAvatarIcon('.N', '#8562AD'),
|
||||
go: getAvatarIcon('Go', '#00A9D6'),
|
||||
java: getAvatarIcon('Jv', '#41717E'),
|
||||
'js-base': getAvatarIcon('JS', '#F0DB4E', theme.euiTextColor),
|
||||
|
@ -37,3 +45,12 @@ export const icons: { [key: string]: string } = {
|
|||
};
|
||||
|
||||
export const defaultIcon = getAvatarIcon();
|
||||
|
||||
export function iconForNode(node: cytoscape.NodeSingular) {
|
||||
const type = node.data('type');
|
||||
if (type === 'service') {
|
||||
return serviceIcons[node.data('agentName') as string];
|
||||
} else {
|
||||
return icons[type];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" class="euiIcon euiIcon--medium euiIcon-isLoaded" focusable="false" role="img" aria-hidden="true"><title></title><path d="M8.8 0c.274 0 .537.113.726.312l3.2 3.428c.176.186.274.433.274.689V13a1 1 0 01-1 1H2a1 1 0 01-1-1V1a1 1 0 011-1h6.8zM12 5H8.5a.5.5 0 01-.5-.5V1H2v12h10V5zm-7.5 6a.5.5 0 110-1h5a.5.5 0 110 1h-5zm0-3a.5.5 0 010-1h5a.5.5 0 110 1h-5zm1 8a.5.5 0 110-1H14V6.5a.5.5 0 111 0V15a1 1 0 01-1 1H5.5z"></path></svg>
|
After Width: | Height: | Size: 506 B |
|
@ -4,31 +4,32 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import React, {
|
||||
useMemo,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback
|
||||
} from 'react';
|
||||
import { find, isEqual } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import theme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ElementDefinition } from 'cytoscape';
|
||||
import { find, isEqual } from 'lodash';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public';
|
||||
import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map';
|
||||
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
|
||||
import { useCallApmApi } from '../../../hooks/useCallApmApi';
|
||||
import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity';
|
||||
import { useLicense } from '../../../hooks/useLicense';
|
||||
import { useLocation } from '../../../hooks/useLocation';
|
||||
import { useUrlParams } from '../../../hooks/useUrlParams';
|
||||
import { Controls } from './Controls';
|
||||
import { Cytoscape } from './Cytoscape';
|
||||
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
|
||||
import { useCallApmApi } from '../../../hooks/useCallApmApi';
|
||||
import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity';
|
||||
import { useLocation } from '../../../hooks/useLocation';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
|
||||
import { getCytoscapeElements } from './get_cytoscape_elements';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
|
||||
import { Popover } from './Popover';
|
||||
|
||||
interface ServiceMapProps {
|
||||
serviceName?: string;
|
||||
|
@ -205,6 +206,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
|
|||
style={cytoscapeDivStyle}
|
||||
>
|
||||
<Controls />
|
||||
<Popover focusedServiceName={serviceName} />
|
||||
</Cytoscape>
|
||||
</LoadingOverlay>
|
||||
) : (
|
||||
|
|
|
@ -43,7 +43,7 @@ const chartBase: ChartBase = {
|
|||
series
|
||||
};
|
||||
|
||||
const percentUsedScript = {
|
||||
export const percentMemoryUsedScript = {
|
||||
lang: 'expression',
|
||||
source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']`
|
||||
};
|
||||
|
@ -59,8 +59,8 @@ export async function getMemoryChartData(
|
|||
serviceNodeName,
|
||||
chartBase,
|
||||
aggs: {
|
||||
memoryUsedAvg: { avg: { script: percentUsedScript } },
|
||||
memoryUsedMax: { max: { script: percentUsedScript } }
|
||||
memoryUsedAvg: { avg: { script: percentMemoryUsedScript } },
|
||||
memoryUsedMax: { max: { script: percentMemoryUsedScript } }
|
||||
},
|
||||
additionalFilters: [
|
||||
{
|
||||
|
|
|
@ -163,7 +163,8 @@ export async function getServiceMapFromTraceIds({
|
|||
}
|
||||
|
||||
/* if there is an outgoing span, create a new path */
|
||||
if (event['span.type'] == 'external' || event['span.type'] == 'messaging') {
|
||||
if (event['destination.address'] != null
|
||||
&& event['destination.address'] != '') {
|
||||
def outgoingLocation = getDestination(event);
|
||||
def outgoingPath = new ArrayList(basePath);
|
||||
outgoingPath.add(outgoingLocation);
|
||||
|
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* 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 { Setup, SetupTimeRange } from '../helpers/setup_request';
|
||||
import { ESFilter } from '../../../typings/elasticsearch';
|
||||
import { rangeFilter } from '../helpers/range_filter';
|
||||
import {
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_ENVIRONMENT,
|
||||
SERVICE_NAME,
|
||||
TRANSACTION_DURATION,
|
||||
METRIC_SYSTEM_CPU_PERCENT,
|
||||
METRIC_SYSTEM_FREE_MEMORY,
|
||||
METRIC_SYSTEM_TOTAL_MEMORY,
|
||||
SERVICE_NODE_NAME
|
||||
} from '../../../common/elasticsearch_fieldnames';
|
||||
import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
|
||||
interface Options {
|
||||
setup: Setup & SetupTimeRange;
|
||||
environment?: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
interface TaskParameters {
|
||||
setup: Setup;
|
||||
minutes: number;
|
||||
filter: ESFilter[];
|
||||
}
|
||||
|
||||
export type ServiceNodeMetrics = PromiseReturnType<
|
||||
typeof getServiceMapServiceNodeInfo
|
||||
>;
|
||||
|
||||
export async function getServiceMapServiceNodeInfo({
|
||||
serviceName,
|
||||
environment,
|
||||
setup
|
||||
}: Options & { serviceName: string; environment?: string }) {
|
||||
const { start, end } = setup;
|
||||
|
||||
const filter: ESFilter[] = [
|
||||
{ range: rangeFilter(start, end) },
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
...(environment
|
||||
? [{ term: { [SERVICE_ENVIRONMENT]: SERVICE_ENVIRONMENT } }]
|
||||
: [])
|
||||
];
|
||||
|
||||
const minutes = Math.abs((end - start) / (1000 * 60));
|
||||
|
||||
const taskParams = {
|
||||
setup,
|
||||
minutes,
|
||||
filter
|
||||
};
|
||||
|
||||
const [
|
||||
errorMetrics,
|
||||
transactionMetrics,
|
||||
cpuMetrics,
|
||||
memoryMetrics,
|
||||
instanceMetrics
|
||||
] = await Promise.all([
|
||||
getErrorMetrics(taskParams),
|
||||
getTransactionMetrics(taskParams),
|
||||
getCpuMetrics(taskParams),
|
||||
getMemoryMetrics(taskParams),
|
||||
getNumInstances(taskParams)
|
||||
]);
|
||||
|
||||
return {
|
||||
...errorMetrics,
|
||||
...transactionMetrics,
|
||||
...cpuMetrics,
|
||||
...memoryMetrics,
|
||||
...instanceMetrics
|
||||
};
|
||||
}
|
||||
|
||||
async function getErrorMetrics({ setup, minutes, filter }: TaskParameters) {
|
||||
const { client, indices } = setup;
|
||||
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.errorIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat({
|
||||
term: {
|
||||
[PROCESSOR_EVENT]: 'error'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
track_total_hits: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
avgErrorsPerMinute:
|
||||
response.hits.total.value > 0 ? response.hits.total.value / minutes : null
|
||||
};
|
||||
}
|
||||
|
||||
async function getTransactionMetrics({
|
||||
setup,
|
||||
filter,
|
||||
minutes
|
||||
}: TaskParameters) {
|
||||
const { indices, client } = setup;
|
||||
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
size: 1,
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat({
|
||||
term: {
|
||||
[PROCESSOR_EVENT]: 'transaction'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
track_total_hits: true,
|
||||
aggs: {
|
||||
duration: {
|
||||
avg: {
|
||||
field: TRANSACTION_DURATION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
avgTransactionDuration: response.aggregations?.duration.value,
|
||||
avgRequestsPerMinute:
|
||||
response.hits.total.value > 0 ? response.hits.total.value / minutes : null
|
||||
};
|
||||
}
|
||||
|
||||
async function getCpuMetrics({ setup, filter }: TaskParameters) {
|
||||
const { indices, client } = setup;
|
||||
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.metricsIndices'],
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat([
|
||||
{
|
||||
term: {
|
||||
[PROCESSOR_EVENT]: 'metric'
|
||||
}
|
||||
},
|
||||
{
|
||||
exists: {
|
||||
field: METRIC_SYSTEM_CPU_PERCENT
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
avgCpuUsage: {
|
||||
avg: {
|
||||
field: METRIC_SYSTEM_CPU_PERCENT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
avgCpuUsage: response.aggregations?.avgCpuUsage.value
|
||||
};
|
||||
}
|
||||
|
||||
async function getMemoryMetrics({ setup, filter }: TaskParameters) {
|
||||
const { client, indices } = setup;
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.metricsIndices'],
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat([
|
||||
{
|
||||
term: {
|
||||
[PROCESSOR_EVENT]: 'metric'
|
||||
}
|
||||
},
|
||||
{
|
||||
exists: {
|
||||
field: METRIC_SYSTEM_FREE_MEMORY
|
||||
}
|
||||
},
|
||||
{
|
||||
exists: {
|
||||
field: METRIC_SYSTEM_TOTAL_MEMORY
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
avgMemoryUsage: {
|
||||
avg: {
|
||||
script: percentMemoryUsedScript
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
avgMemoryUsage: response.aggregations?.avgMemoryUsage.value
|
||||
};
|
||||
}
|
||||
|
||||
async function getNumInstances({ setup, filter }: TaskParameters) {
|
||||
const { client, indices } = setup;
|
||||
const response = await client.search({
|
||||
index: indices['apm_oss.transactionIndices'],
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: filter.concat([
|
||||
{
|
||||
term: {
|
||||
[PROCESSOR_EVENT]: 'transaction'
|
||||
}
|
||||
},
|
||||
{
|
||||
exists: {
|
||||
field: SERVICE_NODE_NAME
|
||||
}
|
||||
},
|
||||
{
|
||||
exists: {
|
||||
field: METRIC_SYSTEM_TOTAL_MEMORY
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
instances: {
|
||||
cardinality: {
|
||||
field: SERVICE_NODE_NAME
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
numInstances: response.aggregations?.instances.value || 1
|
||||
};
|
||||
}
|
|
@ -58,7 +58,7 @@ import {
|
|||
uiFiltersEnvironmentsRoute
|
||||
} from './ui_filters';
|
||||
import { createApi } from './create_api';
|
||||
import { serviceMapRoute } from './service_map';
|
||||
import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map';
|
||||
|
||||
const createApmApi = () => {
|
||||
const api = createApi()
|
||||
|
@ -123,7 +123,8 @@ const createApmApi = () => {
|
|||
.add(transactionByTraceIdRoute)
|
||||
|
||||
// Service map
|
||||
.add(serviceMapRoute);
|
||||
.add(serviceMapRoute)
|
||||
.add(serviceMapServiceNodeRoute);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ import { setupRequest } from '../lib/helpers/setup_request';
|
|||
import { createRoute } from './create_route';
|
||||
import { uiFiltersRt, rangeRt } from './default_api_types';
|
||||
import { getServiceMap } from '../lib/service_map/get_service_map';
|
||||
import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info';
|
||||
|
||||
export const serviceMapRoute = createRoute(() => ({
|
||||
path: '/api/apm/service-map',
|
||||
|
@ -32,3 +33,35 @@ export const serviceMapRoute = createRoute(() => ({
|
|||
return getServiceMap({ setup, serviceName, environment, after });
|
||||
}
|
||||
}));
|
||||
|
||||
export const serviceMapServiceNodeRoute = createRoute(() => ({
|
||||
path: `/api/apm/service-map/service/{serviceName}`,
|
||||
params: {
|
||||
path: t.type({
|
||||
serviceName: t.string
|
||||
}),
|
||||
query: t.intersection([
|
||||
rangeRt,
|
||||
t.partial({
|
||||
environment: t.string
|
||||
})
|
||||
])
|
||||
},
|
||||
handler: async ({ context, request }) => {
|
||||
if (!context.config['xpack.apm.serviceMapEnabled']) {
|
||||
throw Boom.notFound();
|
||||
}
|
||||
const setup = await setupRequest(context, request);
|
||||
|
||||
const {
|
||||
query: { environment },
|
||||
path: { serviceName }
|
||||
} = context.params;
|
||||
|
||||
return getServiceMapServiceNodeInfo({
|
||||
setup,
|
||||
serviceName,
|
||||
environment
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
|
Loading…
Reference in a new issue