Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getSeverity, severity } from './getSeverity';
|
||||
import { getSeverity, Severity } from './anomaly_detection';
|
||||
|
||||
describe('getSeverity', () => {
|
||||
describe('when score is undefined', () => {
|
||||
|
@ -15,25 +15,25 @@ describe('getSeverity', () => {
|
|||
|
||||
describe('when score < 25', () => {
|
||||
it('returns warning', () => {
|
||||
expect(getSeverity(10)).toEqual(severity.warning);
|
||||
expect(getSeverity(10)).toEqual(Severity.warning);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score is between 25 and 50', () => {
|
||||
it('returns minor', () => {
|
||||
expect(getSeverity(40)).toEqual(severity.minor);
|
||||
expect(getSeverity(40)).toEqual(Severity.minor);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score is between 50 and 75', () => {
|
||||
it('returns major', () => {
|
||||
expect(getSeverity(60)).toEqual(severity.major);
|
||||
expect(getSeverity(60)).toEqual(Severity.major);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score is 75 or more', () => {
|
||||
it('returns critical', () => {
|
||||
expect(getSeverity(100)).toEqual(severity.critical);
|
||||
expect(getSeverity(100)).toEqual(Severity.critical);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiTheme } from '../../../legacy/common/eui_styled_components';
|
||||
|
||||
export interface ServiceAnomalyStats {
|
||||
transactionType?: string;
|
||||
|
@ -13,6 +14,82 @@ export interface ServiceAnomalyStats {
|
|||
jobId?: string;
|
||||
}
|
||||
|
||||
export enum Severity {
|
||||
critical = 'critical',
|
||||
major = 'major',
|
||||
minor = 'minor',
|
||||
warning = 'warning',
|
||||
}
|
||||
|
||||
// TODO: Replace with `getSeverity` from:
|
||||
// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129
|
||||
export function getSeverity(score?: number) {
|
||||
if (typeof score !== 'number') {
|
||||
return undefined;
|
||||
} else if (score < 25) {
|
||||
return Severity.warning;
|
||||
} else if (score >= 25 && score < 50) {
|
||||
return Severity.minor;
|
||||
} else if (score >= 50 && score < 75) {
|
||||
return Severity.major;
|
||||
} else if (score >= 75) {
|
||||
return Severity.critical;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSeverityColor(theme: EuiTheme, severity?: Severity) {
|
||||
switch (severity) {
|
||||
case Severity.warning:
|
||||
return theme.eui.euiColorVis0;
|
||||
case Severity.minor:
|
||||
case Severity.major:
|
||||
return theme.eui.euiColorVis5;
|
||||
case Severity.critical:
|
||||
return theme.eui.euiColorVis9;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSeverityLabel(severity?: Severity) {
|
||||
switch (severity) {
|
||||
case Severity.critical:
|
||||
return i18n.translate(
|
||||
'xpack.apm.servicesTable.serviceHealthStatus.critical',
|
||||
{
|
||||
defaultMessage: 'Critical',
|
||||
}
|
||||
);
|
||||
|
||||
case Severity.major:
|
||||
case Severity.minor:
|
||||
return i18n.translate(
|
||||
'xpack.apm.servicesTable.serviceHealthStatus.warning',
|
||||
{
|
||||
defaultMessage: 'Warning',
|
||||
}
|
||||
);
|
||||
|
||||
case Severity.warning:
|
||||
return i18n.translate(
|
||||
'xpack.apm.servicesTable.serviceHealthStatus.healthy',
|
||||
{
|
||||
defaultMessage: 'Healthy',
|
||||
}
|
||||
);
|
||||
|
||||
default:
|
||||
return i18n.translate(
|
||||
'xpack.apm.servicesTable.serviceHealthStatus.unknown',
|
||||
{
|
||||
defaultMessage: 'Unknown',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ML_ERRORS = {
|
||||
INVALID_LICENSE: i18n.translate(
|
||||
'xpack.apm.anomaly_detection.error.invalid_license',
|
||||
|
|
|
@ -8,7 +8,7 @@ import { License } from '../../licensing/common/license';
|
|||
import * as serviceMap from './service_map';
|
||||
|
||||
describe('service map helpers', () => {
|
||||
describe('isValidPlatinumLicense', () => {
|
||||
describe('isActivePlatinumLicense', () => {
|
||||
describe('with an expired license', () => {
|
||||
it('returns false', () => {
|
||||
const license = new License({
|
||||
|
@ -22,7 +22,7 @@ describe('service map helpers', () => {
|
|||
signature: 'test signature',
|
||||
});
|
||||
|
||||
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(false);
|
||||
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -39,7 +39,7 @@ describe('service map helpers', () => {
|
|||
signature: 'test signature',
|
||||
});
|
||||
|
||||
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(false);
|
||||
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -56,7 +56,7 @@ describe('service map helpers', () => {
|
|||
signature: 'test signature',
|
||||
});
|
||||
|
||||
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true);
|
||||
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -73,7 +73,7 @@ describe('service map helpers', () => {
|
|||
signature: 'test signature',
|
||||
});
|
||||
|
||||
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true);
|
||||
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -90,7 +90,7 @@ describe('service map helpers', () => {
|
|||
signature: 'test signature',
|
||||
});
|
||||
|
||||
expect(serviceMap.isValidPlatinumLicense(license)).toEqual(true);
|
||||
expect(serviceMap.isActivePlatinumLicense(license)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -46,7 +46,7 @@ export interface ServiceNodeStats {
|
|||
avgErrorRate: number | null;
|
||||
}
|
||||
|
||||
export function isValidPlatinumLicense(license: ILicense) {
|
||||
export function isActivePlatinumLicense(license: ILicense) {
|
||||
return license.isActive && license.hasAtLeast('platinum');
|
||||
}
|
||||
|
||||
|
|
|
@ -18,10 +18,13 @@ import { useTheme } from '../../../../hooks/useTheme';
|
|||
import { fontSize, px } from '../../../../style/variables';
|
||||
import { asInteger, asDuration } from '../../../../utils/formatters';
|
||||
import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink';
|
||||
import { getSeverityColor, popoverWidth } from '../cytoscapeOptions';
|
||||
import { popoverWidth } from '../cytoscapeOptions';
|
||||
import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types';
|
||||
import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection';
|
||||
import { getSeverity } from './getSeverity';
|
||||
import {
|
||||
getSeverity,
|
||||
getSeverityColor,
|
||||
ServiceAnomalyStats,
|
||||
} from '../../../../../common/anomaly_detection';
|
||||
|
||||
const HealthStatusTitle = styled(EuiTitle)`
|
||||
display: inline;
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export enum severity {
|
||||
critical = 'critical',
|
||||
major = 'major',
|
||||
minor = 'minor',
|
||||
warning = 'warning',
|
||||
}
|
||||
|
||||
// TODO: Replace with `getSeverity` from:
|
||||
// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129
|
||||
export function getSeverity(score?: number) {
|
||||
if (typeof score !== 'number') {
|
||||
return undefined;
|
||||
} else if (score < 25) {
|
||||
return severity.warning;
|
||||
} else if (score >= 25 && score < 50) {
|
||||
return severity.minor;
|
||||
} else if (score >= 50 && score < 75) {
|
||||
return severity.major;
|
||||
} else if (score >= 75) {
|
||||
return severity.critical;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -11,25 +11,15 @@ import {
|
|||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import { EuiTheme } from '../../../../../observability/public';
|
||||
import { defaultIcon, iconForNode } from './icons';
|
||||
import { ServiceAnomalyStats } from '../../../../common/anomaly_detection';
|
||||
import { severity, getSeverity } from './Popover/getSeverity';
|
||||
import {
|
||||
getSeverity,
|
||||
getSeverityColor,
|
||||
ServiceAnomalyStats,
|
||||
Severity,
|
||||
} from '../../../../common/anomaly_detection';
|
||||
|
||||
export const popoverWidth = 280;
|
||||
|
||||
export function getSeverityColor(theme: EuiTheme, nodeSeverity?: string) {
|
||||
switch (nodeSeverity) {
|
||||
case severity.warning:
|
||||
return theme.eui.euiColorVis0;
|
||||
case severity.minor:
|
||||
case severity.major:
|
||||
return theme.eui.euiColorVis5;
|
||||
case severity.critical:
|
||||
return theme.eui.euiColorVis9;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeSeverity(el: cytoscape.NodeSingular) {
|
||||
const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data(
|
||||
'serviceAnomalyStats'
|
||||
|
@ -60,7 +50,7 @@ const getBorderStyle: cytoscape.Css.MapperFunction<
|
|||
cytoscape.Css.LineStyle
|
||||
> = (el: cytoscape.NodeSingular) => {
|
||||
const nodeSeverity = getNodeSeverity(el);
|
||||
if (nodeSeverity === severity.critical) {
|
||||
if (nodeSeverity === Severity.critical) {
|
||||
return 'double';
|
||||
} else {
|
||||
return 'solid';
|
||||
|
@ -70,9 +60,9 @@ const getBorderStyle: cytoscape.Css.MapperFunction<
|
|||
function getBorderWidth(el: cytoscape.NodeSingular) {
|
||||
const nodeSeverity = getNodeSeverity(el);
|
||||
|
||||
if (nodeSeverity === severity.minor || nodeSeverity === severity.major) {
|
||||
if (nodeSeverity === Severity.minor || nodeSeverity === Severity.major) {
|
||||
return 4;
|
||||
} else if (nodeSeverity === severity.critical) {
|
||||
} else if (nodeSeverity === Severity.critical) {
|
||||
return 8;
|
||||
} else {
|
||||
return 4;
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import cytoscape from 'cytoscape';
|
||||
import { getNormalizedAgentName } from '../../../../common/agent_name';
|
||||
import {
|
||||
AGENT_NAME,
|
||||
SPAN_SUBTYPE,
|
||||
|
@ -13,29 +12,22 @@ import {
|
|||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import awsIcon from './icons/aws.svg';
|
||||
import cassandraIcon from './icons/cassandra.svg';
|
||||
import darkIcon from './icons/dark.svg';
|
||||
import databaseIcon from './icons/database.svg';
|
||||
import defaultIconImport from './icons/default.svg';
|
||||
import documentsIcon from './icons/documents.svg';
|
||||
import dotNetIcon from './icons/dot-net.svg';
|
||||
import elasticsearchIcon from './icons/elasticsearch.svg';
|
||||
import globeIcon from './icons/globe.svg';
|
||||
import goIcon from './icons/go.svg';
|
||||
import graphqlIcon from './icons/graphql.svg';
|
||||
import grpcIcon from './icons/grpc.svg';
|
||||
import handlebarsIcon from './icons/handlebars.svg';
|
||||
import javaIcon from './icons/java.svg';
|
||||
import kafkaIcon from './icons/kafka.svg';
|
||||
import mongodbIcon from './icons/mongodb.svg';
|
||||
import mysqlIcon from './icons/mysql.svg';
|
||||
import nodeJsIcon from './icons/nodejs.svg';
|
||||
import phpIcon from './icons/php.svg';
|
||||
import postgresqlIcon from './icons/postgresql.svg';
|
||||
import pythonIcon from './icons/python.svg';
|
||||
import redisIcon from './icons/redis.svg';
|
||||
import rubyIcon from './icons/ruby.svg';
|
||||
import rumJsIcon from './icons/rumjs.svg';
|
||||
import websocketIcon from './icons/websocket.svg';
|
||||
import javaIcon from '../../shared/AgentIcon/icons/java.svg';
|
||||
import { getAgentIcon } from '../../shared/AgentIcon/get_agent_icon';
|
||||
|
||||
export const defaultIcon = defaultIconImport;
|
||||
|
||||
|
@ -74,23 +66,6 @@ const typeIcons: { [key: string]: { [key: string]: string } } = {
|
|||
},
|
||||
};
|
||||
|
||||
const agentIcons: { [key: string]: string } = {
|
||||
dark: darkIcon,
|
||||
dotnet: dotNetIcon,
|
||||
go: goIcon,
|
||||
java: javaIcon,
|
||||
'js-base': rumJsIcon,
|
||||
nodejs: nodeJsIcon,
|
||||
php: phpIcon,
|
||||
python: pythonIcon,
|
||||
ruby: rubyIcon,
|
||||
};
|
||||
|
||||
function getAgentIcon(agentName?: string) {
|
||||
const normalizedAgentName = getNormalizedAgentName(agentName);
|
||||
return normalizedAgentName && agentIcons[normalizedAgentName];
|
||||
}
|
||||
|
||||
function getSpanIcon(type?: string, subtype?: string) {
|
||||
if (!type) {
|
||||
return;
|
||||
|
|
Before Width: | Height: | Size: 39 KiB |
|
@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
|||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import {
|
||||
invalidLicenseMessage,
|
||||
isValidPlatinumLicense,
|
||||
isActivePlatinumLicense,
|
||||
} from '../../../../common/service_map';
|
||||
import { useFetcher } from '../../../hooks/useFetcher';
|
||||
import { useLicense } from '../../../hooks/useLicense';
|
||||
|
@ -36,7 +36,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
|
|||
|
||||
const { data = { elements: [] } } = useFetcher(() => {
|
||||
// When we don't have a license or a valid license, don't make the request.
|
||||
if (!license || !isValidPlatinumLicense(license)) {
|
||||
if (!license || !isActivePlatinumLicense(license)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -66,7 +66,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
return isValidPlatinumLicense(license) ? (
|
||||
return isActivePlatinumLicense(license) ? (
|
||||
<div
|
||||
style={{
|
||||
height: height - parseInt(theme.eui.gutterTypes.gutterLarge, 10),
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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 { EuiBadge } from '@elastic/eui';
|
||||
import {
|
||||
getSeverityColor,
|
||||
getSeverityLabel,
|
||||
Severity,
|
||||
} from '../../../../../common/anomaly_detection';
|
||||
import { useTheme } from '../../../../hooks/useTheme';
|
||||
|
||||
export function HealthBadge({ severity }: { severity?: Severity }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const unknownColor = theme.eui.euiColorLightShade;
|
||||
|
||||
return (
|
||||
<EuiBadge color={getSeverityColor(theme, severity) ?? unknownColor}>
|
||||
{getSeverityLabel(severity)}
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { EuiCallOut } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiFlexGrid } from '@elastic/eui';
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import { APMLink } from '../../../shared/Links/apm/APMLink';
|
||||
|
||||
export function MLCallout({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.title', {
|
||||
defaultMessage:
|
||||
'Enable anomaly detection to see the health of your services',
|
||||
})}
|
||||
iconType="iInCircle"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate('xpack.apm.serviceOverview.mlNudgeMessage.content', {
|
||||
defaultMessage: `Our integration with ML anomaly detection will enable you to see your services' health status`,
|
||||
})}
|
||||
</p>
|
||||
<EuiFlexGrid gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton>
|
||||
<APMLink
|
||||
path="/settings/anomaly-detection"
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceOverview.mlNudgeMessage.learnMoreButton',
|
||||
{
|
||||
defaultMessage: `Learn more`,
|
||||
}
|
||||
)}
|
||||
</APMLink>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={() => onDismiss()}>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceOverview.mlNudgeMessage.dismissButton',
|
||||
{
|
||||
defaultMessage: `Dismiss message`,
|
||||
}
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../../../hooks/useTheme';
|
||||
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||
import { getEmptySeries } from '../../../shared/charts/CustomPlot/getEmptySeries';
|
||||
import { SparkPlot } from '../../../shared/charts/SparkPlot';
|
||||
|
||||
export function ServiceListMetric({
|
||||
color,
|
||||
series,
|
||||
valueLabel,
|
||||
}: {
|
||||
color: 'euiColorVis1' | 'euiColorVis0' | 'euiColorVis7';
|
||||
series?: Array<{ x: number; y: number | null }>;
|
||||
valueLabel: React.ReactNode;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
urlParams: { start, end },
|
||||
} = useUrlParams();
|
||||
|
||||
const colorValue = theme.eui[color];
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<SparkPlot
|
||||
series={
|
||||
series ??
|
||||
getEmptySeries(parseFloat(start!), parseFloat(end!))[0].data
|
||||
}
|
||||
color={colorValue}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ whiteSpace: 'nowrap' }}>
|
||||
{valueLabel}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -15,34 +15,62 @@ describe('ServiceOverview -> List', () => {
|
|||
mockMoment();
|
||||
});
|
||||
|
||||
it('should render empty state', () => {
|
||||
it('renders empty state', () => {
|
||||
const wrapper = shallow(<ServiceList items={[]} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with data', () => {
|
||||
it('renders with data', () => {
|
||||
const wrapper = shallow(<ServiceList items={props.items} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render columns correctly', () => {
|
||||
it('renders columns correctly', () => {
|
||||
const service = {
|
||||
serviceName: 'opbeans-python',
|
||||
agentName: 'python',
|
||||
transactionsPerMinute: 86.93333333333334,
|
||||
errorsPerMinute: 12.6,
|
||||
avgResponseTime: 91535.42944785276,
|
||||
transactionsPerMinute: {
|
||||
value: 86.93333333333334,
|
||||
timeseries: [],
|
||||
},
|
||||
errorsPerMinute: {
|
||||
value: 12.6,
|
||||
timeseries: [],
|
||||
},
|
||||
avgResponseTime: {
|
||||
value: 91535.42944785276,
|
||||
timeseries: [],
|
||||
},
|
||||
environments: ['test'],
|
||||
};
|
||||
const renderedColumns = SERVICE_COLUMNS.map((c) =>
|
||||
c.render(service[c.field], service)
|
||||
);
|
||||
|
||||
expect(renderedColumns[0]).toMatchSnapshot();
|
||||
expect(renderedColumns.slice(2)).toEqual([
|
||||
'python',
|
||||
'92 ms',
|
||||
'86.9 tpm',
|
||||
'12.6 err.',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('without ML data', () => {
|
||||
it('does not render health column', () => {
|
||||
const wrapper = shallow(
|
||||
<ServiceList items={props.items} displayHealthStatus={false} />
|
||||
);
|
||||
|
||||
const columns = wrapper.props().columns;
|
||||
|
||||
expect(columns[0].field).not.toBe('severity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ML data', () => {
|
||||
it('renders health column', () => {
|
||||
const wrapper = shallow(
|
||||
<ServiceList items={props.items} displayHealthStatus />
|
||||
);
|
||||
|
||||
const columns = wrapper.props().columns;
|
||||
|
||||
expect(columns[0].field).toBe('severity');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,21 +1,8 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ServiceOverview -> List should render columns correctly 1`] = `
|
||||
<EuiToolTip
|
||||
content="opbeans-python"
|
||||
delay="regular"
|
||||
id="service-name-tooltip"
|
||||
position="top"
|
||||
>
|
||||
<Styled(TransactionOverviewLink)
|
||||
serviceName="opbeans-python"
|
||||
>
|
||||
opbeans-python
|
||||
</Styled(TransactionOverviewLink)>
|
||||
</EuiToolTip>
|
||||
`;
|
||||
exports[`ServiceOverview -> List renders columns correctly 1`] = `<HealthBadge />`;
|
||||
|
||||
exports[`ServiceOverview -> List should render empty state 1`] = `
|
||||
exports[`ServiceOverview -> List renders empty state 1`] = `
|
||||
<Memo(UnoptimizedManagedTable)
|
||||
columns={
|
||||
Array [
|
||||
|
@ -31,44 +18,46 @@ exports[`ServiceOverview -> List should render empty state 1`] = `
|
|||
"name": "Environment",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "20%",
|
||||
},
|
||||
Object {
|
||||
"field": "agentName",
|
||||
"name": "Agent",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
Object {
|
||||
"align": "left",
|
||||
"dataType": "number",
|
||||
"field": "avgResponseTime",
|
||||
"name": "Avg. response time",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
Object {
|
||||
"align": "left",
|
||||
"dataType": "number",
|
||||
"field": "transactionsPerMinute",
|
||||
"name": "Trans. per minute",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
Object {
|
||||
"align": "left",
|
||||
"dataType": "number",
|
||||
"field": "errorsPerMinute",
|
||||
"name": "Errors per minute",
|
||||
"name": "Error rate %",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
]
|
||||
}
|
||||
initialPageSize={50}
|
||||
initialSortField="serviceName"
|
||||
initialSortDirection="desc"
|
||||
initialSortField="severity"
|
||||
items={Array []}
|
||||
sortFn={[Function]}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`ServiceOverview -> List should render with data 1`] = `
|
||||
exports[`ServiceOverview -> List renders with data 1`] = `
|
||||
<Memo(UnoptimizedManagedTable)
|
||||
columns={
|
||||
Array [
|
||||
|
@ -84,39 +73,40 @@ exports[`ServiceOverview -> List should render with data 1`] = `
|
|||
"name": "Environment",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "20%",
|
||||
},
|
||||
Object {
|
||||
"field": "agentName",
|
||||
"name": "Agent",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
Object {
|
||||
"align": "left",
|
||||
"dataType": "number",
|
||||
"field": "avgResponseTime",
|
||||
"name": "Avg. response time",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
Object {
|
||||
"align": "left",
|
||||
"dataType": "number",
|
||||
"field": "transactionsPerMinute",
|
||||
"name": "Trans. per minute",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
Object {
|
||||
"align": "left",
|
||||
"dataType": "number",
|
||||
"field": "errorsPerMinute",
|
||||
"name": "Errors per minute",
|
||||
"name": "Error rate %",
|
||||
"render": [Function],
|
||||
"sortable": true,
|
||||
"width": "160px",
|
||||
},
|
||||
]
|
||||
}
|
||||
initialPageSize={50}
|
||||
initialSortField="serviceName"
|
||||
initialSortDirection="desc"
|
||||
initialSortField="severity"
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
|
@ -125,19 +115,35 @@ exports[`ServiceOverview -> List should render with data 1`] = `
|
|||
"environments": Array [
|
||||
"test",
|
||||
],
|
||||
"errorsPerMinute": 46.06666666666667,
|
||||
"errorsPerMinute": Object {
|
||||
"timeseries": Array [],
|
||||
"value": 46.06666666666667,
|
||||
},
|
||||
"serviceName": "opbeans-node",
|
||||
"transactionsPerMinute": 0,
|
||||
"transactionsPerMinute": Object {
|
||||
"timeseries": Array [],
|
||||
"value": 0,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"agentName": "python",
|
||||
"avgResponseTime": 91535.42944785276,
|
||||
"avgResponseTime": Object {
|
||||
"timeseries": Array [],
|
||||
"value": 91535.42944785276,
|
||||
},
|
||||
"environments": Array [],
|
||||
"errorsPerMinute": 12.6,
|
||||
"errorsPerMinute": Object {
|
||||
"timeseries": Array [],
|
||||
"value": 12.6,
|
||||
},
|
||||
"serviceName": "opbeans-python",
|
||||
"transactionsPerMinute": 86.93333333333334,
|
||||
"transactionsPerMinute": Object {
|
||||
"timeseries": Array [],
|
||||
"value": 86.93333333333334,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
sortFn={[Function]}
|
||||
/>
|
||||
`;
|
||||
|
|
|
@ -3,17 +3,34 @@
|
|||
{
|
||||
"serviceName": "opbeans-node",
|
||||
"agentName": "nodejs",
|
||||
"transactionsPerMinute": 0,
|
||||
"errorsPerMinute": 46.06666666666667,
|
||||
"transactionsPerMinute": {
|
||||
"value": 0,
|
||||
"timeseries": []
|
||||
},
|
||||
"errorsPerMinute": {
|
||||
"value": 46.06666666666667,
|
||||
"timeseries": []
|
||||
},
|
||||
"avgResponseTime": null,
|
||||
"environments": ["test"]
|
||||
"environments": [
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"serviceName": "opbeans-python",
|
||||
"agentName": "python",
|
||||
"transactionsPerMinute": 86.93333333333334,
|
||||
"errorsPerMinute": 12.6,
|
||||
"avgResponseTime": 91535.42944785276,
|
||||
"transactionsPerMinute": {
|
||||
"value": 86.93333333333334,
|
||||
"timeseries": []
|
||||
},
|
||||
"errorsPerMinute": {
|
||||
"value": 12.6,
|
||||
"timeseries": []
|
||||
},
|
||||
"avgResponseTime": {
|
||||
"value": 91535.42944785276,
|
||||
"timeseries": []
|
||||
},
|
||||
"environments": []
|
||||
}
|
||||
]
|
||||
|
|
|
@ -4,24 +4,34 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
import { EuiFlexItem, EuiFlexGroup, EuiToolTip } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ValuesType } from 'utility-types';
|
||||
import { orderBy } from 'lodash';
|
||||
import { asPercent } from '../../../../../common/utils/formatters';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
|
||||
import { fontSizes, truncate } from '../../../../style/variables';
|
||||
import { fontSizes, px, truncate, unit } from '../../../../style/variables';
|
||||
import { asDecimal, asMillisecondDuration } from '../../../../utils/formatters';
|
||||
import { ManagedTable } from '../../../shared/ManagedTable';
|
||||
import { ManagedTable, ITableColumn } from '../../../shared/ManagedTable';
|
||||
import { EnvironmentBadge } from '../../../shared/EnvironmentBadge';
|
||||
import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink';
|
||||
import { AgentIcon } from '../../../shared/AgentIcon';
|
||||
import { Severity } from '../../../../../common/anomaly_detection';
|
||||
import { HealthBadge } from './HealthBadge';
|
||||
import { ServiceListMetric } from './ServiceListMetric';
|
||||
|
||||
interface Props {
|
||||
items: ServiceListAPIResponse['items'];
|
||||
noItemsMessage?: React.ReactNode;
|
||||
displayHealthStatus: boolean;
|
||||
}
|
||||
|
||||
type ServiceListItem = ValuesType<Props['items']>;
|
||||
|
||||
function formatNumber(value: number) {
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
|
@ -41,7 +51,18 @@ const AppLink = styled(TransactionOverviewLink)`
|
|||
${truncate('100%')};
|
||||
`;
|
||||
|
||||
export const SERVICE_COLUMNS = [
|
||||
export const SERVICE_COLUMNS: Array<ITableColumn<ServiceListItem>> = [
|
||||
{
|
||||
field: 'severity',
|
||||
name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', {
|
||||
defaultMessage: 'Health',
|
||||
}),
|
||||
width: px(unit * 6),
|
||||
sortable: true,
|
||||
render: (_, { severity }) => {
|
||||
return <HealthBadge severity={severity} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'serviceName',
|
||||
name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', {
|
||||
|
@ -49,9 +70,24 @@ export const SERVICE_COLUMNS = [
|
|||
}),
|
||||
width: '40%',
|
||||
sortable: true,
|
||||
render: (serviceName: string) => (
|
||||
<EuiToolTip content={formatString(serviceName)} id="service-name-tooltip">
|
||||
<AppLink serviceName={serviceName}>{formatString(serviceName)}</AppLink>
|
||||
render: (_, { serviceName, agentName }) => (
|
||||
<EuiToolTip
|
||||
delay="long"
|
||||
content={formatString(serviceName)}
|
||||
id="service-name-tooltip"
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
{agentName && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<AgentIcon agentName={agentName} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<AppLink serviceName={serviceName}>
|
||||
{formatString(serviceName)}
|
||||
</AppLink>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiToolTip>
|
||||
),
|
||||
},
|
||||
|
@ -60,20 +96,12 @@ export const SERVICE_COLUMNS = [
|
|||
name: i18n.translate('xpack.apm.servicesTable.environmentColumnLabel', {
|
||||
defaultMessage: 'Environment',
|
||||
}),
|
||||
width: '20%',
|
||||
width: px(unit * 10),
|
||||
sortable: true,
|
||||
render: (environments: string[]) => (
|
||||
<EnvironmentBadge environments={environments} />
|
||||
render: (_, { environments }) => (
|
||||
<EnvironmentBadge environments={environments ?? []} />
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'agentName',
|
||||
name: i18n.translate('xpack.apm.servicesTable.agentColumnLabel', {
|
||||
defaultMessage: 'Agent',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (agentName: string) => formatString(agentName),
|
||||
},
|
||||
{
|
||||
field: 'avgResponseTime',
|
||||
name: i18n.translate('xpack.apm.servicesTable.avgResponseTimeColumnLabel', {
|
||||
|
@ -81,7 +109,15 @@ export const SERVICE_COLUMNS = [
|
|||
}),
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
render: (time: number) => asMillisecondDuration(time),
|
||||
render: (_, { avgResponseTime }) => (
|
||||
<ServiceListMetric
|
||||
series={avgResponseTime?.timeseries}
|
||||
color="euiColorVis1"
|
||||
valueLabel={asMillisecondDuration(avgResponseTime?.value || 0)}
|
||||
/>
|
||||
),
|
||||
align: 'left',
|
||||
width: px(unit * 10),
|
||||
},
|
||||
{
|
||||
field: 'transactionsPerMinute',
|
||||
|
@ -93,39 +129,107 @@ export const SERVICE_COLUMNS = [
|
|||
),
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
render: (value: number) =>
|
||||
`${formatNumber(value)} ${i18n.translate(
|
||||
'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel',
|
||||
{
|
||||
defaultMessage: 'tpm',
|
||||
}
|
||||
)}`,
|
||||
render: (_, { transactionsPerMinute }) => (
|
||||
<ServiceListMetric
|
||||
series={transactionsPerMinute?.timeseries}
|
||||
color="euiColorVis0"
|
||||
valueLabel={`${formatNumber(
|
||||
transactionsPerMinute?.value || 0
|
||||
)} ${i18n.translate(
|
||||
'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel',
|
||||
{
|
||||
defaultMessage: 'tpm',
|
||||
}
|
||||
)}`}
|
||||
/>
|
||||
),
|
||||
align: 'left',
|
||||
width: px(unit * 10),
|
||||
},
|
||||
{
|
||||
field: 'errorsPerMinute',
|
||||
name: i18n.translate('xpack.apm.servicesTable.errorsPerMinuteColumnLabel', {
|
||||
defaultMessage: 'Errors per minute',
|
||||
name: i18n.translate('xpack.apm.servicesTable.transactionErrorRate', {
|
||||
defaultMessage: 'Error rate %',
|
||||
}),
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
render: (value: number) =>
|
||||
`${formatNumber(value)} ${i18n.translate(
|
||||
'xpack.apm.servicesTable.errorsPerMinuteUnitLabel',
|
||||
{
|
||||
defaultMessage: 'err.',
|
||||
}
|
||||
)}`,
|
||||
render: (_, { transactionErrorRate }) => {
|
||||
const value = transactionErrorRate?.value;
|
||||
|
||||
const valueLabel =
|
||||
value !== null && value !== undefined ? asPercent(value, 1) : '';
|
||||
|
||||
return (
|
||||
<ServiceListMetric
|
||||
series={transactionErrorRate?.timeseries}
|
||||
color="euiColorVis7"
|
||||
valueLabel={valueLabel}
|
||||
/>
|
||||
);
|
||||
},
|
||||
align: 'left',
|
||||
width: px(unit * 10),
|
||||
},
|
||||
];
|
||||
|
||||
export function ServiceList({ items, noItemsMessage }: Props) {
|
||||
const SEVERITY_ORDER = [
|
||||
Severity.warning,
|
||||
Severity.minor,
|
||||
Severity.major,
|
||||
Severity.critical,
|
||||
];
|
||||
|
||||
export function ServiceList({
|
||||
items,
|
||||
displayHealthStatus,
|
||||
noItemsMessage,
|
||||
}: Props) {
|
||||
const columns = displayHealthStatus
|
||||
? SERVICE_COLUMNS
|
||||
: SERVICE_COLUMNS.filter((column) => column.field !== 'severity');
|
||||
|
||||
return (
|
||||
<ManagedTable
|
||||
columns={SERVICE_COLUMNS}
|
||||
columns={columns}
|
||||
items={items}
|
||||
noItemsMessage={noItemsMessage}
|
||||
initialSortField="serviceName"
|
||||
initialSortField="severity"
|
||||
initialSortDirection="desc"
|
||||
initialPageSize={50}
|
||||
sortFn={(itemsToSort, sortField, sortDirection) => {
|
||||
// For severity, sort items by severity first, then by TPM
|
||||
|
||||
return sortField === 'severity'
|
||||
? orderBy(
|
||||
itemsToSort,
|
||||
[
|
||||
(item) => {
|
||||
return item.severity
|
||||
? SEVERITY_ORDER.indexOf(item.severity)
|
||||
: -1;
|
||||
},
|
||||
(item) => item.transactionsPerMinute?.value ?? 0,
|
||||
],
|
||||
[sortDirection, sortDirection]
|
||||
)
|
||||
: orderBy(
|
||||
itemsToSort,
|
||||
(item) => {
|
||||
switch (sortField) {
|
||||
case 'avgResponseTime':
|
||||
return item.avgResponseTime?.value ?? 0;
|
||||
case 'transactionsPerMinute':
|
||||
return item.transactionsPerMinute?.value ?? 0;
|
||||
case 'transactionErrorRate':
|
||||
return item.transactionErrorRate?.value ?? 0;
|
||||
|
||||
default:
|
||||
return item[sortField as keyof typeof item];
|
||||
}
|
||||
},
|
||||
sortDirection
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { render, wait, waitForElement } from '@testing-library/react';
|
|||
import { CoreStart } from 'kibana/public';
|
||||
import React, { FunctionComponent, ReactChild } from 'react';
|
||||
import { createKibanaReactContext } from 'src/plugins/kibana_react/public';
|
||||
import { merge } from 'lodash';
|
||||
import { ServiceOverview } from '..';
|
||||
import { ApmPluginContextValue } from '../../../../context/ApmPluginContext';
|
||||
import {
|
||||
|
@ -17,35 +18,38 @@ import {
|
|||
import { FETCH_STATUS } from '../../../../hooks/useFetcher';
|
||||
import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters';
|
||||
import * as urlParamsHooks from '../../../../hooks/useUrlParams';
|
||||
import * as useAnomalyDetectionJobs from '../../../../hooks/useAnomalyDetectionJobs';
|
||||
import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock';
|
||||
import { EuiThemeProvider } from '../../../../../../../legacy/common/eui_styled_components';
|
||||
|
||||
const KibanaReactContext = createKibanaReactContext({
|
||||
usageCollection: { reportUiStats: () => {} },
|
||||
} as Partial<CoreStart>);
|
||||
|
||||
const addWarning = jest.fn();
|
||||
const httpGet = jest.fn();
|
||||
|
||||
function wrapper({ children }: { children: ReactChild }) {
|
||||
const mockPluginContext = (merge({}, mockApmPluginContextValue, {
|
||||
core: {
|
||||
http: {
|
||||
get: httpGet,
|
||||
},
|
||||
notifications: {
|
||||
toasts: {
|
||||
addWarning,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as unknown) as ApmPluginContextValue;
|
||||
|
||||
return (
|
||||
<KibanaReactContext.Provider>
|
||||
<MockApmPluginContextWrapper
|
||||
value={
|
||||
({
|
||||
...mockApmPluginContextValue,
|
||||
core: {
|
||||
...mockApmPluginContextValue.core,
|
||||
http: { ...mockApmPluginContextValue.core.http, get: httpGet },
|
||||
notifications: {
|
||||
...mockApmPluginContextValue.core.notifications,
|
||||
toasts: {
|
||||
...mockApmPluginContextValue.core.notifications.toasts,
|
||||
addWarning,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown) as ApmPluginContextValue
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</MockApmPluginContextWrapper>
|
||||
<EuiThemeProvider>
|
||||
<MockApmPluginContextWrapper value={mockPluginContext as any}>
|
||||
{children}
|
||||
</MockApmPluginContextWrapper>
|
||||
</EuiThemeProvider>
|
||||
</KibanaReactContext.Provider>
|
||||
);
|
||||
}
|
||||
|
@ -56,9 +60,6 @@ function renderServiceOverview() {
|
|||
});
|
||||
}
|
||||
|
||||
const addWarning = jest.fn();
|
||||
const httpGet = jest.fn();
|
||||
|
||||
describe('Service Overview -> View', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error
|
||||
|
@ -80,6 +81,17 @@ describe('Service Overview -> View', () => {
|
|||
clearValues: () => null,
|
||||
status: FETCH_STATUS.SUCCESS,
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(useAnomalyDetectionJobs, 'useAnomalyDetectionJobs')
|
||||
.mockReturnValue({
|
||||
status: FETCH_STATUS.SUCCESS,
|
||||
data: {
|
||||
jobs: [],
|
||||
hasLegacyJobs: false,
|
||||
},
|
||||
refetch: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -99,6 +111,7 @@ describe('Service Overview -> View', () => {
|
|||
errorsPerMinute: 200,
|
||||
avgResponseTime: 300,
|
||||
environments: ['test', 'dev'],
|
||||
severity: 1,
|
||||
},
|
||||
{
|
||||
serviceName: 'My Go Service',
|
||||
|
@ -107,6 +120,7 @@ describe('Service Overview -> View', () => {
|
|||
errorsPerMinute: 500,
|
||||
avgResponseTime: 600,
|
||||
environments: [],
|
||||
severity: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -195,4 +209,57 @@ describe('Service Overview -> View', () => {
|
|||
expect(addWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when ML data is not found', () => {
|
||||
it('does not render the health column', async () => {
|
||||
httpGet.mockResolvedValueOnce({
|
||||
hasLegacyData: false,
|
||||
hasHistoricalData: true,
|
||||
items: [
|
||||
{
|
||||
serviceName: 'My Python Service',
|
||||
agentName: 'python',
|
||||
transactionsPerMinute: 100,
|
||||
errorsPerMinute: 200,
|
||||
avgResponseTime: 300,
|
||||
environments: ['test', 'dev'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { queryByText } = renderServiceOverview();
|
||||
|
||||
// wait for requests to be made
|
||||
await wait(() => expect(httpGet).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(queryByText('Health')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when ML data is found', () => {
|
||||
it('renders the health column', async () => {
|
||||
httpGet.mockResolvedValueOnce({
|
||||
hasLegacyData: false,
|
||||
hasHistoricalData: true,
|
||||
items: [
|
||||
{
|
||||
serviceName: 'My Python Service',
|
||||
agentName: 'python',
|
||||
transactionsPerMinute: 100,
|
||||
errorsPerMinute: 200,
|
||||
avgResponseTime: 300,
|
||||
environments: ['test', 'dev'],
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { queryAllByText } = renderServiceOverview();
|
||||
|
||||
// wait for requests to be made
|
||||
await wait(() => expect(httpGet).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(queryAllByText('Health').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@ NodeList [
|
|||
>
|
||||
<td
|
||||
class="euiTableRowCell euiTableRowCell--isMobileFullWidth"
|
||||
colspan="6"
|
||||
colspan="5"
|
||||
>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignCenter"
|
||||
|
@ -45,7 +45,7 @@ NodeList [
|
|||
>
|
||||
<td
|
||||
class="euiTableRowCell euiTableRowCell--isMobileFullWidth"
|
||||
colspan="6"
|
||||
colspan="5"
|
||||
>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignCenter"
|
||||
|
@ -141,110 +141,33 @@ NodeList [
|
|||
>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 40%;"
|
||||
style="width: 96px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Name
|
||||
Health
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
<span
|
||||
class="euiToolTipAnchor"
|
||||
class="euiBadge euiBadge--iconLeft"
|
||||
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
|
||||
title="Unknown"
|
||||
>
|
||||
<a
|
||||
class="euiLink euiLink--primary c0"
|
||||
href="/basepath/app/apm/services/My Go Service/transactions"
|
||||
rel="noreferrer"
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
>
|
||||
My Go Service
|
||||
</a>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
>
|
||||
Unknown
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 20%;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Environment
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Agent
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
go
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Avg. response time
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
|
||||
>
|
||||
0.6 ms
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Trans. per minute
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
|
||||
>
|
||||
400.0 tpm
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Errors per minute
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
|
||||
>
|
||||
500.0 err.
|
||||
</div>
|
||||
</td>
|
||||
</tr>,
|
||||
.c0 {
|
||||
font-size: 16px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
<tr
|
||||
class="euiTableRow"
|
||||
>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 40%;"
|
||||
|
@ -260,19 +183,36 @@ NodeList [
|
|||
<span
|
||||
class="euiToolTipAnchor"
|
||||
>
|
||||
<a
|
||||
class="euiLink euiLink--primary c0"
|
||||
href="/basepath/app/apm/services/My Python Service/transactions"
|
||||
rel="noreferrer"
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
My Python Service
|
||||
</a>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<img
|
||||
alt="python"
|
||||
height="24px"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem"
|
||||
>
|
||||
<a
|
||||
class="euiLink euiLink--primary c0"
|
||||
href="/basepath/app/apm/services/My Python Service/transactions"
|
||||
rel="noreferrer"
|
||||
>
|
||||
My Python Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 20%;"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
|
@ -314,20 +254,7 @@ NodeList [
|
|||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Agent
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
python
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
|
@ -335,13 +262,52 @@ NodeList [
|
|||
Avg. response time
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
0.3 ms
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
color="subdued"
|
||||
data-euiicon-type="visLine"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small"
|
||||
>
|
||||
<div
|
||||
class="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
N/A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
style="white-space: nowrap;"
|
||||
>
|
||||
0 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
|
@ -349,23 +315,351 @@ NodeList [
|
|||
Trans. per minute
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
100.0 tpm
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
color="subdued"
|
||||
data-euiicon-type="visLine"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small"
|
||||
>
|
||||
<div
|
||||
class="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
N/A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
style="white-space: nowrap;"
|
||||
>
|
||||
0 tpm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Errors per minute
|
||||
Error rate %
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
200.0 err.
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
color="subdued"
|
||||
data-euiicon-type="visLine"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small"
|
||||
>
|
||||
<div
|
||||
class="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
N/A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
style="white-space: nowrap;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>,
|
||||
.c0 {
|
||||
font-size: 16px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
<tr
|
||||
class="euiTableRow"
|
||||
>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 96px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Health
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
<span
|
||||
class="euiBadge euiBadge--iconLeft"
|
||||
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
|
||||
title="Unknown"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__content"
|
||||
>
|
||||
<span
|
||||
class="euiBadge__text"
|
||||
>
|
||||
Unknown
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 40%;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Name
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
<span
|
||||
class="euiToolTipAnchor"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<img
|
||||
alt="go"
|
||||
height="24px"
|
||||
src="test-file-stub"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem"
|
||||
>
|
||||
<a
|
||||
class="euiLink euiLink--primary c0"
|
||||
href="/basepath/app/apm/services/My Go Service/transactions"
|
||||
rel="noreferrer"
|
||||
>
|
||||
My Go Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Environment
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Avg. response time
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
color="subdued"
|
||||
data-euiicon-type="visLine"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small"
|
||||
>
|
||||
<div
|
||||
class="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
N/A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
style="white-space: nowrap;"
|
||||
>
|
||||
0 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Trans. per minute
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
color="subdued"
|
||||
data-euiicon-type="visLine"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small"
|
||||
>
|
||||
<div
|
||||
class="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
N/A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
style="white-space: nowrap;"
|
||||
>
|
||||
0 tpm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="euiTableRowCell"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<div
|
||||
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
|
||||
>
|
||||
Error rate %
|
||||
</div>
|
||||
<div
|
||||
class="euiTableCellContent euiTableCellContent--overflowingContent"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
color="subdued"
|
||||
data-euiicon-type="visLine"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiText euiText--small"
|
||||
>
|
||||
<div
|
||||
class="euiTextColor euiTextColor--subdued"
|
||||
>
|
||||
N/A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
style="white-space: nowrap;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>,
|
||||
|
|
|
@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import url from 'url';
|
||||
import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import { useFetcher } from '../../../hooks/useFetcher';
|
||||
import { useFetcher, FETCH_STATUS } from '../../../hooks/useFetcher';
|
||||
import { NoServicesMessage } from './NoServicesMessage';
|
||||
import { ServiceList } from './ServiceList';
|
||||
import { useUrlParams } from '../../../hooks/useUrlParams';
|
||||
|
@ -18,8 +18,11 @@ import { useTrackPageview } from '../../../../../observability/public';
|
|||
import { Projection } from '../../../../common/projections';
|
||||
import { LocalUIFilters } from '../../shared/LocalUIFilters';
|
||||
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
|
||||
import { MLCallout } from './ServiceList/MLCallout';
|
||||
import { useLocalStorage } from '../../../hooks/useLocalStorage';
|
||||
import { useAnomalyDetectionJobs } from '../../../hooks/useAnomalyDetectionJobs';
|
||||
|
||||
const initalData = {
|
||||
const initialData = {
|
||||
items: [],
|
||||
hasHistoricalData: true,
|
||||
hasLegacyData: false,
|
||||
|
@ -33,7 +36,7 @@ export function ServiceOverview() {
|
|||
urlParams: { start, end },
|
||||
uiFilters,
|
||||
} = useUrlParams();
|
||||
const { data = initalData, status } = useFetcher(
|
||||
const { data = initialData, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (start && end) {
|
||||
return callApmApi({
|
||||
|
@ -93,6 +96,26 @@ export function ServiceOverview() {
|
|||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: anomalyDetectionJobsData,
|
||||
status: anomalyDetectionJobsStatus,
|
||||
} = useAnomalyDetectionJobs();
|
||||
|
||||
const [userHasDismissedCallout, setUserHasDismissedCallout] = useLocalStorage(
|
||||
'apm.userHasDismissedServiceInventoryMlCallout',
|
||||
false
|
||||
);
|
||||
|
||||
const canCreateJob = !!core.application.capabilities.ml?.canCreateJob;
|
||||
|
||||
const displayMlCallout =
|
||||
anomalyDetectionJobsStatus === FETCH_STATUS.SUCCESS &&
|
||||
!anomalyDetectionJobsData?.jobs.length &&
|
||||
canCreateJob &&
|
||||
!userHasDismissedCallout;
|
||||
|
||||
const displayHealthStatus = data.items.some((item) => 'severity' in item);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
|
@ -101,17 +124,27 @@ export function ServiceOverview() {
|
|||
<LocalUIFilters {...localFiltersConfig} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={7}>
|
||||
<EuiPanel>
|
||||
<ServiceList
|
||||
items={data.items}
|
||||
noItemsMessage={
|
||||
<NoServicesMessage
|
||||
historicalDataFound={data.hasHistoricalData}
|
||||
status={status}
|
||||
<EuiFlexGroup direction="column">
|
||||
{displayMlCallout ? (
|
||||
<EuiFlexItem>
|
||||
<MLCallout onDismiss={() => setUserHasDismissedCallout(true)} />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
<EuiFlexItem>
|
||||
<EuiPanel>
|
||||
<ServiceList
|
||||
items={data.items}
|
||||
displayHealthStatus={displayHealthStatus}
|
||||
noItemsMessage={
|
||||
<NoServicesMessage
|
||||
historicalDataFound={data.hasHistoricalData}
|
||||
status={status}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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 { getNormalizedAgentName } from '../../../../common/agent_name';
|
||||
import dotNetIcon from './icons/dot-net.svg';
|
||||
import goIcon from './icons/go.svg';
|
||||
import javaIcon from './icons/java.svg';
|
||||
import nodeJsIcon from './icons/nodejs.svg';
|
||||
import phpIcon from './icons/php.svg';
|
||||
import pythonIcon from './icons/python.svg';
|
||||
import rubyIcon from './icons/ruby.svg';
|
||||
import rumJsIcon from './icons/rumjs.svg';
|
||||
|
||||
const agentIcons: { [key: string]: string } = {
|
||||
dotnet: dotNetIcon,
|
||||
go: goIcon,
|
||||
java: javaIcon,
|
||||
'js-base': rumJsIcon,
|
||||
nodejs: nodeJsIcon,
|
||||
php: phpIcon,
|
||||
python: pythonIcon,
|
||||
ruby: rubyIcon,
|
||||
};
|
||||
|
||||
export function getAgentIcon(agentName?: string) {
|
||||
const normalizedAgentName = getNormalizedAgentName(agentName);
|
||||
return normalizedAgentName && agentIcons[normalizedAgentName];
|
||||
}
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 642 B |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 686 B After Width: | Height: | Size: 686 B |
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
|
||||
import { getAgentIcon } from './get_agent_icon';
|
||||
import { px } from '../../../style/variables';
|
||||
|
||||
interface Props {
|
||||
agentName: AgentName;
|
||||
}
|
||||
|
||||
export function AgentIcon(props: Props) {
|
||||
const { agentName } = props;
|
||||
|
||||
const icon = getAgentIcon(agentName);
|
||||
|
||||
return <img src={icon} height={px(24)} alt={agentName} />;
|
||||
}
|
|
@ -33,9 +33,22 @@ interface Props<T> {
|
|||
hidePerPageOptions?: boolean;
|
||||
noItemsMessage?: React.ReactNode;
|
||||
sortItems?: boolean;
|
||||
sortFn?: (
|
||||
items: T[],
|
||||
sortField: string,
|
||||
sortDirection: 'asc' | 'desc'
|
||||
) => T[];
|
||||
pagination?: boolean;
|
||||
}
|
||||
|
||||
function defaultSortFn<T extends any>(
|
||||
items: T[],
|
||||
sortField: string,
|
||||
sortDirection: 'asc' | 'desc'
|
||||
) {
|
||||
return orderBy(items, sortField, sortDirection);
|
||||
}
|
||||
|
||||
function UnoptimizedManagedTable<T>(props: Props<T>) {
|
||||
const history = useHistory();
|
||||
const {
|
||||
|
@ -48,6 +61,7 @@ function UnoptimizedManagedTable<T>(props: Props<T>) {
|
|||
hidePerPageOptions = true,
|
||||
noItemsMessage,
|
||||
sortItems = true,
|
||||
sortFn = defaultSortFn,
|
||||
pagination = true,
|
||||
} = props;
|
||||
|
||||
|
@ -62,11 +76,11 @@ function UnoptimizedManagedTable<T>(props: Props<T>) {
|
|||
|
||||
const renderedItems = useMemo(() => {
|
||||
const sortedItems = sortItems
|
||||
? orderBy(items, sortField, sortDirection as 'asc' | 'desc')
|
||||
? sortFn(items, sortField, sortDirection as 'asc' | 'desc')
|
||||
: items;
|
||||
|
||||
return sortedItems.slice(page * pageSize, (page + 1) * pageSize);
|
||||
}, [page, pageSize, sortField, sortDirection, items, sortItems]);
|
||||
}, [page, pageSize, sortField, sortDirection, items, sortItems, sortFn]);
|
||||
|
||||
const sort = useMemo(() => {
|
||||
return {
|
||||
|
|
|
@ -8,9 +8,11 @@ import React from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui';
|
||||
import { getSeverityColor } from '../../app/ServiceMap/cytoscapeOptions';
|
||||
import {
|
||||
getSeverityColor,
|
||||
Severity,
|
||||
} from '../../../../common/anomaly_detection';
|
||||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import { severity as Severity } from '../../app/ServiceMap/Popover/getSeverity';
|
||||
|
||||
type SeverityScore = 0 | 25 | 50 | 75;
|
||||
const ANOMALY_SCORES: SeverityScore[] = [0, 25, 50, 75];
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { ScaleType, Chart, Settings, AreaSeries } from '@elastic/charts';
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import { EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { px } from '../../../../style/variables';
|
||||
import { useChartTheme } from '../../../../../../observability/public';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
|
||||
|
||||
interface Props {
|
||||
color: string;
|
||||
series: Array<{ x: number; y: number | null }>;
|
||||
}
|
||||
|
||||
export function SparkPlot(props: Props) {
|
||||
const { series, color } = props;
|
||||
const chartTheme = useChartTheme();
|
||||
|
||||
const isEmpty = series.every((point) => point.y === null);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="visLine" color="subdued" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s" color="subdued">
|
||||
{NOT_AVAILABLE_LABEL}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Chart size={{ height: px(24), width: px(64) }}>
|
||||
<Settings
|
||||
theme={{
|
||||
...chartTheme,
|
||||
background: {
|
||||
...chartTheme.background,
|
||||
color: 'transparent',
|
||||
},
|
||||
}}
|
||||
showLegend={false}
|
||||
tooltip="none"
|
||||
/>
|
||||
<AreaSeries
|
||||
id="area"
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor={'x'}
|
||||
yAccessors={['y']}
|
||||
data={series}
|
||||
color={color}
|
||||
/>
|
||||
</Chart>
|
||||
);
|
||||
}
|
18
x-pack/plugins/apm/public/hooks/useAnomalyDetectionJobs.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 { useFetcher } from './useFetcher';
|
||||
|
||||
export function useAnomalyDetectionJobs() {
|
||||
return useFetcher(
|
||||
(callApmApi) =>
|
||||
callApmApi({
|
||||
pathname: `/api/apm/settings/anomaly-detection`,
|
||||
}),
|
||||
[],
|
||||
{ showToastOnError: false }
|
||||
);
|
||||
}
|
54
x-pack/plugins/apm/public/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||
const [item, setItem] = useState<T>(getFromStorage());
|
||||
|
||||
function getFromStorage() {
|
||||
const storedItem = window.localStorage.getItem(key);
|
||||
|
||||
let toStore: T = defaultValue;
|
||||
|
||||
if (storedItem !== null) {
|
||||
try {
|
||||
toStore = JSON.parse(storedItem) as T;
|
||||
} catch (err) {
|
||||
window.localStorage.removeItem(key);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Unable to decode: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
return toStore;
|
||||
}
|
||||
|
||||
const updateFromStorage = () => {
|
||||
const storedItem = getFromStorage();
|
||||
setItem(storedItem);
|
||||
};
|
||||
|
||||
const saveToStorage = (value: T) => {
|
||||
if (value === undefined) {
|
||||
window.localStorage.removeItem(key);
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
updateFromStorage();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('storage', (event: StorageEvent) => {
|
||||
if (event.key === key) {
|
||||
updateFromStorage();
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [item, saveToStorage] as const;
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"include": [
|
||||
"./**/*"
|
||||
"./**/*",
|
||||
"../observability"
|
||||
],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
|
|
|
@ -81,6 +81,11 @@ export function registerTransactionDurationAnomalyAlertType({
|
|||
anomalyDetectors,
|
||||
alertParams.environment
|
||||
);
|
||||
|
||||
if (mlJobIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const anomalySearchParams = {
|
||||
body: {
|
||||
size: 0,
|
||||
|
|
|
@ -7,22 +7,23 @@
|
|||
import moment from 'moment';
|
||||
// @ts-expect-error
|
||||
import { calculateAuto } from './calculate_auto';
|
||||
// @ts-expect-error
|
||||
import { unitToSeconds } from './unit_to_seconds';
|
||||
|
||||
export function getBucketSize(start: number, end: number, interval: string) {
|
||||
export function getBucketSize(
|
||||
start: number,
|
||||
end: number,
|
||||
numBuckets: number = 100
|
||||
) {
|
||||
const duration = moment.duration(end - start, 'ms');
|
||||
const bucketSize = Math.max(calculateAuto.near(100, duration).asSeconds(), 1);
|
||||
const bucketSize = Math.max(
|
||||
calculateAuto.near(numBuckets, duration).asSeconds(),
|
||||
1
|
||||
);
|
||||
const intervalString = `${bucketSize}s`;
|
||||
const matches = interval && interval.match(/^([\d]+)([shmdwMy]|ms)$/);
|
||||
const minBucketSize = matches
|
||||
? Number(matches[1]) * unitToSeconds(matches[2])
|
||||
: 0;
|
||||
|
||||
if (bucketSize < minBucketSize) {
|
||||
if (bucketSize < 0) {
|
||||
return {
|
||||
bucketSize: minBucketSize,
|
||||
intervalString: interval,
|
||||
bucketSize: 0,
|
||||
intervalString: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ export function getMetricsDateHistogramParams(
|
|||
end: number,
|
||||
metricsInterval: number
|
||||
) {
|
||||
const { bucketSize } = getBucketSize(start, end, 'auto');
|
||||
const { bucketSize } = getBucketSize(start, end);
|
||||
return {
|
||||
field: '@timestamp',
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import { isActivePlatinumLicense } from '../../../common/service_map';
|
||||
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
|
||||
import { KibanaRequest } from '../../../../../../src/core/server';
|
||||
import { APMConfig } from '../..';
|
||||
|
@ -98,11 +99,14 @@ export async function setupRequest<TParams extends SetupRequestParams>(
|
|||
context,
|
||||
request,
|
||||
}),
|
||||
ml: getMlSetup(
|
||||
context.plugins.ml,
|
||||
context.core.savedObjects.client,
|
||||
request
|
||||
),
|
||||
ml:
|
||||
context.plugins.ml && isActivePlatinumLicense(context.licensing.license)
|
||||
? getMlSetup(
|
||||
context.plugins.ml,
|
||||
context.core.savedObjects.client,
|
||||
request
|
||||
)
|
||||
: undefined,
|
||||
config,
|
||||
};
|
||||
|
||||
|
@ -115,14 +119,10 @@ export async function setupRequest<TParams extends SetupRequestParams>(
|
|||
}
|
||||
|
||||
function getMlSetup(
|
||||
ml: APMRequestHandlerContext['plugins']['ml'],
|
||||
ml: Required<APMRequestHandlerContext['plugins']>['ml'],
|
||||
savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'],
|
||||
request: KibanaRequest
|
||||
) {
|
||||
if (!ml) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
mlSystem: ml.mlSystemProvider(request),
|
||||
anomalyDetectors: ml.anomalyDetectorsProvider(request),
|
||||
|
|
|
@ -44,7 +44,7 @@ export async function fetchAndTransformGcMetrics({
|
|||
}) {
|
||||
const { start, end, apmEventClient, config } = setup;
|
||||
|
||||
const { bucketSize } = getBucketSize(start, end, 'auto');
|
||||
const { bucketSize } = getBucketSize(start, end);
|
||||
|
||||
const projection = getMetricsProjection({
|
||||
setup,
|
||||
|
@ -74,7 +74,7 @@ export async function fetchAndTransformGcMetrics({
|
|||
field: `${LABEL_NAME}`,
|
||||
},
|
||||
aggs: {
|
||||
over_time: {
|
||||
timeseries: {
|
||||
date_histogram: getMetricsDateHistogramParams(
|
||||
start,
|
||||
end,
|
||||
|
@ -123,7 +123,7 @@ export async function fetchAndTransformGcMetrics({
|
|||
|
||||
const series = aggregations.per_pool.buckets.map((poolBucket, i) => {
|
||||
const label = poolBucket.key as string;
|
||||
const timeseriesData = poolBucket.over_time;
|
||||
const timeseriesData = poolBucket.timeseries;
|
||||
|
||||
const data = timeseriesData.buckets.map((bucket) => {
|
||||
// derivative/value will be undefined for the first hit and if the `max` value is null
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { Logger } from 'kibana/server';
|
||||
import Boom from 'boom';
|
||||
import { Setup, SetupTimeRange } from '../helpers/setup_request';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
|
@ -27,11 +26,9 @@ export type ServiceAnomaliesResponse = PromiseReturnType<
|
|||
|
||||
export async function getServiceAnomalies({
|
||||
setup,
|
||||
logger,
|
||||
environment,
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange;
|
||||
logger: Logger;
|
||||
environment?: string;
|
||||
}) {
|
||||
const { ml, start, end } = setup;
|
||||
|
@ -41,11 +38,20 @@ export async function getServiceAnomalies({
|
|||
}
|
||||
|
||||
const mlCapabilities = await ml.mlSystem.mlCapabilities();
|
||||
|
||||
if (!mlCapabilities.mlFeatureEnabledInSpace) {
|
||||
throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE);
|
||||
}
|
||||
|
||||
const mlJobIds = await getMLJobIds(ml.anomalyDetectors, environment);
|
||||
|
||||
if (!mlJobIds.length) {
|
||||
return {
|
||||
mlJobIds: [],
|
||||
serviceAnomalies: {},
|
||||
};
|
||||
}
|
||||
|
||||
const params = {
|
||||
body: {
|
||||
size: 0,
|
||||
|
@ -120,7 +126,9 @@ interface ServiceAnomaliesAggResponse {
|
|||
function transformResponseToServiceAnomalies(
|
||||
response: ServiceAnomaliesAggResponse
|
||||
): Record<string, ServiceAnomalyStats> {
|
||||
const serviceAnomaliesMap = response.aggregations.services.buckets.reduce(
|
||||
const serviceAnomaliesMap = (
|
||||
response.aggregations?.services.buckets ?? []
|
||||
).reduce(
|
||||
(statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => {
|
||||
return {
|
||||
...statsByServiceName,
|
||||
|
@ -153,7 +161,7 @@ export async function getMLJobIds(
|
|||
(job) => job.custom_settings?.job_tags?.environment === environment
|
||||
);
|
||||
if (!matchingMLJob) {
|
||||
throw new Error(`ML job Not Found for environment "${environment}".`);
|
||||
return [];
|
||||
}
|
||||
return [matchingMLJob.job_id];
|
||||
}
|
||||
|
|
|
@ -58,6 +58,9 @@ describe('getServiceMapServiceNodeInfo', () => {
|
|||
indices: {},
|
||||
start: 1593460053026000,
|
||||
end: 1593497863217000,
|
||||
config: {
|
||||
'xpack.apm.metricsInterval': 30,
|
||||
},
|
||||
} as unknown) as Setup & SetupTimeRange;
|
||||
const environment = 'test environment';
|
||||
const serviceName = 'test service name';
|
||||
|
|
|
@ -105,6 +105,24 @@ Array [
|
|||
"field": "transaction.duration.us",
|
||||
},
|
||||
},
|
||||
"timeseries": Object {
|
||||
"aggs": Object {
|
||||
"average": Object {
|
||||
"avg": Object {
|
||||
"field": "transaction.duration.us",
|
||||
},
|
||||
},
|
||||
},
|
||||
"date_histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 1528977600000,
|
||||
"min": 1528113600000,
|
||||
},
|
||||
"field": "@timestamp",
|
||||
"fixed_interval": "43200s",
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"terms": Object {
|
||||
"field": "service.name",
|
||||
|
@ -194,6 +212,19 @@ Array [
|
|||
"body": Object {
|
||||
"aggs": Object {
|
||||
"services": Object {
|
||||
"aggs": Object {
|
||||
"timeseries": Object {
|
||||
"date_histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 1528977600000,
|
||||
"min": 1528113600000,
|
||||
},
|
||||
"field": "@timestamp",
|
||||
"fixed_interval": "43200s",
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"terms": Object {
|
||||
"field": "service.name",
|
||||
"size": 500,
|
||||
|
@ -226,12 +257,37 @@ Array [
|
|||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"error",
|
||||
"transaction",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"services": Object {
|
||||
"aggs": Object {
|
||||
"outcomes": Object {
|
||||
"terms": Object {
|
||||
"field": "event.outcome",
|
||||
},
|
||||
},
|
||||
"timeseries": Object {
|
||||
"aggs": Object {
|
||||
"outcomes": Object {
|
||||
"terms": Object {
|
||||
"field": "event.outcome",
|
||||
},
|
||||
},
|
||||
},
|
||||
"date_histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 1528977600000,
|
||||
"min": 1528113600000,
|
||||
},
|
||||
"field": "@timestamp",
|
||||
"fixed_interval": "43200s",
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"terms": Object {
|
||||
"field": "service.name",
|
||||
"size": 500,
|
||||
|
@ -255,6 +311,14 @@ Array [
|
|||
"my.custom.ui.filter": "foo-bar",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"terms": Object {
|
||||
"event.outcome": Array [
|
||||
"failure",
|
||||
"success",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -15,15 +15,22 @@ import {
|
|||
getTransactionDurationAverages,
|
||||
getAgentNames,
|
||||
getTransactionRates,
|
||||
getErrorRates,
|
||||
getTransactionErrorRates,
|
||||
getEnvironments,
|
||||
getHealthStatuses,
|
||||
} from './get_services_items_stats';
|
||||
|
||||
export type ServiceListAPIResponse = PromiseReturnType<typeof getServicesItems>;
|
||||
export type ServicesItemsSetup = Setup & SetupTimeRange & SetupUIFilters;
|
||||
export type ServicesItemsProjection = ReturnType<typeof getServicesProjection>;
|
||||
|
||||
export async function getServicesItems(setup: ServicesItemsSetup) {
|
||||
export async function getServicesItems({
|
||||
setup,
|
||||
mlAnomaliesEnvironment,
|
||||
}: {
|
||||
setup: ServicesItemsSetup;
|
||||
mlAnomaliesEnvironment?: string;
|
||||
}) {
|
||||
const params = {
|
||||
projection: getServicesProjection({ setup }),
|
||||
setup,
|
||||
|
@ -33,22 +40,25 @@ export async function getServicesItems(setup: ServicesItemsSetup) {
|
|||
transactionDurationAverages,
|
||||
agentNames,
|
||||
transactionRates,
|
||||
errorRates,
|
||||
transactionErrorRates,
|
||||
environments,
|
||||
healthStatuses,
|
||||
] = await Promise.all([
|
||||
getTransactionDurationAverages(params),
|
||||
getAgentNames(params),
|
||||
getTransactionRates(params),
|
||||
getErrorRates(params),
|
||||
getTransactionErrorRates(params),
|
||||
getEnvironments(params),
|
||||
getHealthStatuses(params, mlAnomaliesEnvironment),
|
||||
]);
|
||||
|
||||
const allMetrics = [
|
||||
...transactionDurationAverages,
|
||||
...agentNames,
|
||||
...transactionRates,
|
||||
...errorRates,
|
||||
...transactionErrorRates,
|
||||
...environments,
|
||||
...healthStatuses,
|
||||
];
|
||||
|
||||
return joinByKey(allMetrics, 'serviceName');
|
||||
|
|
|
@ -4,10 +4,14 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EventOutcome } from '../../../../common/event_outcome';
|
||||
import { getSeverity } from '../../../../common/anomaly_detection';
|
||||
import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent';
|
||||
import {
|
||||
TRANSACTION_DURATION,
|
||||
AGENT_NAME,
|
||||
SERVICE_ENVIRONMENT,
|
||||
EVENT_OUTCOME,
|
||||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import { mergeProjection } from '../../../projections/util/merge_projection';
|
||||
import { ProcessorEvent } from '../../../../common/processor_event';
|
||||
|
@ -15,6 +19,21 @@ import {
|
|||
ServicesItemsSetup,
|
||||
ServicesItemsProjection,
|
||||
} from './get_services_items';
|
||||
import { getBucketSize } from '../../helpers/get_bucket_size';
|
||||
import {
|
||||
getMLJobIds,
|
||||
getServiceAnomalies,
|
||||
} from '../../service_map/get_service_anomalies';
|
||||
import { AggregationResultOf } from '../../../../typings/elasticsearch/aggregations';
|
||||
|
||||
function getDateHistogramOpts(start: number, end: number) {
|
||||
return {
|
||||
field: '@timestamp',
|
||||
fixed_interval: getBucketSize(start, end, 20).intervalString,
|
||||
min_doc_count: 0,
|
||||
extended_bounds: { min: start, max: end },
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_NUMBER_OF_SERVICES = 500;
|
||||
|
||||
|
@ -30,7 +49,7 @@ export const getTransactionDurationAverages = async ({
|
|||
setup,
|
||||
projection,
|
||||
}: AggregationParams) => {
|
||||
const { apmEventClient } = setup;
|
||||
const { apmEventClient, start, end } = setup;
|
||||
|
||||
const response = await apmEventClient.search(
|
||||
mergeProjection(projection, {
|
||||
|
@ -51,6 +70,16 @@ export const getTransactionDurationAverages = async ({
|
|||
field: TRANSACTION_DURATION,
|
||||
},
|
||||
},
|
||||
timeseries: {
|
||||
date_histogram: getDateHistogramOpts(start, end),
|
||||
aggs: {
|
||||
average: {
|
||||
avg: {
|
||||
field: TRANSACTION_DURATION,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -64,9 +93,15 @@ export const getTransactionDurationAverages = async ({
|
|||
return [];
|
||||
}
|
||||
|
||||
return aggregations.services.buckets.map((bucket) => ({
|
||||
serviceName: bucket.key as string,
|
||||
avgResponseTime: bucket.average.value,
|
||||
return aggregations.services.buckets.map((serviceBucket) => ({
|
||||
serviceName: serviceBucket.key as string,
|
||||
avgResponseTime: {
|
||||
value: serviceBucket.average.value,
|
||||
timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({
|
||||
x: dateBucket.key,
|
||||
y: dateBucket.average.value,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
|
@ -112,9 +147,10 @@ export const getAgentNames = async ({
|
|||
return [];
|
||||
}
|
||||
|
||||
return aggregations.services.buckets.map((bucket) => ({
|
||||
serviceName: bucket.key as string,
|
||||
agentName: bucket.agent_name.hits.hits[0]?._source.agent.name,
|
||||
return aggregations.services.buckets.map((serviceBucket) => ({
|
||||
serviceName: serviceBucket.key as string,
|
||||
agentName: serviceBucket.agent_name.hits.hits[0]?._source.agent
|
||||
.name as AgentName,
|
||||
}));
|
||||
};
|
||||
|
||||
|
@ -122,7 +158,7 @@ export const getTransactionRates = async ({
|
|||
setup,
|
||||
projection,
|
||||
}: AggregationParams) => {
|
||||
const { apmEventClient } = setup;
|
||||
const { apmEventClient, start, end } = setup;
|
||||
const response = await apmEventClient.search(
|
||||
mergeProjection(projection, {
|
||||
apm: {
|
||||
|
@ -136,46 +172,10 @@ export const getTransactionRates = async ({
|
|||
...projection.body.aggs.services.terms,
|
||||
size: MAX_NUMBER_OF_SERVICES,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { aggregations } = response;
|
||||
|
||||
if (!aggregations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deltaAsMinutes = getDeltaAsMinutes(setup);
|
||||
|
||||
return aggregations.services.buckets.map((bucket) => {
|
||||
const transactionsPerMinute = bucket.doc_count / deltaAsMinutes;
|
||||
return {
|
||||
serviceName: bucket.key as string,
|
||||
transactionsPerMinute,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getErrorRates = async ({
|
||||
setup,
|
||||
projection,
|
||||
}: AggregationParams) => {
|
||||
const { apmEventClient } = setup;
|
||||
const response = await apmEventClient.search(
|
||||
mergeProjection(projection, {
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
size: 0,
|
||||
aggs: {
|
||||
services: {
|
||||
terms: {
|
||||
...projection.body.aggs.services.terms,
|
||||
size: MAX_NUMBER_OF_SERVICES,
|
||||
aggs: {
|
||||
timeseries: {
|
||||
date_histogram: getDateHistogramOpts(start, end),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -191,11 +191,109 @@ export const getErrorRates = async ({
|
|||
|
||||
const deltaAsMinutes = getDeltaAsMinutes(setup);
|
||||
|
||||
return aggregations.services.buckets.map((bucket) => {
|
||||
const errorsPerMinute = bucket.doc_count / deltaAsMinutes;
|
||||
return aggregations.services.buckets.map((serviceBucket) => {
|
||||
const transactionsPerMinute = serviceBucket.doc_count / deltaAsMinutes;
|
||||
return {
|
||||
serviceName: bucket.key as string,
|
||||
errorsPerMinute,
|
||||
serviceName: serviceBucket.key as string,
|
||||
transactionsPerMinute: {
|
||||
value: transactionsPerMinute,
|
||||
timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => ({
|
||||
x: dateBucket.key,
|
||||
y: dateBucket.doc_count / deltaAsMinutes,
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getTransactionErrorRates = async ({
|
||||
setup,
|
||||
projection,
|
||||
}: AggregationParams) => {
|
||||
const { apmEventClient, start, end } = setup;
|
||||
|
||||
const outcomes = {
|
||||
terms: {
|
||||
field: EVENT_OUTCOME,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await apmEventClient.search(
|
||||
mergeProjection(projection, {
|
||||
apm: {
|
||||
events: [ProcessorEvent.transaction],
|
||||
},
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...projection.body.query.bool.filter,
|
||||
{
|
||||
terms: {
|
||||
[EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
services: {
|
||||
terms: {
|
||||
...projection.body.aggs.services.terms,
|
||||
size: MAX_NUMBER_OF_SERVICES,
|
||||
},
|
||||
aggs: {
|
||||
outcomes,
|
||||
timeseries: {
|
||||
date_histogram: getDateHistogramOpts(start, end),
|
||||
aggs: {
|
||||
outcomes,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { aggregations } = response;
|
||||
|
||||
if (!aggregations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function calculateTransactionErrorPercentage(
|
||||
outcomeResponse: AggregationResultOf<typeof outcomes, {}>
|
||||
) {
|
||||
const successfulTransactions =
|
||||
outcomeResponse.buckets.find(
|
||||
(bucket) => bucket.key === EventOutcome.success
|
||||
)?.doc_count ?? 0;
|
||||
const failedTransactions =
|
||||
outcomeResponse.buckets.find(
|
||||
(bucket) => bucket.key === EventOutcome.failure
|
||||
)?.doc_count ?? 0;
|
||||
|
||||
return failedTransactions / (successfulTransactions + failedTransactions);
|
||||
}
|
||||
|
||||
return aggregations.services.buckets.map((serviceBucket) => {
|
||||
const transactionErrorRate = calculateTransactionErrorPercentage(
|
||||
serviceBucket.outcomes
|
||||
);
|
||||
return {
|
||||
serviceName: serviceBucket.key as string,
|
||||
transactionErrorRate: {
|
||||
value: transactionErrorRate,
|
||||
timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => {
|
||||
return {
|
||||
x: dateBucket.key,
|
||||
y: calculateTransactionErrorPercentage(dateBucket.outcomes),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
@ -241,8 +339,43 @@ export const getEnvironments = async ({
|
|||
return [];
|
||||
}
|
||||
|
||||
return aggregations.services.buckets.map((bucket) => ({
|
||||
serviceName: bucket.key as string,
|
||||
environments: bucket.environments.buckets.map((env) => env.key as string),
|
||||
return aggregations.services.buckets.map((serviceBucket) => ({
|
||||
serviceName: serviceBucket.key as string,
|
||||
environments: serviceBucket.environments.buckets.map(
|
||||
(envBucket) => envBucket.key as string
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getHealthStatuses = async (
|
||||
{ setup }: AggregationParams,
|
||||
mlAnomaliesEnvironment?: string
|
||||
) => {
|
||||
if (!setup.ml) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const jobIds = await getMLJobIds(
|
||||
setup.ml.anomalyDetectors,
|
||||
mlAnomaliesEnvironment
|
||||
);
|
||||
if (!jobIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const anomalies = await getServiceAnomalies({
|
||||
setup,
|
||||
environment: mlAnomaliesEnvironment,
|
||||
});
|
||||
|
||||
return Object.keys(anomalies.serviceAnomalies).map((serviceName) => {
|
||||
const stats = anomalies.serviceAnomalies[serviceName];
|
||||
|
||||
const severity = getSeverity(stats.anomalyScore);
|
||||
|
||||
return {
|
||||
serviceName,
|
||||
severity,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
|
@ -17,11 +17,15 @@ import { getServicesItems } from './get_services_items';
|
|||
|
||||
export type ServiceListAPIResponse = PromiseReturnType<typeof getServices>;
|
||||
|
||||
export async function getServices(
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters
|
||||
) {
|
||||
export async function getServices({
|
||||
setup,
|
||||
mlAnomaliesEnvironment,
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
mlAnomaliesEnvironment?: string;
|
||||
}) {
|
||||
const [items, hasLegacyData] = await Promise.all([
|
||||
getServicesItems(setup),
|
||||
getServicesItems({ setup, mlAnomaliesEnvironment }),
|
||||
getLegacyDataStatus(setup),
|
||||
]);
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ describe('services queries', () => {
|
|||
});
|
||||
|
||||
it('fetches the service items', async () => {
|
||||
mock = await inspectSearchParams((setup) => getServicesItems(setup));
|
||||
mock = await inspectSearchParams((setup) => getServicesItems({ setup }));
|
||||
|
||||
const allParams = mock.spy.mock.calls.map((call) => call[0]);
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ export async function getErrorRate({
|
|||
total_transactions: {
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: getBucketSize(start, end, 'auto').intervalString,
|
||||
fixed_interval: getBucketSize(start, end).intervalString,
|
||||
min_doc_count: 0,
|
||||
extended_bounds: { min: start, max: end },
|
||||
},
|
||||
|
|
|
@ -24,7 +24,7 @@ export type ESResponse = PromiseReturnType<typeof fetcher>;
|
|||
export function fetcher(options: Options) {
|
||||
const { end, apmEventClient, start, uiFiltersES } = options.setup;
|
||||
const { serviceName, transactionName } = options;
|
||||
const { intervalString } = getBucketSize(start, end, 'auto');
|
||||
const { intervalString } = getBucketSize(start, end);
|
||||
|
||||
const transactionNameFilter = transactionName
|
||||
? [{ term: { [TRANSACTION_NAME]: transactionName } }]
|
||||
|
|
|
@ -64,16 +64,10 @@ export async function getAnomalySeries({
|
|||
return;
|
||||
}
|
||||
|
||||
let mlJobIds: string[] = [];
|
||||
try {
|
||||
mlJobIds = await getMLJobIds(
|
||||
setup.ml.anomalyDetectors,
|
||||
uiFilters.environment
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return;
|
||||
}
|
||||
const mlJobIds = await getMLJobIds(
|
||||
setup.ml.anomalyDetectors,
|
||||
uiFilters.environment
|
||||
);
|
||||
|
||||
// don't fetch anomalies if there are isn't exaclty 1 ML job match for the given environment
|
||||
if (mlJobIds.length !== 1) {
|
||||
|
@ -87,7 +81,7 @@ export async function getAnomalySeries({
|
|||
}
|
||||
|
||||
const { start, end } = setup;
|
||||
const { intervalString, bucketSize } = getBucketSize(start, end, 'auto');
|
||||
const { intervalString, bucketSize } = getBucketSize(start, end);
|
||||
|
||||
const esResponse = await anomalySeriesFetcher({
|
||||
serviceName,
|
||||
|
|
|
@ -35,7 +35,7 @@ export function timeseriesFetcher({
|
|||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
}) {
|
||||
const { start, end, uiFiltersES, apmEventClient } = setup;
|
||||
const { intervalString } = getBucketSize(start, end, 'auto');
|
||||
const { intervalString } = getBucketSize(start, end);
|
||||
|
||||
const filter: ESFilter[] = [
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
|
|
|
@ -20,7 +20,7 @@ export async function getApmTimeseriesData(options: {
|
|||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
}) {
|
||||
const { start, end } = options.setup;
|
||||
const { bucketSize } = getBucketSize(start, end, 'auto');
|
||||
const { bucketSize } = getBucketSize(start, end);
|
||||
const durationAsMinutes = (end - start) / 1000 / 60;
|
||||
|
||||
const timeseriesResponse = await timeseriesFetcher(options);
|
||||
|
|
|
@ -8,7 +8,7 @@ import Boom from 'boom';
|
|||
import * as t from 'io-ts';
|
||||
import {
|
||||
invalidLicenseMessage,
|
||||
isValidPlatinumLicense,
|
||||
isActivePlatinumLicense,
|
||||
} from '../../common/service_map';
|
||||
import { setupRequest } from '../lib/helpers/setup_request';
|
||||
import { getServiceMap } from '../lib/service_map/get_service_map';
|
||||
|
@ -33,7 +33,7 @@ export const serviceMapRoute = createRoute(() => ({
|
|||
if (!context.config['xpack.apm.serviceMapEnabled']) {
|
||||
throw Boom.notFound();
|
||||
}
|
||||
if (!isValidPlatinumLicense(context.licensing.license)) {
|
||||
if (!isActivePlatinumLicense(context.licensing.license)) {
|
||||
throw Boom.forbidden(invalidLicenseMessage);
|
||||
}
|
||||
context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME);
|
||||
|
@ -59,7 +59,7 @@ export const serviceMapServiceNodeRoute = createRoute(() => ({
|
|||
if (!context.config['xpack.apm.serviceMapEnabled']) {
|
||||
throw Boom.notFound();
|
||||
}
|
||||
if (!isValidPlatinumLicense(context.licensing.license)) {
|
||||
if (!isActivePlatinumLicense(context.licensing.license)) {
|
||||
throw Boom.forbidden(invalidLicenseMessage);
|
||||
}
|
||||
const logger = context.logger;
|
||||
|
|
|
@ -16,6 +16,7 @@ import { createRoute } from './create_route';
|
|||
import { uiFiltersRt, rangeRt } from './default_api_types';
|
||||
import { getServiceAnnotations } from '../lib/services/annotations';
|
||||
import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt';
|
||||
import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters';
|
||||
|
||||
export const servicesRoute = createRoute(() => ({
|
||||
path: '/api/apm/services',
|
||||
|
@ -23,8 +24,17 @@ export const servicesRoute = createRoute(() => ({
|
|||
query: t.intersection([uiFiltersRt, rangeRt]),
|
||||
},
|
||||
handler: async ({ context, request }) => {
|
||||
const { environment } = getParsedUiFilters({
|
||||
uiFilters: context.params.query.uiFilters,
|
||||
logger: context.logger,
|
||||
});
|
||||
|
||||
const setup = await setupRequest(context, request);
|
||||
const services = await getServices(setup);
|
||||
|
||||
const services = await getServices({
|
||||
setup,
|
||||
mlAnomaliesEnvironment: environment,
|
||||
});
|
||||
|
||||
return services;
|
||||
},
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import * as t from 'io-ts';
|
||||
import Boom from 'boom';
|
||||
import { isActivePlatinumLicense } from '../../../common/service_map';
|
||||
import { ML_ERRORS } from '../../../common/anomaly_detection';
|
||||
import { createRoute } from '../create_route';
|
||||
import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs';
|
||||
|
@ -24,8 +25,7 @@ export const anomalyDetectionJobsRoute = createRoute(() => ({
|
|||
handler: async ({ context, request }) => {
|
||||
const setup = await setupRequest(context, request);
|
||||
|
||||
const license = context.licensing.license;
|
||||
if (!license.isActive || !license.hasAtLeast('platinum')) {
|
||||
if (!isActivePlatinumLicense(context.licensing.license)) {
|
||||
throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE);
|
||||
}
|
||||
|
||||
|
@ -56,8 +56,7 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({
|
|||
const { environments } = context.params.body;
|
||||
const setup = await setupRequest(context, request);
|
||||
|
||||
const license = context.licensing.license;
|
||||
if (!license.isActive || !license.hasAtLeast('platinum')) {
|
||||
if (!isActivePlatinumLicense(context.licensing.license)) {
|
||||
throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE);
|
||||
}
|
||||
|
||||
|
|
|
@ -346,6 +346,12 @@ export type ValidAggregationKeysOf<
|
|||
T extends Record<string, any>
|
||||
> = keyof (UnionToIntersection<T> extends never ? T : UnionToIntersection<T>);
|
||||
|
||||
export type AggregationResultOf<
|
||||
TAggregationOptionsMap extends AggregationOptionsMap,
|
||||
TDocument
|
||||
> = AggregationResponsePart<TAggregationOptionsMap, TDocument>[AggregationType &
|
||||
ValidAggregationKeysOf<TAggregationOptionsMap>];
|
||||
|
||||
export type AggregationResponseMap<
|
||||
TAggregationInputMap extends AggregationInputMap | undefined,
|
||||
TDocument
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from 'styled-components';
|
||||
import { useTheme } from './use_theme';
|
||||
|
||||
export function useChartTheme() {
|
||||
const theme = useContext(ThemeContext);
|
||||
const theme = useTheme();
|
||||
return theme.darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme;
|
||||
}
|
||||
|
|
13
x-pack/plugins/observability/public/hooks/use_theme.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 { useContext } from 'react';
|
||||
import { ThemeContext } from 'styled-components';
|
||||
import { EuiTheme } from '../../../../legacy/common/eui_styled_components';
|
||||
|
||||
export function useTheme() {
|
||||
const theme: EuiTheme = useContext(ThemeContext);
|
||||
return theme;
|
||||
}
|
|
@ -26,3 +26,6 @@ export {
|
|||
} from './hooks/use_track_metric';
|
||||
|
||||
export * from './typings';
|
||||
|
||||
export { useChartTheme } from './hooks/use_chart_theme';
|
||||
export { useTheme } from './hooks/use_theme';
|
||||
|
|
|
@ -4863,12 +4863,9 @@
|
|||
"xpack.apm.serviceOverview.upgradeAssistantLink": "アップグレードアシスタント",
|
||||
"xpack.apm.servicesTable.7xOldDataMessage": "また、移行が必要な古いデータがある可能性もあります。",
|
||||
"xpack.apm.servicesTable.7xUpgradeServerMessage": "バージョン7.xより前からのアップグレードですか?また、\n APMサーバーインスタンスを7.0以降にアップグレードしていることも確認してください。",
|
||||
"xpack.apm.servicesTable.agentColumnLabel": "エージェント",
|
||||
"xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均応答時間",
|
||||
"xpack.apm.servicesTable.environmentColumnLabel": "環境",
|
||||
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 個の環境} other {# 個の環境}}",
|
||||
"xpack.apm.servicesTable.errorsPerMinuteColumnLabel": "1 分あたりのエラー",
|
||||
"xpack.apm.servicesTable.errorsPerMinuteUnitLabel": "エラー",
|
||||
"xpack.apm.servicesTable.nameColumnLabel": "名前",
|
||||
"xpack.apm.servicesTable.noServicesLabel": "APM サービスがインストールされていないようです。追加しましょう!",
|
||||
"xpack.apm.servicesTable.notFoundLabel": "サービスが見つかりません",
|
||||
|
|
|
@ -4866,12 +4866,9 @@
|
|||
"xpack.apm.serviceOverview.upgradeAssistantLink": "升级助手",
|
||||
"xpack.apm.servicesTable.7xOldDataMessage": "可能还有需要迁移的旧数据。",
|
||||
"xpack.apm.servicesTable.7xUpgradeServerMessage": "从 7.x 之前的版本升级?另外,确保您已将\n APM Server 实例升级到至少 7.0。",
|
||||
"xpack.apm.servicesTable.agentColumnLabel": "代理",
|
||||
"xpack.apm.servicesTable.avgResponseTimeColumnLabel": "平均响应时间",
|
||||
"xpack.apm.servicesTable.environmentColumnLabel": "环境",
|
||||
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 个环境} other {# 个环境}}",
|
||||
"xpack.apm.servicesTable.errorsPerMinuteColumnLabel": "每分钟错误数",
|
||||
"xpack.apm.servicesTable.errorsPerMinuteUnitLabel": "错误",
|
||||
"xpack.apm.servicesTable.nameColumnLabel": "名称",
|
||||
"xpack.apm.servicesTable.noServicesLabel": "似乎您没有安装任何 APM 服务。让我们添加一些!",
|
||||
"xpack.apm.servicesTable.notFoundLabel": "未找到任何服务",
|
||||
|
|
|
@ -12,7 +12,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
|
|||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
const range = archives['apm_8.0.0'];
|
||||
const archiveName = 'apm_8.0.0';
|
||||
const range = archives[archiveName];
|
||||
const start = encodeURIComponent(range.start);
|
||||
const end = encodeURIComponent(range.end);
|
||||
|
||||
|
@ -29,8 +30,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
describe('when data is loaded', () => {
|
||||
before(() => esArchiver.load('apm_8.0.0'));
|
||||
after(() => esArchiver.unload('apm_8.0.0'));
|
||||
before(() => esArchiver.load(archiveName));
|
||||
after(() => esArchiver.unload(archiveName));
|
||||
|
||||
it('returns the agent name', async () => {
|
||||
const response = await supertest.get(
|
||||
|
|
|
@ -4,18 +4,25 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { sortBy } from 'lodash';
|
||||
import expect from '@kbn/expect';
|
||||
import { isEmpty, pick } from 'lodash';
|
||||
import { PromiseReturnType } from '../../../../../plugins/apm/typings/common';
|
||||
import { expectSnapshot } from '../../../common/match_snapshot';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import archives_metadata from '../../../common/archives_metadata';
|
||||
|
||||
export default function ApiTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
const archiveName = 'apm_8.0.0';
|
||||
|
||||
const range = archives_metadata[archiveName];
|
||||
|
||||
// url parameters
|
||||
const start = encodeURIComponent('2020-06-29T06:45:00.000Z');
|
||||
const end = encodeURIComponent('2020-06-29T06:49:00.000Z');
|
||||
const start = encodeURIComponent(range.start);
|
||||
const end = encodeURIComponent(range.end);
|
||||
|
||||
const uiFilters = encodeURIComponent(JSON.stringify({}));
|
||||
|
||||
describe('APM Services Overview', () => {
|
||||
|
@ -31,52 +38,189 @@ export default function ApiTest({ getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
describe('when data is loaded', () => {
|
||||
before(() => esArchiver.load('8.0.0'));
|
||||
after(() => esArchiver.unload('8.0.0'));
|
||||
before(() => esArchiver.load(archiveName));
|
||||
after(() => esArchiver.unload(archiveName));
|
||||
|
||||
it('returns a list of services', async () => {
|
||||
const response = await supertest.get(
|
||||
`/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}`
|
||||
);
|
||||
// sort services to mitigate unstable sort order
|
||||
const services = sortBy(response.body.items, ['serviceName']);
|
||||
describe('and fetching a list of services', () => {
|
||||
let response: PromiseReturnType<typeof supertest.get>;
|
||||
before(async () => {
|
||||
response = await supertest.get(
|
||||
`/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}`
|
||||
);
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expectSnapshot(services).toMatchInline(`
|
||||
Array [
|
||||
Object {
|
||||
"agentName": "rum-js",
|
||||
"avgResponseTime": 116375,
|
||||
"environments": Array [],
|
||||
"errorsPerMinute": 2.75,
|
||||
"serviceName": "client",
|
||||
"transactionsPerMinute": 2,
|
||||
},
|
||||
Object {
|
||||
"agentName": "java",
|
||||
"avgResponseTime": 25636.349593495936,
|
||||
"environments": Array [
|
||||
it('the response is successful', () => {
|
||||
expect(response.status).to.eql(200);
|
||||
});
|
||||
|
||||
it('returns hasHistoricalData: true', () => {
|
||||
expect(response.body.hasHistoricalData).to.be(true);
|
||||
});
|
||||
|
||||
it('returns hasLegacyData: false', () => {
|
||||
expect(response.body.hasLegacyData).to.be(false);
|
||||
});
|
||||
|
||||
it('returns the correct service names', () => {
|
||||
expectSnapshot(response.body.items.map((item: any) => item.serviceName)).toMatchInline(`
|
||||
Array [
|
||||
"opbeans-python",
|
||||
"opbeans-node",
|
||||
"opbeans-ruby",
|
||||
"opbeans-go",
|
||||
"opbeans-dotnet",
|
||||
"opbeans-java",
|
||||
"opbeans-rum",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns the correct metrics averages', () => {
|
||||
expectSnapshot(
|
||||
response.body.items.map((item: any) =>
|
||||
pick(
|
||||
item,
|
||||
'transactionErrorRate.value',
|
||||
'avgResponseTime.value',
|
||||
'transactionsPerMinute.value'
|
||||
)
|
||||
)
|
||||
).toMatchInline(`
|
||||
Array [
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 208079.9121184089,
|
||||
},
|
||||
"transactionErrorRate": Object {
|
||||
"value": 0.041666666666666664,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 18.016666666666666,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 578297.1431623931,
|
||||
},
|
||||
"transactionErrorRate": Object {
|
||||
"value": 0.03317535545023697,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 7.8,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 60518.587926509186,
|
||||
},
|
||||
"transactionErrorRate": Object {
|
||||
"value": 0.013123359580052493,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 6.35,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 25259.78717201166,
|
||||
},
|
||||
"transactionErrorRate": Object {
|
||||
"value": 0.014577259475218658,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 5.716666666666667,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 527290.3218390804,
|
||||
},
|
||||
"transactionErrorRate": Object {
|
||||
"value": 0.01532567049808429,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 4.35,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 530245.8571428572,
|
||||
},
|
||||
"transactionErrorRate": Object {
|
||||
"value": 0.15384615384615385,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 3.033333333333333,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"avgResponseTime": Object {
|
||||
"value": 896134.328358209,
|
||||
},
|
||||
"transactionsPerMinute": Object {
|
||||
"value": 2.2333333333333334,
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('returns environments', () => {
|
||||
expectSnapshot(response.body.items.map((item: any) => item.environments ?? []))
|
||||
.toMatchInline(`
|
||||
Array [
|
||||
Array [
|
||||
"production",
|
||||
],
|
||||
"errorsPerMinute": 4.5,
|
||||
"serviceName": "opbeans-java",
|
||||
"transactionsPerMinute": 30.75,
|
||||
},
|
||||
Object {
|
||||
"agentName": "nodejs",
|
||||
"avgResponseTime": 38682.52419354839,
|
||||
"environments": Array [
|
||||
Array [
|
||||
"testing",
|
||||
],
|
||||
Array [
|
||||
"production",
|
||||
],
|
||||
"errorsPerMinute": 3.75,
|
||||
"serviceName": "opbeans-node",
|
||||
"transactionsPerMinute": 31,
|
||||
},
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
"testing",
|
||||
],
|
||||
Array [
|
||||
"production",
|
||||
],
|
||||
Array [
|
||||
"production",
|
||||
],
|
||||
Array [
|
||||
"testing",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
expect(response.body.hasHistoricalData).to.be(true);
|
||||
expect(response.body.hasLegacyData).to.be(false);
|
||||
it(`RUM services don't report any transaction error rates`, () => {
|
||||
// RUM transactions don't have event.outcome set,
|
||||
// so they should not have an error rate
|
||||
|
||||
const rumServices = response.body.items.filter(
|
||||
(item: any) => item.agentName === 'rum-js'
|
||||
);
|
||||
|
||||
expect(rumServices.length).to.be.greaterThan(0);
|
||||
|
||||
expect(rumServices.every((item: any) => isEmpty(item.transactionErrorRate?.value)));
|
||||
});
|
||||
|
||||
it('non-RUM services all report transaction error rates', () => {
|
||||
const nonRumServices = response.body.items.filter(
|
||||
(item: any) => item.agentName !== 'rum-js'
|
||||
);
|
||||
|
||||
expect(
|
||||
nonRumServices.every((item: any) => {
|
||||
return (
|
||||
typeof item.transactionErrorRate?.value === 'number' &&
|
||||
item.transactionErrorRate.timeseries.length > 0
|
||||
);
|
||||
})
|
||||
).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr
|
|||
describe('Services', function () {
|
||||
loadTestFile(require.resolve('./services/annotations'));
|
||||
loadTestFile(require.resolve('./services/rum_services.ts'));
|
||||
loadTestFile(require.resolve('./services/top_services.ts'));
|
||||
});
|
||||
|
||||
describe('Settings', function () {
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { expectSnapshot } from '../../../common/match_snapshot';
|
||||
import { PromiseReturnType } from '../../../../../plugins/apm/typings/common';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import archives_metadata from '../../../common/archives_metadata';
|
||||
|
||||
export default function ApiTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
const archiveName = 'apm_8.0.0';
|
||||
|
||||
const range = archives_metadata[archiveName];
|
||||
|
||||
// url parameters
|
||||
const start = encodeURIComponent(range.start);
|
||||
const end = encodeURIComponent(range.end);
|
||||
|
||||
const uiFilters = encodeURIComponent(JSON.stringify({}));
|
||||
|
||||
describe('APM Services Overview', () => {
|
||||
describe('when data is loaded', () => {
|
||||
before(() => esArchiver.load(archiveName));
|
||||
after(() => esArchiver.unload(archiveName));
|
||||
|
||||
describe('and fetching a list of services', () => {
|
||||
let response: PromiseReturnType<typeof supertest.get>;
|
||||
before(async () => {
|
||||
response = await supertest.get(
|
||||
`/api/apm/services?start=${start}&end=${end}&uiFilters=${uiFilters}`
|
||||
);
|
||||
});
|
||||
|
||||
it('the response is successful', () => {
|
||||
expect(response.status).to.eql(200);
|
||||
});
|
||||
|
||||
it('there is at least one service', () => {
|
||||
expect(response.body.items.length).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it('some items have severity set', () => {
|
||||
// Under the assumption that the loaded archive has
|
||||
// at least one APM ML job, and the time range is longer
|
||||
// than 15m, at least one items should have severity set.
|
||||
// Note that we currently have a bug where healthy services
|
||||
// report as unknown (so without any severity status):
|
||||
// https://github.com/elastic/kibana/issues/77083
|
||||
|
||||
const severityScores = response.body.items.map((item: any) => item.severity);
|
||||
|
||||
expect(severityScores.filter(Boolean).length).to.be.greaterThan(0);
|
||||
|
||||
expectSnapshot(severityScores).toMatchInline(`
|
||||
Array [
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"warning",
|
||||
undefined,
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|