diff --git a/packages/jaeger-ui/jest.global-setup.js b/packages/jaeger-ui/jest.global-setup.js
new file mode 100644
index 0000000000..6e1fbf41d0
--- /dev/null
+++ b/packages/jaeger-ui/jest.global-setup.js
@@ -0,0 +1,3 @@
+module.exports = async () => {
+ process.env.TZ = 'UTC';
+};
diff --git a/packages/jaeger-ui/package.json b/packages/jaeger-ui/package.json
index aab5561099..aa12fb6a55 100644
--- a/packages/jaeger-ui/package.json
+++ b/packages/jaeger-ui/package.json
@@ -116,6 +116,7 @@
"test": "CI=1 react-app-rewired test --env=jsdom --color"
},
"jest": {
+ "globalSetup": "./jest.global-setup.js",
"collectCoverageFrom": [
"!src/setup*.js",
"!src/utils/DraggableManager/demo/*.tsx",
diff --git a/packages/jaeger-ui/src/actions/jaeger-api.js b/packages/jaeger-ui/src/actions/jaeger-api.js
index 8bde3b8baa..b6d0187aad 100644
--- a/packages/jaeger-ui/src/actions/jaeger-api.js
+++ b/packages/jaeger-ui/src/actions/jaeger-api.js
@@ -15,6 +15,24 @@
import { createAction } from 'redux-actions';
import JaegerAPI from '../api/jaeger';
+const metricType = {
+ latencies: 'latencies',
+ calls: 'calls',
+ errors: 'errors',
+};
+
+// export for tests
+// TODO use native `allSetteled` once #818 is done
+export function allSettled(promises) {
+ const wrappedPromises = promises.map(p =>
+ Promise.resolve(p).then(
+ val => ({ status: 'fulfilled', value: val }),
+ err => ({ status: 'rejected', reason: err })
+ )
+ );
+ return Promise.all(wrappedPromises);
+}
+
export const fetchTrace = createAction(
'@JAEGER_API/FETCH_TRACE',
id => JaegerAPI.fetchTrace(id),
@@ -62,3 +80,28 @@ export const fetchDeepDependencyGraph = createAction(
export const fetchDependencies = createAction('@JAEGER_API/FETCH_DEPENDENCIES', () =>
JaegerAPI.fetchDependencies()
);
+
+export const fetchAllServiceMetrics = createAction(
+ '@JAEGER_API/FETCH_ALL_SERVICE_METRICS',
+ (serviceName, query) => {
+ return allSettled([
+ JaegerAPI.fetchMetrics(metricType.latencies, [serviceName], { ...query, quantile: 0.5 }),
+ JaegerAPI.fetchMetrics(metricType.latencies, [serviceName], { ...query, quantile: 0.75 }),
+ JaegerAPI.fetchMetrics(metricType.latencies, [serviceName], query),
+ JaegerAPI.fetchMetrics(metricType.calls, [serviceName], query),
+ JaegerAPI.fetchMetrics(metricType.errors, [serviceName], query),
+ ]);
+ }
+);
+
+export const fetchAggregatedServiceMetrics = createAction(
+ '@JAEGER_API/FETCH_AGGREGATED_SERVICE_METRICS',
+ (serviceName, queryParams) => {
+ const query = { ...queryParams, groupByOperation: true };
+ return allSettled([
+ JaegerAPI.fetchMetrics(metricType.latencies, [serviceName], query),
+ JaegerAPI.fetchMetrics(metricType.calls, [serviceName], query),
+ JaegerAPI.fetchMetrics(metricType.errors, [serviceName], query),
+ ]);
+ }
+);
diff --git a/packages/jaeger-ui/src/actions/jaeger-api.test.js b/packages/jaeger-ui/src/actions/jaeger-api.test.js
index 602ab2edd2..430b9a82cb 100644
--- a/packages/jaeger-ui/src/actions/jaeger-api.test.js
+++ b/packages/jaeger-ui/src/actions/jaeger-api.test.js
@@ -149,4 +149,49 @@ describe('actions/jaeger-api', () => {
jaegerApiActions.fetchDependencies();
expect(called.verify()).toBeTruthy();
});
+
+ it('@JAEGER_API/FETCH_ALL_SERVICE_METRICS should return the promise', () => {
+ const { payload } = jaegerApiActions.fetchAllServiceMetrics('serviceName', query);
+ expect(isPromise(payload)).toBeTruthy();
+ });
+
+ it('@JAEGER_API/FETCH_ALL_SERVICE_METRICS should fetch service metrics by name', () => {
+ mock.expects('fetchMetrics');
+ mock.expects('fetchMetrics');
+ mock.expects('fetchMetrics');
+ mock.expects('fetchMetrics');
+ mock.expects('fetchMetrics');
+ jaegerApiActions.fetchAllServiceMetrics('serviceName', query);
+ expect(() => mock.verify()).not.toThrow();
+ });
+
+ it('@JAEGER_API/FETCH_AGGREGATED_SERVICE_METRICS should return the promise', () => {
+ const { payload } = jaegerApiActions.fetchAggregatedServiceMetrics('serviceName', query);
+ expect(isPromise(payload)).toBeTruthy();
+ });
+
+ it('@JAEGER_API/FETCH_AGGREGATED_SERVICE_METRICS should fetch service metrics by name', () => {
+ mock.expects('fetchMetrics');
+ mock.expects('fetchMetrics');
+ mock.expects('fetchMetrics');
+ jaegerApiActions.fetchAggregatedServiceMetrics('serviceName', query);
+ expect(() => mock.verify()).not.toThrow();
+ });
+});
+
+describe('allSettled', () => {
+ it('validate responses', async () => {
+ const res = await jaegerApiActions.allSettled([
+ Promise.resolve(1),
+ // eslint-disable-next-line prefer-promise-reject-errors
+ Promise.reject(2),
+ Promise.resolve(3),
+ ]);
+
+ expect(res).toEqual([
+ { status: 'fulfilled', value: 1 },
+ { status: 'rejected', reason: 2 },
+ { status: 'fulfilled', value: 3 },
+ ]);
+ });
});
diff --git a/packages/jaeger-ui/src/api/jaeger.js b/packages/jaeger-ui/src/api/jaeger.js
index db71d75e7f..0283e4179e 100644
--- a/packages/jaeger-ui/src/api/jaeger.js
+++ b/packages/jaeger-ui/src/api/jaeger.js
@@ -36,7 +36,12 @@ export function getMessageFromError(errData, status) {
function getJSON(url, options = {}) {
const { query = null, ...init } = options;
init.credentials = 'same-origin';
- const queryStr = query ? `?${queryString.stringify(query)}` : '';
+ let queryStr = '';
+
+ if (query) {
+ queryStr = `?${typeof query === 'string' ? query : queryString.stringify(query)}`;
+ }
+
return fetch(`${url}${queryStr}`, init).then(response => {
if (response.status < 400) {
return response.json();
@@ -112,6 +117,13 @@ const JaegerAPI = {
searchTraces(query) {
return getJSON(`${this.apiRoot}traces`, { query });
},
+ fetchMetrics(metricType, serviceNameList, query) {
+ const servicesName = serviceNameList.map(serviceName => `service=${serviceName}`).join(',');
+
+ return getJSON(`${this.apiRoot}metrics/${metricType}`, {
+ query: `${servicesName}&${queryString.stringify(query)}`,
+ }).then(d => ({ ...d, quantile: query.quantile }));
+ },
};
export default JaegerAPI;
diff --git a/packages/jaeger-ui/src/api/jaeger.test.js b/packages/jaeger-ui/src/api/jaeger.test.js
index 3c442fe925..7bad3bcf6b 100644
--- a/packages/jaeger-ui/src/api/jaeger.test.js
+++ b/packages/jaeger-ui/src/api/jaeger.test.js
@@ -201,3 +201,44 @@ describe('getMessageFromError()', () => {
});
});
});
+
+describe('fetchMetrics', () => {
+ it('GETs metrics query', () => {
+ fetchMock.mockReset();
+ const metricsType = 'latencies';
+ const serviceName = ['serviceName'];
+ const query = {
+ quantile: 95,
+ };
+ fetchMock.mockReturnValue(
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({ someObj: {} }),
+ })
+ );
+
+ JaegerAPI.fetchMetrics(metricsType, serviceName, query);
+ expect(fetchMock).toHaveBeenLastCalledWith(
+ `${DEFAULT_API_ROOT}metrics/${metricsType}?service=${serviceName}&${queryString.stringify(query)}`,
+ defaultOptions
+ );
+ });
+
+ it('fetchMetrics() should add quantile to response', async () => {
+ const metricsType = 'latencies';
+ const serviceName = ['serviceName'];
+ const query = {
+ quantile: 95,
+ };
+
+ fetchMock.mockReturnValue(
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({ someObj: {} }),
+ })
+ );
+
+ const resp = await JaegerAPI.fetchMetrics(metricsType, serviceName, query);
+ expect(resp.quantile).toBe(query.quantile);
+ });
+});
diff --git a/packages/jaeger-ui/src/components/App/TopNav.tsx b/packages/jaeger-ui/src/components/App/TopNav.tsx
index 04cbb2b7e6..5d116cadf5 100644
--- a/packages/jaeger-ui/src/components/App/TopNav.tsx
+++ b/packages/jaeger-ui/src/components/App/TopNav.tsx
@@ -24,6 +24,7 @@ import * as deepDependencies from '../DeepDependencies/url';
import * as qualityMetrics from '../QualityMetrics/url';
import * as searchUrl from '../SearchTracePage/url';
import * as diffUrl from '../TraceDiff/url';
+import * as monitorATMUrl from '../Monitor/url';
import { ReduxState } from '../../types';
import { ConfigMenuItem, ConfigMenuGroup } from '../../types/config';
import { getConfigValue } from '../../utils/config/get-config';
@@ -68,6 +69,14 @@ if (getConfigValue('qualityMetrics.menuEnabled')) {
});
}
+if (getConfigValue('monitor.menuEnabled')) {
+ NAV_LINKS.push({
+ to: monitorATMUrl.getUrl(),
+ matches: monitorATMUrl.matches,
+ text: 'Monitor',
+ });
+}
+
function getItem(item: ConfigMenuItem) {
const { label, anchorTarget, url } = item;
const link = (
@@ -75,6 +84,7 @@ function getItem(item: ConfigMenuItem) {
{label}
);
+
return (
{url ? link : label}
diff --git a/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap
index 541c5f172f..acac663f62 100644
--- a/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap
+++ b/packages/jaeger-ui/src/components/App/__snapshots__/index.test.js.snap
@@ -60,6 +60,10 @@ exports[`JaegerUIApp does not explode 1`] = `
component={[Function]}
path="/quality-metrics"
/>
+
+
diff --git a/packages/jaeger-ui/src/components/Monitor/EmptyState/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitor/EmptyState/__snapshots__/index.test.js.snap
new file mode 100644
index 0000000000..9f27e82fe4
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/EmptyState/__snapshots__/index.test.js.snap
@@ -0,0 +1,217 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` partially configured ATM snapshot test 1`] = `
+
+
+
+
+
+ Get started with Services Monitor
+
+
+
+
+
+ •
+ Configured
+
+
+
+
+
+
+
+
+ •
+ Sent data
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`not configured ATM snapshot test 1`] = `
+
+
+
+
+
+ Get started with Services Monitor
+
+
+
+
+
+ •
+ Configured
+
+
+
+
+
+
+
+
+ •
+ Sent data
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/jaeger-ui/src/components/Monitor/EmptyState/index.css b/packages/jaeger-ui/src/components/Monitor/EmptyState/index.css
new file mode 100644
index 0000000000..faeaf63638
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/EmptyState/index.css
@@ -0,0 +1,20 @@
+/*
+Copyright (c) 2021 The Jaeger Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.center-empty-state {
+ text-align: center;
+ margin-top: calc(100vh / 3.5);
+}
diff --git a/packages/jaeger-ui/src/components/Monitor/EmptyState/index.test.js b/packages/jaeger-ui/src/components/Monitor/EmptyState/index.test.js
new file mode 100644
index 0000000000..c027acb287
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/EmptyState/index.test.js
@@ -0,0 +1,70 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { mount } from 'enzyme';
+import IoIosCheckmark from 'react-icons/lib/io/ios-checkmark-outline';
+import IoIosCloseCircle from 'react-icons/lib/io/ios-circle-outline';
+import MonitorATMEmptyState from '.';
+
+const props = {
+ configureStatus: false,
+ sendDataStatus: false,
+};
+describe('not configured', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount( );
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('shows a loading indicator when loading data', () => {
+ expect(wrapper.find(IoIosCloseCircle).length).toBe(2);
+ });
+
+ it('ATM snapshot test', () => {
+ expect(wrapper).toMatchSnapshot();
+ });
+});
+
+describe(' partially configured', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(
+
+ );
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('should render checkbox', () => {
+ expect(wrapper.find(IoIosCheckmark).length).toBe(1);
+ });
+
+ it('ATM snapshot test', () => {
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/EmptyState/index.tsx b/packages/jaeger-ui/src/components/Monitor/EmptyState/index.tsx
new file mode 100644
index 0000000000..7e3a7e1938
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/EmptyState/index.tsx
@@ -0,0 +1,75 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from 'react';
+import { List, Row, Col, Button } from 'antd';
+import IoIosCheckmark from 'react-icons/lib/io/ios-checkmark-outline';
+import IoIosCloseCircle from 'react-icons/lib/io/ios-circle-outline';
+
+import './index.css';
+
+type TProps = {
+ configureStatus: boolean;
+ sendDataStatus: boolean;
+};
+
+export default class MonitorATMEmptyState extends React.PureComponent {
+ private configureStatus = {
+ text: 'Configured',
+ status: false,
+ };
+
+ private sendDataStatus = {
+ text: 'Sent data',
+ status: false,
+ };
+
+ constructor(props: TProps) {
+ super(props);
+
+ this.configureStatus.status = props.configureStatus;
+ this.sendDataStatus.status = props.sendDataStatus;
+ }
+
+ render() {
+ return (
+
+
+ Get started with Services Monitor}
+ footer={
+
+ Go to documentation
+
+ }
+ renderItem={(item: { text: string; status: boolean }) => (
+
+ • {item.text} {item.status ? : }
+
+ )}
+ />
+
+
+ );
+ }
+}
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitor/ServicesView/__snapshots__/index.test.js.snap
new file mode 100644
index 0000000000..0430e24544
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/__snapshots__/index.test.js.snap
@@ -0,0 +1,353 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` ATM snapshot test 1`] = `
+
+
+
+
+ Choose service
+
+
+
+
+
+
+
+ Aggregation of all "
+ s1
+ " metrics in selected timeframe.
+
+
+ View all traces
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Operations metrics under
+ s1
+
+
+
+ Over the
+ last hour
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/__snapshots__/serviceGraph.test.js.snap b/packages/jaeger-ui/src/components/Monitor/ServicesView/__snapshots__/serviceGraph.test.js.snap
new file mode 100644
index 0000000000..567b2a6932
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/__snapshots__/serviceGraph.test.js.snap
@@ -0,0 +1,701 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` "Couldn’t fetch data" displayed 1`] = `
+
+
+ Hello Graph
+
+
+ Couldn’t fetch data
+
+
+`;
+
+exports[` "No data" displayed 1`] = `
+
+
+ Hello Graph
+
+
+ No Data
+
+
+`;
+
+exports[` Base graph should be displayed 1`] = `
+
+
+ Hello Graph
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` Base graph with custom color should be displayed 1`] = `
+
+
+ Hello Graph
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` Base graph with horizontal lines should be displayed 1`] = `
+
+
+ Hello Graph
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` Base graph with legends should be displayed 1`] = `
+
+
+ Hello Graph
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` Crosshair map test 1`] = `
+
+
+ Hello Graph
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` Crosshair mouse hover test 1`] = `
+
+`;
+
+exports[` Crosshair mouse hover test 2`] = `
+
+`;
+
+exports[` Loading indicator is displayed 1`] = `
+
+
+ Hello Graph
+
+
+
+
+
+`;
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/index.css b/packages/jaeger-ui/src/components/Monitor/ServicesView/index.css
new file mode 100644
index 0000000000..f04b2eba0c
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/index.css
@@ -0,0 +1,74 @@
+/*
+Copyright (c) 2021 The Jaeger Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.service-view-container {
+ padding: 1rem 1.375rem;
+}
+
+.service-selector-header {
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.operations-metrics-text {
+ margin-top: 17px;
+ margin-bottom: 14px;
+ font-size: 14;
+ font-weight: 400;
+}
+
+.timeframe-selector {
+ display: inline-flex;
+ justify-content: flex-end;
+ position: absolute;
+ bottom: 14px;
+}
+
+.operation-table-block {
+ margin-top: 54px;
+}
+
+.table-header {
+ display: inline-block;
+ margin-bottom: 15px;
+ font-size: 18;
+ font-weight: 700;
+}
+
+.select-a-service-input {
+ font-size: 14px;
+ width: 100%;
+}
+
+.select-a-timeframe-input {
+ font-size: 14px;
+ width: 128px;
+}
+
+.over-the-last {
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.select-operation-column {
+ display: inline-flex;
+ justify-content: flex-end;
+}
+
+.select-operation-input {
+ font-size: 14px;
+ width: 218px;
+}
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/index.test.js b/packages/jaeger-ui/src/components/Monitor/ServicesView/index.test.js
new file mode 100644
index 0000000000..0be788968f
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/index.test.js
@@ -0,0 +1,308 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import {
+ MonitorATMServicesViewImpl as MonitorATMServicesView,
+ mapStateToProps,
+ mapDispatchToProps,
+ getLoopbackInterval,
+} from '.';
+import LoadingIndicator from '../../common/LoadingIndicator';
+import MonitorATMEmptyState from '../EmptyState';
+import ServiceGraph from './serviceGraph';
+import { originInitialState, serviceMetrics, serviceOpsMetrics } from '../../../reducers/metrics.mock';
+
+const state = {
+ services: {},
+ metrics: originInitialState,
+ selectedService: undefined,
+};
+
+const props = mapStateToProps(state);
+
+Date.now = jest.fn(() => 1487076708000); // Tue, 14 Feb 2017 12:51:48 GMT'
+
+describe('', () => {
+ let wrapper;
+ const mockFetchServices = jest.fn();
+ const mockFetchAllServiceMetrics = jest.fn();
+ const mockFetchAggregatedServiceMetrics = jest.fn();
+
+ beforeEach(() => {
+ wrapper = shallow(
+
+ );
+ });
+
+ afterEach(() => {
+ wrapper = null;
+ jest.clearAllMocks();
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('shows a loading indicator when loading services list', () => {
+ wrapper.setProps({ servicesLoading: true });
+ expect(wrapper.find(LoadingIndicator).length).toBe(1);
+ });
+
+ it('do not show a loading indicator once data loaded', () => {
+ wrapper.setProps({
+ services: ['s1'],
+ selectedService: undefined,
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ },
+ });
+ expect(wrapper.find(LoadingIndicator).length).toBe(0);
+ });
+
+ it('Render ATM not configured page', () => {
+ wrapper.setProps({
+ services: [],
+ selectedService: undefined,
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: false,
+ },
+ });
+ expect(wrapper.find(MonitorATMEmptyState).length).toBe(1);
+ });
+
+ it('function invocation check on page load', () => {
+ expect(mockFetchServices).toHaveBeenCalled();
+ expect(mockFetchAllServiceMetrics).not.toHaveBeenCalled();
+ expect(mockFetchAggregatedServiceMetrics).not.toHaveBeenCalled();
+ wrapper.setProps({
+ services: ['s1'],
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ },
+ });
+ expect(mockFetchAllServiceMetrics).toHaveBeenCalled();
+ expect(mockFetchAggregatedServiceMetrics).toHaveBeenCalled();
+ });
+
+ it('ATM snapshot test', () => {
+ mockFetchServices.mockResolvedValue(['s1', 's2']);
+ wrapper.setProps({
+ services: ['s1', 's2'],
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ },
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('ComponentWillUnmount remove listener', () => {
+ const remover = jest.spyOn(global, 'removeEventListener').mockImplementation(() => {});
+ wrapper.unmount();
+ expect(remover).toHaveBeenCalled();
+ });
+
+ it('resize window test', () => {
+ const selectedInput = 'graphDivWrapper';
+ wrapper.instance()[selectedInput] = {
+ current: {
+ offsetWidth: 100,
+ },
+ };
+ global.dispatchEvent(new Event('resize'));
+ expect(wrapper.state().graphWidth).toBe(76);
+ });
+
+ it('search test', () => {
+ mockFetchServices.mockResolvedValue(['cartservice']);
+ wrapper.setProps({
+ services: ['s1', 's2'],
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ },
+ });
+
+ wrapper.find('Search').simulate('change', { target: { value: 'place' } });
+ expect(wrapper.state().serviceOpsMetrics.length).toBe(1);
+ wrapper.find('Search').simulate('change', { target: { value: 'qqq' } });
+ expect(wrapper.state().serviceOpsMetrics.length).toBe(0);
+ wrapper.find('Search').simulate('change', { target: { value: '' } });
+ expect(wrapper.state().serviceOpsMetrics.length).toBe(1);
+ });
+
+ it('Error in serviceLatencies ', () => {
+ wrapper.setProps({
+ services: ['s1', 's2'],
+ selectedService: 's1',
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ serviceError: {
+ ...originInitialState.serviceError,
+ service_latencies_50: new Error('some API error'),
+ },
+ },
+ });
+ expect(
+ wrapper
+ .find(ServiceGraph)
+ .first()
+ .prop('error')
+ ).toBeNull();
+
+ wrapper.setProps({
+ services: ['s1', 's2'],
+ selectedService: 's1',
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ serviceError: {
+ ...originInitialState.serviceError,
+ service_latencies_50: new Error('some API error'),
+ service_latencies_75: new Error('some API error'),
+ },
+ },
+ });
+ expect(
+ wrapper
+ .find(ServiceGraph)
+ .first()
+ .prop('error')
+ ).toBeNull();
+
+ wrapper.setProps({
+ services: ['s1', 's2'],
+ selectedService: 's1',
+ metrics: {
+ ...originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ loading: false,
+ isATMActivated: true,
+ serviceError: {
+ service_latencies_50: new Error('some API error'),
+ service_latencies_75: new Error('some API error'),
+ service_latencies_95: new Error('some API error'),
+ },
+ },
+ });
+ expect(
+ wrapper
+ .find(ServiceGraph)
+ .first()
+ .prop('error')
+ ).not.toBeNull();
+ });
+});
+
+describe(' on page switch', () => {
+ // eslint-disable-next-line no-unused-vars
+ let wrapper;
+ const stateOnPageSwitch = {
+ services: {
+ services: ['s1'],
+ },
+ metrics: originInitialState,
+ selectedService: undefined,
+ };
+
+ const propsOnPageSwitch = mapStateToProps(stateOnPageSwitch);
+ const mockFetchServices = jest.fn();
+ const mockFetchAllServiceMetrics = jest.fn();
+ const mockFetchAggregatedServiceMetrics = jest.fn();
+
+ beforeEach(() => {
+ wrapper = shallow(
+
+ );
+ });
+
+ it('function invocation check on page load', () => {
+ expect(mockFetchServices).toHaveBeenCalled();
+ expect(mockFetchAllServiceMetrics).toHaveBeenCalled();
+ expect(mockFetchAggregatedServiceMetrics).toHaveBeenCalled();
+ });
+});
+
+describe('mapStateToProps()', () => {
+ it('refines state to generate the props', () => {
+ expect(mapStateToProps(state)).toEqual({
+ metrics: originInitialState,
+ services: [],
+ selectedService: 's1',
+ selectedTimeFrame: 3600000,
+ });
+ });
+});
+
+describe('mapDispatchToProps()', () => {
+ it('providers the `fetchServices` , `fetchAllServiceMetrics` and `fetchAggregatedServiceMetrics` prop', () => {
+ expect(mapDispatchToProps({})).toEqual({
+ fetchServices: expect.any(Function),
+ fetchAllServiceMetrics: expect.any(Function),
+ fetchAggregatedServiceMetrics: expect.any(Function),
+ });
+ });
+});
+
+describe('getLoopbackInterval()', () => {
+ it('undefined value', () => {
+ expect(getLoopbackInterval()).toBe('');
+ });
+
+ it('timeframe NOT exists', () => {
+ expect(getLoopbackInterval(111)).toBe('');
+ });
+
+ it('timeframe exists', () => {
+ expect(getLoopbackInterval(48 * 3600000)).toBe('last 2 days');
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/index.tsx b/packages/jaeger-ui/src/components/Monitor/ServicesView/index.tsx
new file mode 100644
index 0000000000..b8f21d0cf6
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/index.tsx
@@ -0,0 +1,361 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from 'react';
+import { Row, Col, Input } from 'antd';
+import { ActionFunction, Action } from 'redux-actions';
+import _isEqual from 'lodash/isEqual';
+// @ts-ignore
+import { Field, formValueSelector, reduxForm } from 'redux-form';
+// @ts-ignore
+import store from 'store';
+import { connect } from 'react-redux';
+import { bindActionCreators, Dispatch } from 'redux';
+import VirtSelect from '../../common/VirtSelect';
+import reduxFormFieldAdapter from '../../../utils/redux-form-field-adapter';
+import * as jaegerApiActions from '../../../actions/jaeger-api';
+import ServiceGraph from './serviceGraph';
+import OperationTableDetails from './operationDetailsTable';
+import LoadingIndicator from '../../common/LoadingIndicator';
+import MonitorATMEmptyState from '../EmptyState';
+import { ReduxState } from '../../../types';
+import { MetricsAPIQueryParams, MetricsReduxState, ServiceOpsMetrics } from '../../../types/metrics';
+import prefixUrl from '../../../utils/prefix-url';
+
+import './index.css';
+
+type StateType = {
+ graphWidth: number;
+ serviceOpsMetrics: ServiceOpsMetrics[] | undefined;
+ searchOps: string;
+};
+
+type TReduxProps = {
+ services: string[];
+ servicesLoading: boolean;
+ metrics: MetricsReduxState;
+ selectedService: string;
+ selectedTimeFrame: number;
+};
+
+type TProps = TReduxProps & TDispatchProps;
+
+type TDispatchProps = {
+ fetchServices: ActionFunction>>;
+ fetchAggregatedServiceMetrics: ActionFunction>, string, MetricsAPIQueryParams>;
+ fetchAllServiceMetrics: (serviceName: string, query: MetricsAPIQueryParams) => void;
+};
+
+const Search = Input.Search;
+
+const AdaptedVirtualSelect = reduxFormFieldAdapter({
+ AntInputComponent: VirtSelect,
+ onChangeAdapter: option => (option ? (option as any).value : null),
+});
+
+const serviceFormSelector = formValueSelector('serviceForm');
+const oneHourInMilliSeconds = 3600000;
+const timeFrameOptions = [
+ { label: 'Last Hour', value: oneHourInMilliSeconds },
+ { label: 'Last 2 hour', value: 2 * oneHourInMilliSeconds },
+ { label: 'Last 6 hour', value: 6 * oneHourInMilliSeconds },
+ { label: 'Last 12 hour', value: 12 * oneHourInMilliSeconds },
+ { label: 'Last 24 hours', value: 24 * oneHourInMilliSeconds },
+ { label: 'Last 2 days', value: 48 * oneHourInMilliSeconds },
+];
+
+// export for tests
+export const getLoopbackInterval = (interval: number) => {
+ if (interval === undefined) return '';
+
+ const timeFrameObj = timeFrameOptions.find(t => t.value === interval);
+
+ if (timeFrameObj === undefined) return '';
+
+ return timeFrameObj.label.toLowerCase();
+};
+
+// export for tests
+export class MonitorATMServicesViewImpl extends React.PureComponent {
+ graphDivWrapper: React.RefObject;
+ serviceSelectorValue: string = '';
+ endTime: number = Date.now();
+ state = {
+ graphWidth: 300,
+ serviceOpsMetrics: undefined,
+ searchOps: '',
+ };
+
+ constructor(props: TProps) {
+ super(props);
+ this.graphDivWrapper = React.createRef();
+ }
+
+ componentDidMount() {
+ const { fetchServices, services } = this.props;
+ fetchServices();
+ if (services.length !== 0) {
+ this.fetchMetrics();
+ }
+ window.addEventListener('resize', this.updateDimensions.bind(this));
+ this.updateDimensions.apply(this);
+ }
+
+ componentDidUpdate(nextProps: TProps) {
+ const { selectedService, selectedTimeFrame, services } = this.props;
+
+ if (nextProps.selectedService !== selectedService || nextProps.selectedTimeFrame !== selectedTimeFrame) {
+ this.fetchMetrics();
+ } else if (!_isEqual(nextProps.services, services)) {
+ this.fetchMetrics();
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.updateDimensions.bind(this));
+ }
+
+ updateDimensions() {
+ if (this.graphDivWrapper.current) {
+ this.setState({
+ graphWidth: this.graphDivWrapper.current.offsetWidth - 24,
+ });
+ }
+ }
+
+ fetchMetrics() {
+ const {
+ selectedService,
+ selectedTimeFrame,
+ fetchAllServiceMetrics,
+ fetchAggregatedServiceMetrics,
+ services,
+ } = this.props;
+ const currentService = selectedService || services[0];
+
+ if (currentService) {
+ this.endTime = Date.now();
+ store.set('lastAtmSearchTimeframe', selectedTimeFrame);
+ store.set('lastAtmSearchService', this.getSelectedService());
+
+ const metricQueryPayload = {
+ quantile: 0.95,
+ endTs: this.endTime,
+ lookback: selectedTimeFrame,
+ step: 60 * 1000,
+ ratePer: 60 * 60 * 1000,
+ };
+
+ fetchAllServiceMetrics(currentService, metricQueryPayload);
+ fetchAggregatedServiceMetrics(currentService, metricQueryPayload);
+
+ this.setState({ serviceOpsMetrics: undefined, searchOps: '' });
+ }
+ }
+
+ getSelectedService() {
+ const { selectedService, services } = this.props;
+ return selectedService || store.get('lastAtmSearchService') || services[0];
+ }
+
+ render() {
+ const { services, metrics, selectedTimeFrame, servicesLoading } = this.props;
+
+ if (servicesLoading) {
+ return ;
+ }
+
+ if (metrics.isATMActivated === false) {
+ return ;
+ }
+
+ return (
+
+
+
+ Choose service
+ ({ label: s, value: s })),
+ required: true,
+ }}
+ />
+
+
+
+
+
+ Aggregation of all "{this.getSelectedService()}" metrics in selected timeframe.{' '}
+
+ View all traces
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Operations metrics under {this.getSelectedService()} {' '}
+ Over the {getLoopbackInterval(selectedTimeFrame)}
+
+
+ ) => {
+ const filteredData = metrics.serviceOpsMetrics!.filter(({ name }: { name: string }) => {
+ return name.toLowerCase().includes(e.target.value.toLowerCase());
+ });
+
+ this.setState({ searchOps: e.target.value });
+
+ this.setState({
+ serviceOpsMetrics: filteredData,
+ });
+ }}
+ />
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export function mapStateToProps(state: ReduxState): TReduxProps {
+ const { services, metrics } = state;
+ return {
+ services: services.services || [],
+ servicesLoading: services.loading,
+ metrics,
+ selectedService: serviceFormSelector(state, 'service') || store.get('lastAtmSearchService'),
+ selectedTimeFrame:
+ serviceFormSelector(state, 'timeframe') || store.get('lastAtmSearchTimeframe') || oneHourInMilliSeconds,
+ };
+}
+
+export function mapDispatchToProps(dispatch: Dispatch): TDispatchProps {
+ const { fetchServices, fetchAllServiceMetrics, fetchAggregatedServiceMetrics } = bindActionCreators(
+ jaegerApiActions,
+ dispatch
+ );
+
+ return {
+ fetchServices,
+ fetchAllServiceMetrics,
+ fetchAggregatedServiceMetrics,
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(
+ reduxForm({
+ form: 'serviceForm',
+ })(MonitorATMServicesViewImpl)
+);
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap
new file mode 100644
index 0000000000..1222d27460
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap
@@ -0,0 +1,1612 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` "Couldn’t fetch data" displayed 1`] = `
+
+ Couldn’t fetch data
+
+`;
+
+exports[` Loading indicator is displayed 1`] = `
+
+`;
+
+exports[` Table rendered successfully 1`] = `
+
+
+
+ Impact
+
+
+
+
+ ,
+ },
+ ]
+ }
+ dataSource={Array []}
+ indentSize={20}
+ loading={false}
+ locale={Object {}}
+ onRow={[Function]}
+ pagination={
+ Object {
+ "defaultPageSize": 20,
+ "pageSizeOptions": Array [
+ "20",
+ "50",
+ "100",
+ ],
+ "showSizeChanger": true,
+ }
+ }
+ prefixCls="ant-table"
+ rowClassName={[Function]}
+ rowKey="key"
+ showHeader={true}
+ size="default"
+ useFixedHeader={false}
+ />
+
+`;
+
+exports[` render No data table 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+
+
+
+
+ P95 Latency
+
+
+
+
+
+
+
+
+
+
+
+
+ Request rate
+
+
+
+
+
+
+
+
+
+
+
+
+ Error rate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Impact
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No data
+
+
+
+
+
+
+
+`;
+
+exports[` render some values in the table 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+
+
+
+
+ P95 Latency
+
+
+
+
+
+
+
+
+
+
+
+
+ Request rate
+
+
+
+
+
+
+
+
+
+
+
+
+ Error rate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Impact
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /PlaceOrder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` test column render function 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+
+
+
+
+
+ P95 Latency
+
+
+
+
+
+
+
+
+
+
+
+
+ Request rate
+
+
+
+
+
+
+
+
+
+
+
+
+ Error rate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Impact
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/__snapshots__/opsGraph.test.js.snap b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/__snapshots__/opsGraph.test.js.snap
new file mode 100644
index 0000000000..670e52c619
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/__snapshots__/opsGraph.test.js.snap
@@ -0,0 +1,80 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` "Couldn’t fetch data" displayed 1`] = `
+
+ Couldn’t fetch data
+
+`;
+
+exports[` "Mo data" is displayed 1`] = `
+
+ No Data
+
+`;
+
+exports[` Graph rendered successfully 1`] = `
+
+`;
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.css b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.css
new file mode 100644
index 0000000000..9aca30105f
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.css
@@ -0,0 +1,90 @@
+/*
+Copyright (c) 2021 The Jaeger Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+th.header-item {
+ background: rgba(216, 216, 216, 0.7);
+ border-left: 1px solid rgba(216, 216, 216, 1);
+ border-top: 1px solid rgba(216, 216, 216, 1);
+ border-bottom: 1px solid rgba(216, 216, 216, 1);
+ border-radius: 0px;
+ height: 40px;
+ font-size: 14px;
+ padding: 0 10px !important;
+ border-radius: 0px !important;
+}
+
+th.header-item:last-child {
+ border-right: 1px solid rgba(216, 216, 216, 1);
+}
+
+th.header-item span {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+td.header-item {
+ height: 50px;
+ border-bottom: 1px solid rgba(233, 233, 233, 1);
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0 10px !important;
+}
+
+.table-row:hover {
+ background: rgba(247, 247, 247, 1);
+}
+
+.ant-table-tbody > tr.ant-table-row-level-0:hover > td {
+ background: unset;
+}
+
+.ant-progress-inner {
+ border-radius: 0px;
+ background: rgba(199, 224, 224, 1);
+}
+
+.impact {
+ padding-top: 3px;
+ padding-right: 20px;
+ height: 20px;
+}
+
+.impact .ant-progress-bg {
+ height: 12px !important;
+}
+
+.table-graph-avg {
+ align-self: center;
+ margin-left: 12px;
+}
+
+.column-container {
+ display: flex;
+}
+
+.impact-tooltip .ant-tooltip-inner {
+ word-break: keep-all;
+}
+
+.view-trace-button {
+ width: 200px;
+ height: 30px;
+}
+
+.ops-table-error-placeholder {
+ vertical-align: middle;
+ text-align: center;
+}
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.test.js b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.test.js
new file mode 100644
index 0000000000..7dacc5751e
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.test.js
@@ -0,0 +1,296 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import OperationTableDetails from '.';
+import { originInitialState, serviceOpsMetrics } from '../../../../reducers/metrics.mock';
+
+const props = {
+ data: originInitialState.serviceOpsMetrics,
+ error: originInitialState.opsError,
+ loading: true,
+ endTime: 1632133918915,
+ lookback: 3600 * 1000,
+ serviceName: 'serviceName',
+ hoveredRowKey: [],
+};
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow( );
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('Loading indicator is displayed', () => {
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('"Couldn’t fetch data" displayed', () => {
+ const error = {
+ opsCalls: new Error('API Error'),
+ opsErrors: new Error('API Error'),
+ opsLatencies: new Error('API Error'),
+ };
+ wrapper.setProps({ ...props, loading: false, error });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Table rendered successfully', () => {
+ wrapper.setProps({ ...props, loading: false });
+ expect(wrapper).toMatchSnapshot();
+ });
+});
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount( );
+ });
+
+ it('render No data table', () => {
+ wrapper.setProps({ ...props, loading: false });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('render some values in the table', () => {
+ wrapper.setProps({ ...props, data: serviceOpsMetrics, loading: false });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('test column render function', () => {
+ wrapper.setProps({
+ ...props,
+ data: [
+ {
+ ...serviceOpsMetrics,
+ dataPoints: {
+ ...serviceOpsMetrics.dataPoints,
+ service_operation_call_rate: [],
+ service_operation_error_rate: [],
+ service_operation_latencies: [],
+ },
+ },
+ ],
+ loading: false,
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('highlight the row', () => {
+ wrapper.setProps({ ...props, data: serviceOpsMetrics, loading: false });
+ expect(wrapper.state('hoveredRowKey')).toBe(-1);
+
+ wrapper
+ .find('.table-row')
+ .at(0)
+ .simulate('mouseenter');
+ expect(wrapper.state('hoveredRowKey')).toBe(0);
+
+ wrapper
+ .find('.table-row')
+ .at(0)
+ .simulate('mouseleave');
+ expect(wrapper.state('hoveredRowKey')).toBe(-1);
+ });
+
+ it('highlight the row', () => {
+ wrapper.setProps({ ...props, data: serviceOpsMetrics, loading: false });
+ expect(wrapper.state('hoveredRowKey')).toBe(-1);
+
+ wrapper
+ .find('.table-row')
+ .at(0)
+ .simulate('mouseenter');
+ expect(wrapper.state('hoveredRowKey')).toBe(0);
+
+ wrapper
+ .find('.table-row')
+ .at(0)
+ .simulate('mouseleave');
+ expect(wrapper.state('hoveredRowKey')).toBe(-1);
+ });
+
+ it('sort row', () => {
+ const data = serviceOpsMetrics;
+ data.push({
+ dataPoints: {
+ avg: {
+ service_operation_call_rate: 0.02,
+ service_operation_error_rate: 2,
+ service_operation_latencies: 800.16,
+ },
+ service_operation_call_rate: [
+ {
+ x: 1631534436235,
+ y: 0.01,
+ },
+ {
+ x: 1631534496235,
+ y: 0.01,
+ },
+ ],
+ service_operation_error_rate: [
+ {
+ x: 1631534436235,
+ y: 1,
+ },
+ {
+ x: 1631534496235,
+ y: 1,
+ },
+ ],
+ service_operation_latencies: [
+ {
+ x: 1631534436235,
+ y: 737.33,
+ },
+ {
+ x: 1631534496235,
+ y: 735,
+ },
+ ],
+ },
+ errRates: 2,
+ impact: 0,
+ key: 1,
+ latency: 800.16,
+ name: '/Accounts',
+ requests: 0.002,
+ });
+
+ wrapper.setProps({ ...props, data, loading: false });
+
+ expect(
+ wrapper
+ .find('TableCell td')
+ .first()
+ .text()
+ ).toBe('/PlaceOrder');
+ // click on name
+ wrapper
+ .find('th Icon[type="caret-up"]')
+ .at(0)
+ .simulate('click');
+ expect(
+ wrapper
+ .find('TableCell td')
+ .first()
+ .text()
+ ).toBe('/Accounts');
+
+ // click on latencies
+ wrapper
+ .find('th Icon[type="caret-up"]')
+ .at(1)
+ .simulate('click');
+ expect(
+ wrapper
+ .find('TableCell td')
+ .first()
+ .text()
+ ).toBe('/PlaceOrder');
+
+ // click on request
+ wrapper
+ .find('th Icon[type="caret-up"]')
+ .at(2)
+ .simulate('click');
+ expect(
+ wrapper
+ .find('TableCell td')
+ .first()
+ .text()
+ ).toBe('/Accounts');
+
+ // click on errors
+ wrapper
+ .find('th Icon[type="caret-up"]')
+ .at(3)
+ .simulate('click');
+ expect(
+ wrapper
+ .find('TableCell td')
+ .first()
+ .text()
+ ).toBe('/PlaceOrder');
+
+ // click on errors
+ wrapper
+ .find('th Icon[type="caret-up"]')
+ .at(4)
+ .simulate('click');
+ expect(
+ wrapper
+ .find('TableCell td')
+ .first()
+ .text()
+ ).toBe('/Accounts');
+ });
+
+ it('Graph avg label test', () => {
+ const data = [
+ {
+ dataPoints: {
+ avg: {
+ service_operation_call_rate: 11,
+ service_operation_error_rate: 22,
+ service_operation_latencies: 99,
+ },
+ service_operation_call_rate: [],
+ service_operation_error_rate: [],
+ service_operation_latencies: [],
+ },
+ errRates: 1,
+ impact: 2,
+ key: 1,
+ latency: 3,
+ name: '/Accounts',
+ requests: 4,
+ },
+ ];
+
+ wrapper.setProps({ ...props, data, loading: false });
+
+ // Latency
+ expect(
+ wrapper
+ .find('div.table-graph-avg')
+ .at(0)
+ .text()
+ ).toBe('');
+
+ // Request rate
+ expect(
+ wrapper
+ .find('div.table-graph-avg')
+ .at(1)
+ .text()
+ ).toBe('');
+
+ // Error rate
+ expect(
+ wrapper
+ .find('div.table-graph-avg')
+ .at(2)
+ .text()
+ ).toBe('');
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.tsx b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.tsx
new file mode 100644
index 0000000000..002e6fd15c
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/index.tsx
@@ -0,0 +1,209 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from 'react';
+import { Row, Table, Progress, Button, Icon, Tooltip } from 'antd';
+import REDGraph from './opsGraph';
+import LoadingIndicator from '../../../common/LoadingIndicator';
+import { MetricsReduxState, ServiceOpsMetrics } from '../../../../types/metrics';
+import prefixUrl from '../../../../utils/prefix-url';
+
+import './index.css';
+
+type TProps = {
+ data: ServiceOpsMetrics[] | undefined;
+ error: MetricsReduxState['opsError'];
+ loading: boolean | null;
+ endTime: number;
+ lookback: number;
+ serviceName: string;
+};
+
+type TState = {
+ hoveredRowKey: number;
+};
+
+export class OperationTableDetails extends React.PureComponent {
+ state = {
+ hoveredRowKey: -1,
+ };
+
+ render() {
+ const { loading, error } = this.props;
+
+ if (loading) {
+ return ;
+ }
+
+ if (error.opsCalls && error.opsErrors && error.opsLatencies) {
+ return Couldn’t fetch data
;
+ }
+
+ const columnConfig = [
+ {
+ title: 'Name',
+ className: 'header-item',
+ dataIndex: 'name',
+ key: 'name',
+ sorter: (a: ServiceOpsMetrics, b: ServiceOpsMetrics) => a.name.localeCompare(b.name),
+ },
+ {
+ title: 'P95 Latency',
+ className: 'header-item',
+ dataIndex: 'latency',
+ key: 'latency',
+ sorter: (a: ServiceOpsMetrics, b: ServiceOpsMetrics) => a.latency - b.latency,
+ render: (value: ServiceOpsMetrics['latency'], row: ServiceOpsMetrics) => (
+
+
+
+ {typeof value === 'number' && row.dataPoints.service_operation_latencies.length > 0
+ ? `${value} ms`
+ : ''}
+
+
+ ),
+ },
+ {
+ title: 'Request rate',
+ className: 'header-item',
+ dataIndex: 'requests',
+ key: 'requests',
+ sorter: (a: ServiceOpsMetrics, b: ServiceOpsMetrics) => a.requests - b.requests,
+ render: (value: ServiceOpsMetrics['requests'], row: ServiceOpsMetrics) => (
+
+
+
+ {typeof value === 'number' && row.dataPoints.service_operation_call_rate.length > 0
+ ? `${value} req/s`
+ : ''}
+
+
+ ),
+ },
+ {
+ title: 'Error rate',
+ className: 'header-item',
+ dataIndex: 'errRates',
+ key: 'errRates',
+ sorter: (a: ServiceOpsMetrics, b: ServiceOpsMetrics) => a.errRates - b.errRates,
+ render: (value: ServiceOpsMetrics['errRates'], row: ServiceOpsMetrics) => (
+
+
+
+ {typeof value === 'number' && row.dataPoints.service_operation_error_rate.length > 0
+ ? `${value}%`
+ : ''}
+
+
+ ),
+ },
+ {
+ title: (
+
+
+ Impact
+
+
+
+
+
+ ),
+ className: 'header-item',
+ dataIndex: 'impact',
+ key: 'impact',
+ defaultSortOrder: 'descend' as 'ascend' | 'descend' | undefined,
+ sorter: (a: ServiceOpsMetrics, b: ServiceOpsMetrics) => a.impact - b.impact,
+ render: (value: ServiceOpsMetrics['impact'], row: ServiceOpsMetrics) => {
+ let viewTraceButton = null;
+ if (this.state.hoveredRowKey === row.key) {
+ const { endTime, lookback, serviceName } = this.props;
+ viewTraceButton = (
+
+ View traces
+
+ );
+ }
+
+ return {
+ children: (
+
+ ),
+ };
+ },
+ },
+ ];
+
+ return (
+
+ 'table-row'}
+ columns={columnConfig}
+ dataSource={this.props.data}
+ pagination={{ defaultPageSize: 20, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'] }}
+ onRow={() => {
+ return {
+ onMouseEnter: (event: any) => {
+ this.setState({
+ hoveredRowKey: parseInt(event.currentTarget.getAttribute('data-row-key'), 10),
+ });
+ },
+ onMouseLeave: () => {
+ this.setState({
+ hoveredRowKey: -1,
+ });
+ },
+ };
+ }}
+ />
+
+ );
+ }
+}
+
+export default OperationTableDetails;
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.css b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.css
new file mode 100644
index 0000000000..51bffa9f68
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.css
@@ -0,0 +1,29 @@
+/*
+Copyright (c) 2021 The Jaeger Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.ops-graph-placeholder {
+ vertical-align: middle;
+ text-align: center;
+ display: table-cell;
+}
+
+.ops-container {
+ padding-top: 2px;
+}
+
+.ops-graph-style {
+ opacity: 0.2;
+}
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.test.js b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.test.js
new file mode 100644
index 0000000000..0503f51f77
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.test.js
@@ -0,0 +1,50 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import OperationsGraph from './opsGraph';
+import { serviceOpsMetrics } from '../../../../reducers/metrics.mock';
+
+const props = {
+ color: '#FFFFFF',
+ dataPoints: [],
+ error: null,
+};
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow( );
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('"Mo data" is displayed', () => {
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('"Couldn’t fetch data" displayed', () => {
+ wrapper.setProps({ ...props, error: new Error('API Error') });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Graph rendered successfully', () => {
+ wrapper.setProps({ ...props, dataPoints: serviceOpsMetrics[0].dataPoints.service_operation_call_rate });
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.tsx b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.tsx
new file mode 100644
index 0000000000..08e1cc2ac1
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/operationDetailsTable/opsGraph.tsx
@@ -0,0 +1,87 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from 'react';
+import {
+ XYPlot,
+ AreaSeries,
+ LineSeries,
+ // @ts-ignore
+} from 'react-vis';
+import { ApiError } from '../../../../types/api-error';
+import { Points } from '../../../../types/metrics';
+
+import './opsGraph.css';
+
+type yDomain = [number, number];
+
+type TProps = {
+ color: string;
+ dataPoints: Points[];
+ yDomain?: yDomain;
+ error: null | ApiError;
+};
+
+// export for tests
+export class OperationsGraph extends React.PureComponent {
+ static generatePlaceholder(text: string) {
+ return {text}
;
+ }
+
+ render() {
+ const { dataPoints, yDomain, color, error } = this.props;
+
+ if (error) {
+ return OperationsGraph.generatePlaceholder('Couldn’t fetch data');
+ }
+
+ if (dataPoints.length === 0) {
+ return OperationsGraph.generatePlaceholder('No Data');
+ }
+
+ const dynProps: {
+ yDomain?: yDomain;
+ } = {};
+
+ if (yDomain) {
+ dynProps.yDomain = yDomain;
+ }
+
+ return (
+
+ );
+ }
+}
+
+export default OperationsGraph;
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.css b/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.css
new file mode 100644
index 0000000000..467b46c770
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.css
@@ -0,0 +1,50 @@
+/*
+Copyright (c) 2021 The Jaeger Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.legend-label span {
+ display: inherit !important;
+ margin-left: 10px !important;
+}
+
+.graph-container {
+ border: 1px solid #d7d7d7;
+ padding: 14px 12px 12px 12px;
+ border-radius: 4px;
+}
+
+.graph-header {
+ margin-bottom: 0;
+ font-size: 14;
+ font-weight: 500;
+}
+
+.latency-margins {
+ margin: 0px 7px 0px 0px;
+}
+
+.error-rate-margins {
+ margin: 0px 3px;
+}
+
+.request-margins {
+ margin: 0px 0px 0px 7px;
+}
+
+.center-placeholder {
+ vertical-align: middle;
+ text-align: center;
+ display: table-cell;
+}
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.test.js b/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.test.js
new file mode 100644
index 0000000000..25bcc51807
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.test.js
@@ -0,0 +1,148 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import ServiceGraph, { tickFormat } from './serviceGraph';
+import { serviceMetrics } from '../../../reducers/metrics.mock';
+
+const props = {
+ width: 300,
+ error: null,
+ name: 'Hello Graph',
+ metricsData: null,
+ loading: true,
+ marginClassName: '',
+ crosshairValues: [],
+};
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow( );
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('Loading indicator is displayed', () => {
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('"No data" displayed', () => {
+ wrapper.setProps({ ...props, loading: false });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('"Couldn’t fetch data" displayed', () => {
+ wrapper.setProps({ ...props, loading: false, error: new Error('API Error') });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Base graph should be displayed ', () => {
+ wrapper.setProps({ ...props, loading: false, metricsData: serviceMetrics.service_call_rate });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Base graph with legends should be displayed', () => {
+ wrapper.setProps({
+ ...props,
+ loading: false,
+ metricsData: serviceMetrics.service_call_rate,
+ showLegend: true,
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Base graph with horizontal lines should be displayed', () => {
+ wrapper.setProps({
+ ...props,
+ loading: false,
+ metricsData: serviceMetrics.service_call_rate,
+ showHorizontalLines: true,
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Base graph with custom color should be displayed', () => {
+ wrapper.setProps({
+ ...props,
+ loading: false,
+ metricsData: serviceMetrics.service_call_rate,
+ color: 'AAAAAA',
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Crosshair map test', () => {
+ wrapper.setProps({
+ ...props,
+ loading: false,
+ metricsData: serviceMetrics.service_call_rate,
+ crosshairValues: [
+ {
+ key: 1,
+ ...serviceMetrics.service_call_rate.metricPoints[0],
+ },
+ ],
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('Crosshair mouse hover test', () => {
+ wrapper.setProps({
+ ...props,
+ loading: false,
+ metricsData: {
+ ...serviceMetrics.service_call_rate,
+ metricPoints: [{ x: 1631271783806, y: null }, ...serviceMetrics.service_call_rate.metricPoints],
+ },
+ crosshairValues: [
+ {
+ key: 1,
+ ...serviceMetrics.service_call_rate.metricPoints[0],
+ },
+ ],
+ });
+ expect(wrapper.find('AreaSeries').render()).toMatchSnapshot();
+ expect(wrapper.find('LineSeries').render()).toMatchSnapshot();
+ });
+
+ it('AreaSeries onNearestX, XYPlot onMouseLeave', () => {
+ wrapper.setProps({
+ ...props,
+ loading: false,
+ metricsData: serviceMetrics.service_call_rate,
+ });
+ wrapper
+ .find('AreaSeries')
+ .at(0)
+ .prop('onNearestX')({ x: 1, y: 2 }, { index: 7 });
+ expect(wrapper.state().crosshairValues).toEqual([{ label: 0.95 }]);
+ wrapper.find('XYPlot').prop('onMouseLeave')();
+ expect(wrapper.state().crosshairValues).toEqual([]);
+ });
+});
+
+describe(' util', () => {
+ it('tickFormat singular', () => {
+ expect(tickFormat(Date.UTC(2017, 1, 14, 15, 19)).valueOf()).toBe('15:19');
+ });
+
+ it('tickFormat plural', () => {
+ expect(tickFormat(Date.UTC(2017, 1, 14, 4, 4)).valueOf()).toBe('04:04');
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.tsx b/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.tsx
new file mode 100644
index 0000000000..0e6d03ba1a
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/ServicesView/serviceGraph.tsx
@@ -0,0 +1,219 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from 'react';
+import {
+ XYPlot,
+ XAxis,
+ YAxis,
+ HorizontalGridLines,
+ LineSeries,
+ AreaSeries,
+ Crosshair,
+ DiscreteColorLegend,
+ // @ts-ignore
+} from 'react-vis';
+import LoadingIndicator from '../../common/LoadingIndicator';
+import { ServiceMetricsObject, Points } from '../../../types/metrics';
+
+import './serviceGraph.css';
+import { ApiError } from '../../../types/api-error';
+
+type TProps = {
+ width: number;
+ error: null | ApiError;
+ name: string;
+ metricsData: ServiceMetricsObject | ServiceMetricsObject[] | null;
+ loading: boolean;
+ showLegend?: boolean;
+ showHorizontalLines?: boolean;
+ yDomain?: number[];
+ color?: string;
+ marginClassName?: string;
+};
+
+type TCrossHairValues = {
+ label: number;
+ x: number;
+ y: number | null;
+};
+
+// export for tests
+export const tickFormat = (v: number) => {
+ const dateObj = new Date(v);
+ const hours = dateObj.getHours().toString();
+ const minutes = dateObj.getMinutes().toString();
+
+ return `${hours.length === 1 ? `0${hours}` : hours}:${minutes.length === 1 ? `0${minutes}` : minutes}`;
+};
+
+// export for tests
+export class ServiceGraphImpl extends React.PureComponent {
+ height = 242;
+ colors: string[] = ['#DCA3D2', '#EA9977', '#869ADD'];
+ state: { crosshairValues: TCrossHairValues[] } = {
+ crosshairValues: [],
+ };
+
+ getData(): ServiceMetricsObject[] {
+ const { metricsData } = this.props;
+
+ // istanbul ignore next : TS required to check, but we do it in render function
+ if (metricsData === null) {
+ return [];
+ }
+
+ return Array.isArray(metricsData) ? metricsData : [metricsData];
+ }
+
+ renderLines() {
+ const { metricsData, color } = this.props;
+
+ if (metricsData) {
+ const graphs: any = [];
+ let i = 0;
+
+ this.getData().forEach((line: ServiceMetricsObject, idx: number) => {
+ graphs.push(
+ d.y !== null}
+ onNearestX={(_datapoint: Points, { index }: { index: number }) => {
+ this.setState({
+ crosshairValues: this.getData().map((d: ServiceMetricsObject) => ({
+ ...d.metricPoints[index],
+ label: d.quantile,
+ })),
+ });
+ }}
+ opacity={0.1}
+ color={[color || this.colors[idx]]}
+ />
+ );
+ graphs.push(
+ d.y !== null}
+ key={i++}
+ data={line.metricPoints ? line.metricPoints : []}
+ color={[color || this.colors[idx]]}
+ />
+ );
+ });
+
+ return graphs;
+ }
+
+ return [];
+ }
+
+ generatePlaceholder(placeHolder: string | JSX.Element) {
+ const { width } = this.props;
+
+ return (
+
+ {placeHolder}
+
+ );
+ }
+
+ render() {
+ const {
+ width,
+ yDomain,
+ showHorizontalLines,
+ showLegend,
+ loading,
+ metricsData,
+ marginClassName,
+ name,
+ error,
+ } = this.props;
+ let GraphComponent = this.generatePlaceholder( );
+ const noDataComponent = this.generatePlaceholder('No Data');
+ const apiErrorComponent = this.generatePlaceholder('Couldn’t fetch data');
+
+ const Plot = (
+ this.setState({ crosshairValues: [] })}
+ width={width}
+ height={this.height - 74}
+ yDomain={yDomain}
+ >
+ {showHorizontalLines ? : null}
+
+
+ {this.renderLines()}
+
+
+ {this.state.crosshairValues[0] &&
+ `${new Date(this.state.crosshairValues[0].x).toLocaleDateString()} ${new Date(
+ this.state.crosshairValues[0].x
+ ).toLocaleTimeString()}`}
+ {this.state.crosshairValues.reverse().map((d: TCrossHairValues) =>
+ showLegend ? (
+
+ P{d.label * 100}: {d.y}
+
+ ) : (
+
{d.y}
+ )
+ )}
+
+
+ {showLegend ? (
+ ({
+ color: this.colors[idx],
+ title: `${d.quantile * 100}th`,
+ }))
+ .reverse()}
+ />
+ ) : null}
+
+ );
+
+ if (!loading) {
+ GraphComponent = metricsData === null ? noDataComponent : Plot;
+ }
+
+ if (error) {
+ GraphComponent = apiErrorComponent;
+ }
+
+ return (
+
+
{name}
+ {GraphComponent}
+
+ );
+ }
+}
+
+export default ServiceGraphImpl;
diff --git a/packages/jaeger-ui/src/components/Monitor/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitor/__snapshots__/index.test.js.snap
new file mode 100644
index 0000000000..b915bf3513
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/__snapshots__/index.test.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Component is displayed 1`] = ` `;
diff --git a/packages/jaeger-ui/src/components/Monitor/index.test.js b/packages/jaeger-ui/src/components/Monitor/index.test.js
new file mode 100644
index 0000000000..b60f14935a
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/index.test.js
@@ -0,0 +1,33 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { shallow } from 'enzyme';
+import MonitorATMPage from '.';
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallow( );
+ });
+
+ it('does not explode', () => {
+ expect(wrapper.length).toBe(1);
+ });
+
+ it('Component is displayed', () => {
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/index.tsx b/packages/jaeger-ui/src/components/Monitor/index.tsx
new file mode 100644
index 0000000000..8b9a873738
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/index.tsx
@@ -0,0 +1,20 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from 'react';
+import MonitorATMServicesView from './ServicesView';
+
+const MonitorATMPage = () => ;
+
+export default MonitorATMPage;
diff --git a/packages/jaeger-ui/src/components/Monitor/url.test.js b/packages/jaeger-ui/src/components/Monitor/url.test.js
new file mode 100644
index 0000000000..b566babc4e
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/url.test.js
@@ -0,0 +1,27 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { ROUTE_PATH, matches, getUrl } from './url';
+
+describe('Monitor/url', () => {
+ it('matches', () => {
+ expect(matches('/monitor')).toBe(true);
+ expect(matches('/monitor?var=123')).toBe(false);
+ expect(matches('/bla')).toBe(false);
+ });
+
+ it('getUrl', () => {
+ expect(getUrl()).toBe(ROUTE_PATH);
+ });
+});
diff --git a/packages/jaeger-ui/src/components/Monitor/url.tsx b/packages/jaeger-ui/src/components/Monitor/url.tsx
new file mode 100644
index 0000000000..89d6f6e46a
--- /dev/null
+++ b/packages/jaeger-ui/src/components/Monitor/url.tsx
@@ -0,0 +1,29 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { matchPath } from 'react-router-dom';
+
+import prefixUrl from '../../utils/prefix-url';
+
+export const ROUTE_PATH = prefixUrl('/monitor');
+
+const ROUTE_MATCHER = { path: ROUTE_PATH, strict: true, exact: true };
+
+export function matches(path: string) {
+ return Boolean(matchPath(path, ROUTE_MATCHER));
+}
+
+export function getUrl() {
+ return ROUTE_PATH;
+}
diff --git a/packages/jaeger-ui/src/components/common/LoadingIndicator.css b/packages/jaeger-ui/src/components/common/LoadingIndicator.css
index b576c37ab4..7c7b2c71bf 100644
--- a/packages/jaeger-ui/src/components/common/LoadingIndicator.css
+++ b/packages/jaeger-ui/src/components/common/LoadingIndicator.css
@@ -41,6 +41,11 @@ limitations under the License.
margin-right: auto;
}
+.LoadingIndicator.is-vcentered {
+ display: block;
+ margin-top: calc(100vh - 50% - 18px);
+}
+
.LoadingIndicator.is-small {
font-size: 0.7em;
}
diff --git a/packages/jaeger-ui/src/components/common/LoadingIndicator.tsx b/packages/jaeger-ui/src/components/common/LoadingIndicator.tsx
index 02a906b0fa..e379e93dcc 100644
--- a/packages/jaeger-ui/src/components/common/LoadingIndicator.tsx
+++ b/packages/jaeger-ui/src/components/common/LoadingIndicator.tsx
@@ -19,15 +19,18 @@ import './LoadingIndicator.css';
type LoadingIndicatorProps = {
centered?: boolean;
+ vcentered?: boolean;
className?: string;
small?: boolean;
+ style?: React.CSSProperties;
};
export default function LoadingIndicator(props: LoadingIndicatorProps) {
- const { centered, className, small, ...rest } = props;
+ const { centered, vcentered, className, small, ...rest } = props;
const cls = `
LoadingIndicator
${centered ? 'is-centered' : ''}
+ ${vcentered ? 'is-vcentered' : ''}
${small ? 'is-small' : ''}
${className || ''}
`;
diff --git a/packages/jaeger-ui/src/reducers/index.js b/packages/jaeger-ui/src/reducers/index.js
index 7b23c2ab32..a109e8a387 100644
--- a/packages/jaeger-ui/src/reducers/index.js
+++ b/packages/jaeger-ui/src/reducers/index.js
@@ -20,6 +20,7 @@ import ddg from './ddg';
import pathAgnosticDecorations from './path-agnostic-decorations';
import embedded from './embedded';
import services from './services';
+import metrics from './metrics';
import trace from './trace';
export default {
@@ -29,6 +30,7 @@ export default {
embedded,
pathAgnosticDecorations,
services,
+ metrics,
trace,
form: formReducer,
};
diff --git a/packages/jaeger-ui/src/reducers/metrics.mock.js b/packages/jaeger-ui/src/reducers/metrics.mock.js
new file mode 100644
index 0000000000..5ce8316246
--- /dev/null
+++ b/packages/jaeger-ui/src/reducers/metrics.mock.js
@@ -0,0 +1,851 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+const serviceLatencies50 = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_latencies',
+ type: 'GAUGE',
+ help: '0.5th quantile latency, grouped by service',
+ quantile: 0.5,
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'service_name',
+ value: 'cartservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 189.85919098822325,
+ },
+ timestamp: '2021-09-10T11:03:43.806Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 189.85926305015352,
+ },
+ timestamp: '2021-09-10T11:04:43.806Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceLatencies50withNull = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_latencies',
+ type: 'GAUGE',
+ help: '0.5th quantile latency, grouped by service',
+ quantile: 0.5,
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'service_name',
+ value: 'cartservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: null,
+ },
+ timestamp: '2021-09-10T11:03:43.806Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: '189.85926305015352',
+ },
+ timestamp: '2021-09-10T11:04:43.806Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceLatencies75 = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_latencies',
+ type: 'GAUGE',
+ help: '0.75th quantile latency, grouped by service',
+ quantile: 0.75,
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'service_name',
+ value: 'cartservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 189.85919098822325,
+ },
+ timestamp: '2021-09-10T11:03:43.806Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 189.85926305015352,
+ },
+ timestamp: '2021-09-10T11:04:43.806Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceLatencies95 = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_latencies',
+ type: 'GAUGE',
+ help: '0.95th quantile latency, grouped by service',
+ quantile: 0.95,
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'service_name',
+ value: 'cartservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 189.85919098822325,
+ },
+ timestamp: '2021-09-10T11:03:43.806Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 189.85926305015352,
+ },
+ timestamp: '2021-09-10T11:04:43.806Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceCalls95Response = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_call_rate',
+ type: 'GAUGE',
+ help: 'calls/sec, grouped by service',
+ quantile: 0.95,
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'service_name',
+ value: 'cartservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 0.05256012294063042,
+ },
+ timestamp: '2021-09-10T11:03:43.806Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 0.05228198343384174,
+ },
+ timestamp: '2021-09-10T11:04:43.806Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceErrors95Response = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_error_rate',
+ type: 'GAUGE',
+ help: 'error rate, computed as a fraction of errors/sec over calls/sec, grouped by service',
+ quantile: 0.95,
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'service_name',
+ value: 'cartservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-10T11:52:27.520Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-10T11:53:27.520Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+const serviceOpsLatencies = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_operation_latencies',
+ type: 'GAUGE',
+ help: '0.95th quantile latency, grouped by service \u0026 operation',
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/PlaceOrder',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 737.3333333333333,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 735,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+const serviceOpsLatenciesWithNull = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_operation_latencies',
+ type: 'GAUGE',
+ help: '0.95th quantile latency, grouped by service \u0026 operation',
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/PlaceOrder',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 3,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/Checkout',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: '737.3333333333333',
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: null,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+const serviceOpsErrors = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_operation_error_rate',
+ type: 'GAUGE',
+ help:
+ 'error rate, computed as a fraction of errors/sec over calls/sec, grouped by service \u0026 operation',
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/PlaceOrder',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceOpsErrorsWithNull = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_operation_error_rate',
+ type: 'GAUGE',
+ help:
+ 'error rate, computed as a fraction of errors/sec over calls/sec, grouped by service \u0026 operation',
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/PlaceOrder',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: '737.3333333333333',
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: null,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/Checkout',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 1,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+const serviceOpsCalls = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_operation_call_rate',
+ type: 'GAUGE',
+ help: 'calls/sec, grouped by service \u0026 operation',
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/PlaceOrder',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 0.012657559165859726,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 0.014052384663085615,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+const serviceOpsCallsWithNull = {
+ status: 'fulfilled',
+ value: {
+ name: 'service_operation_call_rate',
+ type: 'GAUGE',
+ help: 'calls/sec, grouped by service \u0026 operation',
+ metrics: [
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/PlaceOrder',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 0.012657559165859726,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 0.031052384663085615,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ {
+ labels: [
+ {
+ name: 'operation',
+ value: '/Checkout',
+ },
+ {
+ name: 'service_name',
+ value: 'checkoutservice',
+ },
+ ],
+ metricPoints: [
+ {
+ gaugeValue: {
+ doubleValue: 0.012657559165859726,
+ },
+ timestamp: '2021-09-13T12:00:36.235Z',
+ },
+ {
+ gaugeValue: {
+ doubleValue: 0.014052384663085615,
+ },
+ timestamp: '2021-09-13T12:01:36.235Z',
+ },
+ ],
+ },
+ ],
+ },
+};
+
+const emptyResponse = responseWithData => {
+ return {
+ ...responseWithData,
+ value: {
+ ...responseWithData.value,
+ metrics: [],
+ },
+ };
+};
+
+const originInitialState = {
+ serviceError: {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ opsError: {
+ opsLatencies: null,
+ opsCalls: null,
+ opsErrors: null,
+ },
+ isATMActivated: null,
+ loading: false,
+ operationMetricsLoading: null,
+ serviceMetrics: null,
+ serviceOpsMetrics: undefined,
+};
+
+const serviceMetrics = {
+ service_call_rate: {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 0.05,
+ },
+ {
+ x: 1631271883806,
+ y: 0.05,
+ },
+ ],
+ quantile: 0.95,
+ serviceName: 'cartservice',
+ },
+ service_error_rate: {
+ metricPoints: [
+ {
+ x: 1631274747520,
+ y: 1,
+ },
+ {
+ x: 1631274807520,
+ y: 1,
+ },
+ ],
+ quantile: 0.95,
+ serviceName: 'cartservice',
+ },
+ service_latencies: [
+ {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 189.86,
+ },
+ {
+ x: 1631271883806,
+ y: 189.86,
+ },
+ ],
+ quantile: 0.5,
+ serviceName: 'cartservice',
+ },
+ {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 189.86,
+ },
+ {
+ x: 1631271883806,
+ y: 189.86,
+ },
+ ],
+ quantile: 0.75,
+ serviceName: 'cartservice',
+ },
+ {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 189.86,
+ },
+ {
+ x: 1631271883806,
+ y: 189.86,
+ },
+ ],
+ quantile: 0.95,
+ serviceName: 'cartservice',
+ },
+ ],
+};
+const serviceMetricsWithNulls = {
+ service_call_rate: {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 0.05,
+ },
+ {
+ x: 1631271883806,
+ y: 0.05,
+ },
+ ],
+ quantile: 0.95,
+ serviceName: 'cartservice',
+ },
+ service_error_rate: {
+ metricPoints: [
+ {
+ x: 1631274747520,
+ y: 1,
+ },
+ {
+ x: 1631274807520,
+ y: 1,
+ },
+ ],
+ quantile: 0.95,
+ serviceName: 'cartservice',
+ },
+ service_latencies: [
+ {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: null,
+ },
+ {
+ x: 1631271883806,
+ y: null,
+ },
+ ],
+ quantile: 0.5,
+ serviceName: 'cartservice',
+ },
+ {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 189.86,
+ },
+ {
+ x: 1631271883806,
+ y: 189.86,
+ },
+ ],
+ quantile: 0.75,
+ serviceName: 'cartservice',
+ },
+ {
+ metricPoints: [
+ {
+ x: 1631271823806,
+ y: 189.86,
+ },
+ {
+ x: 1631271883806,
+ y: 189.86,
+ },
+ ],
+ quantile: 0.95,
+ serviceName: 'cartservice',
+ },
+ ],
+};
+const serviceOpsMetrics = [
+ {
+ dataPoints: {
+ avg: {
+ service_operation_call_rate: 0.01,
+ service_operation_error_rate: 1,
+ service_operation_latencies: 736.16,
+ },
+ service_operation_call_rate: [
+ {
+ x: 1631534436235,
+ y: 0.01,
+ },
+ {
+ x: 1631534496235,
+ y: 0.01,
+ },
+ ],
+ service_operation_error_rate: [
+ {
+ x: 1631534436235,
+ y: 1,
+ },
+ {
+ x: 1631534496235,
+ y: 1,
+ },
+ ],
+ service_operation_latencies: [
+ {
+ x: 1631534436235,
+ y: 737.33,
+ },
+ {
+ x: 1631534496235,
+ y: 735,
+ },
+ ],
+ },
+ errRates: 1,
+ impact: 1,
+ key: 0,
+ latency: 736.16,
+ name: '/PlaceOrder',
+ requests: 0.01,
+ },
+];
+const serviceOpsMetricsWithNull = [
+ {
+ dataPoints: {
+ avg: {
+ service_operation_call_rate: 0.02,
+ service_operation_error_rate: 0,
+ service_operation_latencies: 2,
+ },
+ service_operation_call_rate: [
+ {
+ x: 1631534436235,
+ y: 0.01,
+ },
+ {
+ x: 1631534496235,
+ y: 0.03,
+ },
+ ],
+ service_operation_error_rate: [
+ {
+ x: 1631534436235,
+ y: null,
+ },
+ {
+ x: 1631534496235,
+ y: null,
+ },
+ ],
+ service_operation_latencies: [
+ {
+ x: 1631534436235,
+ y: 1,
+ },
+ {
+ x: 1631534496235,
+ y: 3,
+ },
+ ],
+ },
+ errRates: 0,
+ impact: 1,
+ key: 0,
+ latency: 2,
+ name: '/PlaceOrder',
+ requests: 0.02,
+ },
+ {
+ dataPoints: {
+ avg: {
+ service_operation_call_rate: 0.01,
+ service_operation_error_rate: 1,
+ service_operation_latencies: 0,
+ },
+ service_operation_call_rate: [
+ {
+ x: 1631534436235,
+ y: 0.01,
+ },
+ {
+ x: 1631534496235,
+ y: 0.01,
+ },
+ ],
+ service_operation_error_rate: [
+ {
+ x: 1631534436235,
+ y: 1,
+ },
+ {
+ x: 1631534496235,
+ y: 1,
+ },
+ ],
+ service_operation_latencies: [
+ {
+ x: 1631534436235,
+ y: null,
+ },
+ {
+ x: 1631534496235,
+ y: null,
+ },
+ ],
+ },
+ errRates: 1,
+ impact: 0,
+ key: 1,
+ latency: 0,
+ name: '/Checkout',
+ requests: 0.01,
+ },
+];
+
+export {
+ serviceLatencies50,
+ serviceLatencies75,
+ serviceLatencies95,
+ serviceCalls95Response,
+ serviceErrors95Response,
+ serviceOpsLatencies,
+ serviceOpsErrors,
+ serviceOpsCalls,
+ emptyResponse,
+ originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ serviceLatencies50withNull,
+ serviceMetricsWithNulls,
+ serviceOpsLatenciesWithNull,
+ serviceOpsMetricsWithNull,
+ serviceOpsCallsWithNull,
+ serviceOpsErrorsWithNull,
+};
diff --git a/packages/jaeger-ui/src/reducers/metrics.test.js b/packages/jaeger-ui/src/reducers/metrics.test.js
new file mode 100644
index 0000000000..6bfd6d90af
--- /dev/null
+++ b/packages/jaeger-ui/src/reducers/metrics.test.js
@@ -0,0 +1,384 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { fetchAllServiceMetrics, fetchAggregatedServiceMetrics } from '../actions/jaeger-api';
+import metricReducer from './metrics';
+import {
+ serviceLatencies50,
+ serviceLatencies75,
+ serviceLatencies95,
+ serviceCalls95Response,
+ serviceErrors95Response,
+ serviceOpsLatencies,
+ serviceOpsErrors,
+ serviceOpsCalls,
+ emptyResponse,
+ originInitialState,
+ serviceMetrics,
+ serviceOpsMetrics,
+ serviceLatencies50withNull,
+ serviceMetricsWithNulls,
+ serviceOpsLatenciesWithNull,
+ serviceOpsMetricsWithNull,
+ serviceOpsCallsWithNull,
+ serviceOpsErrorsWithNull,
+} from './metrics.mock';
+
+const initialState = metricReducer(undefined, {});
+
+describe('reducers/fetchAllServiceMetrics', () => {
+ function verifyInitialState() {
+ expect(initialState).toEqual(originInitialState);
+ }
+
+ beforeEach(verifyInitialState);
+ afterEach(verifyInitialState);
+
+ it('Pending state ', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_PENDING`,
+ });
+
+ expect(state).toEqual({
+ ...initialState,
+ loading: true,
+ });
+ });
+
+ it('payload is undefined', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_FULFILLED`,
+ payload: undefined,
+ });
+
+ const expected = {
+ ...initialState,
+ isATMActivated: true,
+ loading: false,
+ serviceMetrics: {
+ service_latencies: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ serviceError: {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+
+ describe('handle successful payload', () => {
+ it('no metric data', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_FULFILLED`,
+ payload: [
+ emptyResponse(serviceLatencies50),
+ emptyResponse(serviceLatencies75),
+ emptyResponse(serviceLatencies95),
+ emptyResponse(serviceCalls95Response),
+ emptyResponse(serviceErrors95Response),
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ isATMActivated: true,
+ loading: false,
+ serviceMetrics: {
+ service_latencies: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ serviceError: {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+
+ it('null checks', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_FULFILLED`,
+ payload: [
+ serviceLatencies50withNull,
+ serviceLatencies75,
+ serviceLatencies95,
+ serviceCalls95Response,
+ serviceErrors95Response,
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ isATMActivated: true,
+ loading: false,
+ serviceMetrics: serviceMetricsWithNulls,
+ serviceError: {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+
+ it('all data are correct', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_FULFILLED`,
+ payload: [
+ serviceLatencies50,
+ serviceLatencies75,
+ serviceLatencies95,
+ serviceCalls95Response,
+ serviceErrors95Response,
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ isATMActivated: true,
+ loading: false,
+ serviceMetrics,
+ serviceError: {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+ });
+
+ describe('handle rejected results', () => {
+ it('some generic error', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_FULFILLED`,
+ payload: [
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ isATMActivated: true,
+ loading: false,
+ serviceMetrics: {
+ service_latencies: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ serviceError: {
+ service_latencies_50: 'Error',
+ service_latencies_75: 'Error',
+ service_latencies_95: 'Error',
+ service_call_rate: 'Error',
+ service_error_rate: 'Error',
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+
+ it('501 Not Implemented error', () => {
+ const notImplementedRejection = {
+ httpStatus: 501,
+ };
+ const state = metricReducer(initialState, {
+ type: `${fetchAllServiceMetrics}_FULFILLED`,
+ payload: [
+ {
+ status: 'rejected',
+ reason: notImplementedRejection,
+ },
+ {
+ status: 'rejected',
+ reason: notImplementedRejection,
+ },
+ {
+ status: 'rejected',
+ reason: notImplementedRejection,
+ },
+ {
+ status: 'rejected',
+ reason: notImplementedRejection,
+ },
+ {
+ status: 'rejected',
+ reason: notImplementedRejection,
+ },
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ isATMActivated: false,
+ loading: false,
+ serviceMetrics: {
+ service_latencies: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ serviceError: {
+ service_latencies_50: notImplementedRejection,
+ service_latencies_75: notImplementedRejection,
+ service_latencies_95: notImplementedRejection,
+ service_call_rate: notImplementedRejection,
+ service_error_rate: notImplementedRejection,
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+ });
+});
+
+describe('reducers/fetchAggregatedServiceMetrics', () => {
+ function verifyInitialState() {
+ expect(initialState).toEqual(originInitialState);
+ }
+
+ beforeEach(verifyInitialState);
+ afterEach(verifyInitialState);
+
+ it('Pending state ', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAggregatedServiceMetrics}_PENDING`,
+ });
+
+ expect(state).toEqual({
+ ...initialState,
+ operationMetricsLoading: true,
+ });
+ });
+
+ it('payload is undefined', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAggregatedServiceMetrics}_FULFILLED`,
+ payload: undefined,
+ });
+
+ const expected = {
+ ...initialState,
+ operationMetricsLoading: false,
+ };
+ expect(state).toEqual(expected);
+ });
+
+ it('handle rejected results', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAggregatedServiceMetrics}_FULFILLED`,
+ payload: [
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ {
+ status: 'rejected',
+ reason: 'Error',
+ },
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ operationMetricsLoading: false,
+ opsError: {
+ opsLatencies: 'Error',
+ opsCalls: 'Error',
+ opsErrors: 'Error',
+ },
+ };
+ expect(state).toEqual(expected);
+ });
+
+ describe('handle payload', () => {
+ it('no metric data', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAggregatedServiceMetrics}_FULFILLED`,
+ payload: [
+ emptyResponse(serviceOpsLatencies),
+ emptyResponse(serviceOpsCalls),
+ emptyResponse(serviceOpsErrors),
+ ],
+ });
+
+ const expected = {
+ ...initialState,
+ operationMetricsLoading: false,
+ };
+ expect(state).toEqual(expected);
+ });
+ });
+
+ it('null checks', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAggregatedServiceMetrics}_FULFILLED`,
+ payload: [serviceOpsLatenciesWithNull, serviceOpsCallsWithNull, serviceOpsErrorsWithNull],
+ });
+
+ const expected = {
+ ...initialState,
+ operationMetricsLoading: false,
+ serviceOpsMetrics: serviceOpsMetricsWithNull,
+ };
+ expect(state).toEqual(expected);
+ });
+
+ it('all data are correct', () => {
+ const state = metricReducer(initialState, {
+ type: `${fetchAggregatedServiceMetrics}_FULFILLED`,
+ payload: [serviceOpsLatencies, serviceOpsCalls, serviceOpsErrors],
+ });
+
+ const expected = {
+ ...initialState,
+ operationMetricsLoading: false,
+ serviceOpsMetrics,
+ };
+ expect(state).toEqual(expected);
+ });
+});
diff --git a/packages/jaeger-ui/src/reducers/metrics.tsx b/packages/jaeger-ui/src/reducers/metrics.tsx
new file mode 100644
index 0000000000..a49c2d9963
--- /dev/null
+++ b/packages/jaeger-ui/src/reducers/metrics.tsx
@@ -0,0 +1,319 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/* eslint-disable camelcase */
+/* eslint-disable default-case */
+
+import { handleActions } from 'redux-actions';
+
+import { fetchAllServiceMetrics, fetchAggregatedServiceMetrics } from '../actions/jaeger-api';
+import {
+ MetricPointObject,
+ MetricsReduxState,
+ ServiceMetrics,
+ MetricObject,
+ ServiceMetricsObject,
+ ServiceOpsMetrics,
+ OpsDataPoints,
+ FetchedAllServiceMetricsResponse,
+ PromiseRejectedResult,
+ FetchAggregatedServiceMetricsResponse,
+} from '../types/metrics';
+
+const initialState: MetricsReduxState = {
+ serviceError: {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ },
+ opsError: {
+ opsLatencies: null,
+ opsCalls: null,
+ opsErrors: null,
+ },
+ isATMActivated: null,
+ loading: false,
+ operationMetricsLoading: null,
+ serviceMetrics: null,
+ serviceOpsMetrics: undefined,
+};
+
+function fetchStarted(state: MetricsReduxState) {
+ return {
+ ...state,
+ serviceError: initialState.serviceError,
+ loading: true,
+ };
+}
+
+function fetchServiceMetricsDone(
+ state: MetricsReduxState,
+ { payload }: { payload?: FetchedAllServiceMetricsResponse }
+) {
+ const serviceMetrics: ServiceMetrics = {
+ service_latencies: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ };
+ const serviceError: MetricsReduxState['serviceError'] = {
+ service_latencies_50: null,
+ service_latencies_75: null,
+ service_latencies_95: null,
+ service_call_rate: null,
+ service_error_rate: null,
+ };
+ let isATMActivated = true;
+
+ if (payload) {
+ payload.forEach((promiseResult, i) => {
+ if (promiseResult.status === 'fulfilled') {
+ const metrics = promiseResult.value;
+ if (metrics.metrics[0]) {
+ const metric: ServiceMetricsObject = {
+ serviceName: metrics.metrics[0].labels[0].value,
+ quantile: metrics.quantile,
+ metricPoints: metrics.metrics[0].metricPoints.map((p: MetricPointObject) => {
+ let y;
+ try {
+ y = parseFloat(p.gaugeValue.doubleValue.toFixed(2));
+ } catch (e) {
+ y = null;
+ }
+
+ return {
+ x: new Date(p.timestamp).getTime(),
+ y,
+ };
+ }),
+ };
+
+ if (metrics.name === 'service_latencies') {
+ if (serviceMetrics[metrics.name] === null) {
+ serviceMetrics[metrics.name] = [];
+ }
+ serviceMetrics[metrics.name]!.push(metric);
+ } else {
+ serviceMetrics[metrics.name] = metric;
+ }
+ }
+ } else {
+ if (
+ typeof (promiseResult as PromiseRejectedResult).reason === 'object' &&
+ (promiseResult as PromiseRejectedResult).reason.httpStatus === 501
+ ) {
+ isATMActivated = false;
+ }
+
+ switch (i) {
+ case 0:
+ serviceError.service_latencies_50 = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ case 1:
+ serviceError.service_latencies_75 = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ case 2:
+ serviceError.service_latencies_95 = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ case 3:
+ serviceError.service_call_rate = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ case 4:
+ serviceError.service_error_rate = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ }
+ }
+ });
+ }
+ return { ...state, serviceMetrics, serviceError, loading: false, isATMActivated };
+}
+
+function fetchOpsMetricsStarted(state: MetricsReduxState) {
+ return {
+ ...state,
+ opsError: initialState.opsError,
+ operationMetricsLoading: true,
+ };
+}
+
+function fetchOpsMetricsDone(
+ state: MetricsReduxState,
+ { payload }: { payload?: FetchAggregatedServiceMetricsResponse }
+) {
+ const opsError: MetricsReduxState['opsError'] = {
+ opsLatencies: null,
+ opsCalls: null,
+ opsErrors: null,
+ };
+
+ let opsMetrics:
+ | null
+ | {
+ [k in string]: {
+ name: string;
+ metricPoints: OpsDataPoints;
+ };
+ } = null;
+
+ // eslint-disable-next-line no-undef-init
+ let serviceOpsMetrics: ServiceOpsMetrics[] | undefined = undefined;
+
+ if (payload) {
+ payload.forEach((promiseResult, i) => {
+ if (promiseResult.status === 'fulfilled') {
+ const metric = promiseResult.value;
+ metric.metrics.forEach((metricDetails: MetricObject) => {
+ if (opsMetrics === null) {
+ opsMetrics = {};
+ }
+
+ let opsName: string | null = null;
+ const avg: {
+ service_operation_latencies: number;
+ service_operation_call_rate: number;
+ service_operation_error_rate: number;
+ } = {
+ service_operation_latencies: 0,
+ service_operation_call_rate: 0,
+ service_operation_error_rate: 0,
+ };
+ metricDetails.labels.forEach((label: any) => {
+ if (label.name === 'operation') {
+ opsName = label.value;
+ }
+ });
+
+ if (opsName) {
+ if (opsMetrics[opsName] === undefined) {
+ opsMetrics[opsName] = {
+ name: opsName,
+ metricPoints: {
+ service_operation_latencies: [],
+ service_operation_call_rate: [],
+ service_operation_error_rate: [],
+ avg: {
+ service_operation_latencies: null,
+ service_operation_call_rate: null,
+ service_operation_error_rate: null,
+ },
+ },
+ };
+ }
+
+ opsMetrics[opsName].metricPoints[metric.name] = metricDetails.metricPoints.map(p => {
+ let y;
+ try {
+ y = parseFloat(p.gaugeValue.doubleValue.toFixed(2));
+ avg[metric.name] += y;
+ } catch (e) {
+ y = null;
+ }
+
+ return {
+ x: new Date(p.timestamp).getTime(),
+ y,
+ };
+ });
+
+ opsMetrics[opsName].metricPoints.avg[metric.name] =
+ metricDetails.metricPoints.length > 0
+ ? parseFloat((avg[metric.name] / metricDetails.metricPoints.length).toFixed(2))
+ : null;
+ }
+ });
+ } else {
+ switch (i) {
+ case 0:
+ opsError.opsLatencies = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ case 1:
+ opsError.opsCalls = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ case 2:
+ opsError.opsErrors = (promiseResult as PromiseRejectedResult).reason;
+ break;
+ }
+ }
+ });
+
+ const minMax: {
+ min: number;
+ max: number;
+ } = {
+ min: 0,
+ max: 0,
+ };
+
+ if (opsMetrics) {
+ serviceOpsMetrics = Object.keys(opsMetrics).map((operationName, i) => {
+ let impact = 0;
+ if (
+ opsMetrics![operationName].metricPoints.avg.service_operation_latencies !== null &&
+ opsMetrics![operationName].metricPoints.avg.service_operation_call_rate !== null
+ ) {
+ impact =
+ (opsMetrics![operationName].metricPoints.avg.service_operation_latencies! *
+ opsMetrics![operationName].metricPoints.avg.service_operation_call_rate!) /
+ 100;
+ }
+
+ if (i === 0) {
+ minMax.max = impact;
+ minMax.min = impact;
+ } else {
+ minMax.max = minMax.max < impact ? impact : minMax.max;
+ minMax.min = minMax.min > impact ? impact : minMax.min;
+ }
+
+ return {
+ key: i,
+ name: opsMetrics![operationName].name,
+ latency: opsMetrics![operationName].metricPoints.avg.service_operation_latencies || 0,
+ requests: opsMetrics![operationName].metricPoints.avg.service_operation_call_rate || 0,
+ errRates: opsMetrics![operationName].metricPoints.avg.service_operation_error_rate || 0,
+ impact,
+ dataPoints: opsMetrics![operationName].metricPoints,
+ };
+ });
+
+ if (serviceOpsMetrics && serviceOpsMetrics.length === 1) {
+ serviceOpsMetrics.forEach((v, i) => {
+ serviceOpsMetrics![i].impact = 1;
+ });
+ } else if (serviceOpsMetrics && serviceOpsMetrics.length > 1) {
+ serviceOpsMetrics.forEach((v, i) => {
+ if (minMax.max - minMax.min === 0) {
+ serviceOpsMetrics![i].impact = 0;
+ } else {
+ serviceOpsMetrics![i].impact = (v.impact - minMax.min) / (minMax.max - minMax.min);
+ }
+ });
+ }
+ }
+ }
+
+ return { ...state, serviceOpsMetrics, opsError, operationMetricsLoading: false };
+}
+
+export default handleActions(
+ {
+ [`${fetchAllServiceMetrics}_PENDING`]: fetchStarted,
+ [`${fetchAllServiceMetrics}_FULFILLED`]: fetchServiceMetricsDone,
+
+ [`${fetchAggregatedServiceMetrics}_PENDING`]: fetchOpsMetricsStarted,
+ [`${fetchAggregatedServiceMetrics}_FULFILLED`]: fetchOpsMetricsDone,
+ },
+ initialState
+);
diff --git a/packages/jaeger-ui/src/types/index.tsx b/packages/jaeger-ui/src/types/index.tsx
index 6758d40de6..d3a093a4f3 100644
--- a/packages/jaeger-ui/src/types/index.tsx
+++ b/packages/jaeger-ui/src/types/index.tsx
@@ -21,14 +21,15 @@ import { Config } from './config';
import { EmbeddedState } from './embedded';
import { SearchQuery } from './search';
import TDdgState from './TDdgState';
-import TNil from './TNil';
-import IWebAnalytics from './tracking';
+import tNil from './TNil';
+import iWebAnalytics from './tracking';
import { Trace } from './trace';
import TTraceDiffState from './TTraceDiffState';
import TTraceTimeline from './TTraceTimeline';
+import { MetricsReduxState } from './metrics';
-export type TNil = TNil;
-export type IWebAnalytics = IWebAnalytics;
+export type TNil = tNil;
+export type IWebAnalytics = iWebAnalytics;
export type FetchedState = 'FETCH_DONE' | 'FETCH_ERROR' | 'FETCH_LOADING';
@@ -70,4 +71,5 @@ export type ReduxState = {
};
traceDiff: TTraceDiffState;
traceTimeline: TTraceTimeline;
+ metrics: MetricsReduxState;
};
diff --git a/packages/jaeger-ui/src/types/metrics.tsx b/packages/jaeger-ui/src/types/metrics.tsx
new file mode 100644
index 0000000000..85aee967be
--- /dev/null
+++ b/packages/jaeger-ui/src/types/metrics.tsx
@@ -0,0 +1,156 @@
+// Copyright (c) 2021 The Jaeger Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/* eslint-disable camelcase */
+
+import { ApiError } from './api-error';
+
+export type MetricsType = 'latencies' | 'calls' | 'errors';
+export type AvailableServiceMetrics = 'service_call_rate' | 'service_latencies' | 'service_error_rate';
+export type AvailableOpsMetrics =
+ | 'service_operation_call_rate'
+ | 'service_operation_latencies'
+ | 'service_operation_error_rate';
+
+export type MetricsAPIQueryParams = {
+ quantile: number;
+ groupByOperation?: boolean;
+ endTs?: number;
+ lookback?: number;
+ step?: number;
+ ratePer?: number;
+ spanKind?: 'unspecified' | 'internal' | 'server' | 'client' | 'producer' | 'consumer';
+};
+
+export type LableObject = {
+ name: string;
+ value: string;
+};
+
+export type MetricPointObject = {
+ gaugeValue: {
+ doubleValue: number;
+ };
+ timestamp: string;
+};
+
+export type MetricObject = {
+ labels: LableObject[];
+ metricPoints: MetricPointObject[];
+};
+
+export type MetricsAPIServiceResponseData = {
+ name: T;
+ type: 'GAUGE';
+ help: string;
+ metrics: MetricObject[];
+ quantile: U;
+};
+
+export type MetricsAPIOpsResponseData = {
+ name: T;
+ type: 'GAUGE';
+ help: string;
+ metrics: MetricObject[];
+ quantile: number;
+};
+
+export type Points = {
+ x: number;
+ y: number | null;
+};
+
+export type DataAvg = {
+ service_operation_call_rate: null | number;
+ service_operation_error_rate: null | number;
+ service_operation_latencies: null | number;
+};
+
+export type OpsDataPoints = {
+ service_operation_call_rate: Points[];
+ service_operation_error_rate: Points[];
+ service_operation_latencies: Points[];
+ avg: DataAvg;
+};
+
+export type ServiceOpsMetrics = {
+ dataPoints: OpsDataPoints;
+ errRates: number;
+ impact: number;
+ latency: number;
+ name: string;
+ requests: number;
+ key: number;
+};
+
+export type ServiceMetricsObject = {
+ serviceName: string;
+ quantile: number;
+ metricPoints: Points[];
+};
+
+export type ServiceMetrics = {
+ service_latencies: null | ServiceMetricsObject[];
+ service_call_rate: null | ServiceMetricsObject;
+ service_error_rate: null | ServiceMetricsObject;
+};
+
+export type MetricsReduxState = {
+ serviceError: {
+ service_latencies_50: null | ApiError;
+ service_latencies_75: null | ApiError;
+ service_latencies_95: null | ApiError;
+ service_call_rate: null | ApiError;
+ service_error_rate: null | ApiError;
+ };
+ opsError: {
+ opsLatencies: null | ApiError;
+ opsCalls: null | ApiError;
+ opsErrors: null | ApiError;
+ };
+ isATMActivated: null | boolean;
+ loading: boolean;
+ operationMetricsLoading: null | boolean;
+ serviceMetrics: ServiceMetrics | null;
+ serviceOpsMetrics: ServiceOpsMetrics[] | undefined;
+};
+
+export enum PromiseStatus {
+ fulfilled = 'fulfilled',
+ rejected = 'rejected',
+}
+
+export type PromiseFulfilledResult = {
+ status: PromiseStatus.fulfilled;
+ value: T;
+};
+
+export type PromiseRejectedResult = {
+ status: PromiseStatus.rejected;
+ reason: any;
+};
+
+export type FetchedAllServiceMetricsResponse = [
+ PromiseFulfilledResult> | PromiseRejectedResult,
+ PromiseFulfilledResult> | PromiseRejectedResult,
+ PromiseFulfilledResult> | PromiseRejectedResult,
+ PromiseFulfilledResult> | PromiseRejectedResult,
+ PromiseFulfilledResult> | PromiseRejectedResult
+];
+
+export type FetchAggregatedServiceMetricsResponse = [
+ PromiseFulfilledResult> | PromiseRejectedResult,
+ PromiseFulfilledResult> | PromiseRejectedResult,
+ PromiseFulfilledResult> | PromiseRejectedResult
+];
diff --git a/packages/jaeger-ui/src/utils/redux-form-field-adapter.tsx b/packages/jaeger-ui/src/utils/redux-form-field-adapter.tsx
index c426d32469..cd550ec950 100644
--- a/packages/jaeger-ui/src/utils/redux-form-field-adapter.tsx
+++ b/packages/jaeger-ui/src/utils/redux-form-field-adapter.tsx
@@ -26,8 +26,8 @@ export default function reduxFormFieldAdapter({
isValidatedInput = false,
}: {
AntInputComponent: React.ComponentType;
- onChangeAdapter: (evt: React.ChangeEvent) => any;
- isValidatedInput: boolean;
+ onChangeAdapter?: (evt: React.ChangeEvent) => any;
+ isValidatedInput?: boolean;
}) {
return function _reduxFormFieldAdapter(props: any) {
const {
diff --git a/packages/plexus/src/DirectedGraph/DirectedGraph.tsx b/packages/plexus/src/DirectedGraph/DirectedGraph.tsx
index 4fd6e70625..059eaa6209 100644
--- a/packages/plexus/src/DirectedGraph/DirectedGraph.tsx
+++ b/packages/plexus/src/DirectedGraph/DirectedGraph.tsx
@@ -188,7 +188,7 @@ export default class DirectedGraph extends React.PureComponent<
});
}
});
- const { positions, layout } = layoutManager.getLayout(edges, sizeVertices);
+ const { positions, layout } = layoutManager.getLayout<{}, {}>(edges, sizeVertices);
positions.then(this._onPositionsDone);
layout.then(this._onLayoutDone);
this.setState({ sizeVertices, layoutPhase: PHASE_CALC_POSITIONS });