Skip to content

Commit

Permalink
[7.x] [APM] Experimental Service Map front end (#46497) (#47840)
Browse files Browse the repository at this point in the history
Add service map tabs on the main APM screen and for individual services.

This is not yet hooked up to work with back-end data, so it always shows the same hard-coded graph.

This is experimental, so you must have x-pack.apm.serviceMapEnabled: true in your Kibana config for it to show up.

Also add "PSF" to the list of allowed licenses since a new dependency added uses this license (it's on the [green list](https://github.com/elastic/open-source/blob/master/elastic-product-policy.md#green-list).)

Fixes #44890
Fixes #44853
  • Loading branch information
smith authored Oct 10, 2019
1 parent 67ce81b commit 6b57c79
Show file tree
Hide file tree
Showing 24 changed files with 2,190 additions and 425 deletions.
862 changes: 462 additions & 400 deletions packages/kbn-pm/dist/index.js

Large diffs are not rendered by default.

952 changes: 952 additions & 0 deletions renovate.json5

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/dev/license_checker/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const LICENSE_WHITELIST = [
'MIT/X11',
'new BSD, and MIT',
'(OFL-1.1 AND MIT)',
'PSF',
'Public Domain',
'Unlicense',
'WTFPL OR ISC',
Expand Down
6 changes: 5 additions & 1 deletion x-pack/legacy/plugins/apm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const apm: LegacyPluginInitializer = kibana => {
apmUiEnabled: config.get('xpack.apm.ui.enabled'),
// TODO: rename to apm_oss.indexPatternTitle in 7.0 (breaking change)
apmIndexPatternTitle: config.get('apm_oss.indexPattern'),
apmServiceMapEnabled: config.get('xpack.apm.serviceMapEnabled'),
apmTransactionIndices: config.get('apm_oss.transactionIndices')
};
},
Expand Down Expand Up @@ -70,7 +71,10 @@ export const apm: LegacyPluginInitializer = kibana => {

// buckets
minimumBucketSize: Joi.number().default(15),
bucketTargetCount: Joi.number().default(15)
bucketTargetCount: Joi.number().default(15),

// service map
serviceMapEnabled: Joi.boolean().default(false)
}).default();
},

Expand Down
19 changes: 18 additions & 1 deletion x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
EuiSpacer
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { npStart } from 'ui/new_platform';
import React from 'react';
import { $ElementType } from 'utility-types';
import { ApmHeader } from '../../shared/ApmHeader';
Expand All @@ -23,6 +24,8 @@ import { ServiceOverviewLink } from '../../shared/Links/apm/ServiceOverviewLink'
import { TraceOverviewLink } from '../../shared/Links/apm/TraceOverviewLink';
import { EuiTabLink } from '../../shared/EuiTabLink';
import { SettingsLink } from '../../shared/Links/apm/SettingsLink';
import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink';
import { ServiceMap } from '../ServiceMap';

const homeTabs = [
{
Expand All @@ -49,12 +52,26 @@ const homeTabs = [
}
];

if (npStart.core.injectedMetadata.getInjectedVar('apmServiceMapEnabled')) {
homeTabs.push({
link: (
<ServiceMapLink>
{i18n.translate('xpack.apm.home.serviceMapTabLabel', {
defaultMessage: 'Service Map'
})}
</ServiceMapLink>
),
render: () => <ServiceMap />,
name: 'service-map'
});
}

const SETTINGS_LINK_LABEL = i18n.translate('xpack.apm.settingsLinkLabel', {
defaultMessage: 'Settings'
});

interface Props {
tab: 'traces' | 'services';
tab: 'traces' | 'services' | 'service-map';
}

export function Home({ tab }: Props) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Redirect, RouteComponentProps } from 'react-router-dom';
import { npStart } from 'ui/new_platform';
import { ErrorGroupDetails } from '../../ErrorGroupDetails';
import { ServiceDetails } from '../../ServiceDetails';
import { TransactionDetails } from '../../TransactionDetails';
Expand Down Expand Up @@ -149,3 +150,26 @@ export const routes: BreadcrumbRoute[] = [
name: RouteName.TRANSACTION_NAME
}
];

if (npStart.core.injectedMetadata.getInjectedVar('apmServiceMapEnabled')) {
routes.push(
{
exact: true,
path: '/service-map',
component: () => <Home tab="service-map" />,
breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', {
defaultMessage: 'Service Map'
}),
name: RouteName.SERVICE_MAP
},
{
exact: true,
path: '/services/:serviceName/service-map',
component: () => <ServiceDetails tab="service-map" />,
breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', {
defaultMessage: 'Service Map'
}),
name: RouteName.SINGLE_SERVICE_MAP
}
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
export enum RouteName {
HOME = 'home',
SERVICES = 'services',
SERVICE_MAP = 'service-map',
SINGLE_SERVICE_MAP = 'single-service-map',
TRACES = 'traces',
SERVICE = 'service',
TRANSACTIONS = 'transactions',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiTabs, EuiSpacer } from '@elastic/eui';
import { npStart } from 'ui/new_platform';
import { ErrorGroupOverview } from '../ErrorGroupOverview';
import { TransactionOverview } from '../TransactionOverview';
import { ServiceMetrics } from '../ServiceMetrics';
Expand All @@ -19,9 +20,11 @@ import { MetricOverviewLink } from '../../shared/Links/apm/MetricOverviewLink';
import { ServiceNodeOverviewLink } from '../../shared/Links/apm/ServiceNodeOverviewLink';
import { ServiceNodeOverview } from '../ServiceNodeOverview';
import { useAgentName } from '../../../hooks/useAgentName';
import { ServiceMap } from '../ServiceMap';
import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink';

interface Props {
tab: 'transactions' | 'errors' | 'metrics' | 'nodes';
tab: 'transactions' | 'errors' | 'metrics' | 'nodes' | 'service-map';
}

export function ServiceDetailTabs({ tab }: Props) {
Expand Down Expand Up @@ -90,6 +93,22 @@ export function ServiceDetailTabs({ tab }: Props) {
tabs.push(metricsTab);
}

const serviceMapTab = {
link: (
<ServiceMapLink serviceName={serviceName}>
{i18n.translate('xpack.apm.home.serviceMapTabLabel', {
defaultMessage: 'Service Map'
})}
</ServiceMapLink>
),
render: () => <ServiceMap serviceName={serviceName} />,
name: 'service-map'
};

if (npStart.core.injectedMetadata.getInjectedVar('apmServiceMapEnabled')) {
tabs.push(serviceMapTab);
}

const selectedTab = tabs.find(serviceTab => serviceTab.name === tab);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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, { useContext, useState, useEffect } from 'react';
import { EuiButtonIcon, EuiPanel } from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { CytoscapeContext } from './Cytoscape';
import { FullscreenPanel } from './FullscreenPanel';

const Container = styled('div')`
left: ${theme.gutterTypes.gutterMedium};
position: absolute;
top: ${theme.gutterTypes.gutterSmall};
`;

const Button = styled(EuiButtonIcon)`
display: block;
margin: ${theme.paddingSizes.xs};
`;

const ZoomInButton = styled(Button)`
margin-bottom: ${theme.paddingSizes.s};
`;

const ZoomPanel = styled(EuiPanel)`
margin-bottom: ${theme.paddingSizes.s};
`;

const duration = parseInt(theme.euiAnimSpeedFast, 10);
const steps = 5;

function doZoom(cy: cytoscape.Core | undefined, increment: number) {
if (cy) {
const level = cy.zoom() + increment;
cy.animate({
duration,
zoom: { level, position: cy.$('.primary').position() }
});
}
}

export function Controls() {
const cy = useContext(CytoscapeContext);

const [zoom, setZoom] = useState((cy && cy.zoom()) || 1);

useEffect(() => {
if (cy) {
cy.on('zoom', event => {
setZoom(event.cy.zoom());
});
}
}, [cy]);

function zoomIn() {
doZoom(cy, increment);
}

function zoomOut() {
doZoom(cy, -increment);
}

if (!cy) {
return null;
}

const maxZoom = cy.maxZoom();
const isMaxZoom = zoom === maxZoom;
const minZoom = cy.minZoom();
const isMinZoom = zoom === minZoom;
const increment = (maxZoom - minZoom) / steps;
const mapDomElement = cy.container();
const zoomInLabel = i18n.translate('xpack.apm.serviceMap.zoomIn', {
defaultMessage: 'Zoom in'
});
const zoomOutLabel = i18n.translate('xpack.apm.serviceMap.zoomOut', {
defaultMessage: 'Zoom out'
});

return (
<Container>
<ZoomPanel hasShadow={true} paddingSize="none">
<ZoomInButton
aria-label={zoomInLabel}
color="text"
disabled={isMaxZoom}
iconType="plusInCircleFilled"
onClick={zoomIn}
title={zoomInLabel}
/>
<Button
aria-label={zoomOutLabel}
color="text"
disabled={isMinZoom}
iconType="minusInCircleFilled"
onClick={zoomOut}
title={zoomOutLabel}
/>
</ZoomPanel>
<FullscreenPanel element={mapDomElement} />
</Container>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* 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, {
CSSProperties,
useState,
useRef,
useEffect,
ReactNode,
createContext
} from 'react';
import cytoscape from 'cytoscape';
import dagre from 'cytoscape-dagre';
import { cytoscapeOptions } from './cytoscapeOptions';

cytoscape.use(dagre);

export const CytoscapeContext = createContext<cytoscape.Core | undefined>(
undefined
);

interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
serviceName?: string;
style: CSSProperties;
}

function useCytoscape(options: cytoscape.CytoscapeOptions) {
const [cy, setCy] = useState<cytoscape.Core | undefined>(undefined);
const ref = useRef(null);

useEffect(() => {
if (!cy) {
setCy(cytoscape({ ...options, container: ref.current }));
}
}, [options, cy]);

// Destroy the cytoscape instance on unmount
useEffect(() => {
return () => {
if (cy) {
cy.destroy();
}
};
}, [cy]);

return [ref, cy] as [React.MutableRefObject<any>, cytoscape.Core | undefined];
}

export function Cytoscape({
children,
elements,
serviceName,
style
}: CytoscapeProps) {
const [ref, cy] = useCytoscape({ ...cytoscapeOptions, elements });

// Trigger a custom "data" event when data changes
useEffect(() => {
if (cy) {
cy.add(elements);
cy.trigger('data');
}
}, [cy, elements]);

// Set up cytoscape event handlers
useEffect(() => {
if (cy) {
cy.on('data', event => {
// Add the "primary" class to the node if its id matches the serviceName.
if (cy.nodes().length > 0 && serviceName) {
cy.getElementById(serviceName).addClass('primary');
}

if (event.cy.elements().length > 0) {
cy.layout(cytoscapeOptions.layout as cytoscape.LayoutOptions).run();
}
});
}
}, [cy, serviceName]);

return (
<CytoscapeContext.Provider value={cy}>
<div ref={ref} style={style}>
{children}
</div>
</CytoscapeContext.Provider>
);
}
Loading

0 comments on commit 6b57c79

Please sign in to comment.