From 9a86bb82194ccd997f7d7e71ee435ee3859f1a75 Mon Sep 17 00:00:00 2001 From: Mykhailo Semenchenko Date: Fri, 5 Nov 2021 12:10:06 +0200 Subject: [PATCH] Monitoring Page for ATM Signed-off-by: Mykhailo Semenchenko --- packages/jaeger-ui/jest.global-setup.js | 3 + packages/jaeger-ui/package.json | 1 + packages/jaeger-ui/src/actions/jaeger-api.js | 43 + .../jaeger-ui/src/actions/jaeger-api.test.js | 45 + packages/jaeger-ui/src/api/jaeger.js | 14 +- packages/jaeger-ui/src/api/jaeger.test.js | 41 + .../jaeger-ui/src/components/App/TopNav.tsx | 10 + .../App/__snapshots__/index.test.js.snap | 4 + .../jaeger-ui/src/components/App/index.js | 3 + .../__snapshots__/index.test.js.snap | 217 ++++ .../Monitoring/EmptyState/index.css | 20 + .../Monitoring/EmptyState/index.test.js | 70 + .../Monitoring/EmptyState/index.tsx | 75 ++ .../__snapshots__/index.test.js.snap | 353 ++++++ .../__snapshots__/serviceGraph.test.js.snap | 701 ++++++++++ .../Monitoring/ServicesView/index.css | 74 ++ .../Monitoring/ServicesView/index.test.js | 178 +++ .../Monitoring/ServicesView/index.tsx | 358 ++++++ .../__snapshots__/index.test.js.snap | 1124 +++++++++++++++++ .../__snapshots__/opsGraph.test.js.snap | 80 ++ .../operationDetailsTable/index.css | 90 ++ .../operationDetailsTable/index.test.js | 229 ++++ .../operationDetailsTable/index.tsx | 209 +++ .../operationDetailsTable/opsGraph.css | 29 + .../operationDetailsTable/opsGraph.test.js | 50 + .../operationDetailsTable/opsGraph.tsx | 87 ++ .../Monitoring/ServicesView/serviceGraph.css | 50 + .../ServicesView/serviceGraph.test.js | 149 +++ .../Monitoring/ServicesView/serviceGraph.tsx | 221 ++++ .../__snapshots__/index.test.js.snap | 3 + .../src/components/Monitoring/index.test.js | 33 + .../src/components/Monitoring/index.tsx | 20 + .../src/components/Monitoring/url.test.js | 27 + .../src/components/Monitoring/url.tsx | 29 + .../components/common/LoadingIndicator.css | 5 + .../components/common/LoadingIndicator.tsx | 5 +- packages/jaeger-ui/src/reducers/index.js | 2 + .../jaeger-ui/src/reducers/metrics.mock.js | 459 +++++++ .../jaeger-ui/src/reducers/metrics.test.js | 314 +++++ packages/jaeger-ui/src/reducers/metrics.tsx | 318 +++++ packages/jaeger-ui/src/types/index.tsx | 10 +- packages/jaeger-ui/src/types/metrics.tsx | 156 +++ .../src/utils/redux-form-field-adapter.tsx | 4 +- .../src/DirectedGraph/DirectedGraph.tsx | 2 +- 44 files changed, 5906 insertions(+), 9 deletions(-) create mode 100644 packages/jaeger-ui/jest.global-setup.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/EmptyState/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/Monitoring/EmptyState/index.css create mode 100644 packages/jaeger-ui/src/components/Monitoring/EmptyState/index.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/EmptyState/index.tsx create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/__snapshots__/serviceGraph.test.js.snap create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/index.css create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/index.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/index.tsx create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/opsGraph.test.js.snap create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.css create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.tsx create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/opsGraph.css create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/opsGraph.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/opsGraph.tsx create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.css create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.tsx create mode 100644 packages/jaeger-ui/src/components/Monitoring/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/Monitoring/index.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/index.tsx create mode 100644 packages/jaeger-ui/src/components/Monitoring/url.test.js create mode 100644 packages/jaeger-ui/src/components/Monitoring/url.tsx create mode 100644 packages/jaeger-ui/src/reducers/metrics.mock.js create mode 100644 packages/jaeger-ui/src/reducers/metrics.test.js create mode 100644 packages/jaeger-ui/src/reducers/metrics.tsx create mode 100644 packages/jaeger-ui/src/types/metrics.tsx 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 487017fbd2..c9099dda3c 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..8ba2130f5d 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 monitoringATMUrl from '../Monitoring/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('monitoring.menuEnabled')) { + NAV_LINKS.push({ + to: monitoringATMUrl.getUrl(), + matches: monitoringATMUrl.matches, + text: 'Monitoring', + }); +} + 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..8f0f45f4db 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/Monitoring/EmptyState/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitoring/EmptyState/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..2de14df953 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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 Monitoring +

+
+
+
+
+ • + Configured + + + + + + +
+
+ • + Sent data + + + + + + +
+
+
+ +
+
+
+`; + +exports[`not configured ATM snapshot test 1`] = ` +
+
+
+
+

+ Get started with Services Monitoring +

+
+
+
+
+ • + Configured + + + + + + +
+
+ • + Sent data + + + + + + +
+
+
+ +
+
+
+`; diff --git a/packages/jaeger-ui/src/components/Monitoring/EmptyState/index.css b/packages/jaeger-ui/src/components/Monitoring/EmptyState/index.css new file mode 100644 index 0000000000..faeaf63638 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/EmptyState/index.test.js b/packages/jaeger-ui/src/components/Monitoring/EmptyState/index.test.js new file mode 100644 index 0000000000..99f6b8aecd --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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 MonitoringATMEmptyState 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/Monitoring/EmptyState/index.tsx b/packages/jaeger-ui/src/components/Monitoring/EmptyState/index.tsx new file mode 100644 index 0000000000..6a95f02ff5 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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 MonitoringATMEmptyState 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 Monitoring} + footer={ + + } + renderItem={(item: { text: string; status: boolean }) => ( +
+ • {item.text} {item.status ? : } +
+ )} + /> + +
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/Monitoring/ServicesView/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitoring/ServicesView/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..7560c8bf95 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/__snapshots__/serviceGraph.test.js.snap b/packages/jaeger-ui/src/components/Monitoring/ServicesView/__snapshots__/serviceGraph.test.js.snap new file mode 100644 index 0000000000..567b2a6932 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/index.css b/packages/jaeger-ui/src/components/Monitoring/ServicesView/index.css new file mode 100644 index 0000000000..f04b2eba0c --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/index.test.js b/packages/jaeger-ui/src/components/Monitoring/ServicesView/index.test.js new file mode 100644 index 0000000000..1811b8dd42 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/ServicesView/index.test.js @@ -0,0 +1,178 @@ +// 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 { + MonitoringATMServicesViewImpl as MonitoringATMServicesView, + mapStateToProps, + mapDispatchToProps, + getLoopbackInterval, +} from '.'; +import LoadingIndicator from '../../common/LoadingIndicator'; +import MonitoringATMEmptyState from '../EmptyState'; +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( + + ); + }); + + it('does not explode', () => { + expect(wrapper.length).toBe(1); + }); + + it('shows a loading indicator when loading data', () => { + expect(wrapper.find(LoadingIndicator).length).toBe(1); + }); + + it('do not show a loading indicator once data loaded', () => { + wrapper.setProps({ metrics: { loading: false } }); + expect(wrapper.find(LoadingIndicator).length).toBe(0); + }); + + it('Render ATM not configured page', () => { + wrapper.setProps({ metrics: { loading: false, isATMActivated: false } }); + expect(wrapper.find(MonitoringATMEmptyState).length).toBe(1); + }); + + it('function invocation check', () => { + expect(mockFetchServices).toHaveBeenCalled(); + expect(mockFetchAllServiceMetrics).not.toHaveBeenCalled(); + expect(mockFetchAggregatedServiceMetrics).not.toHaveBeenCalled(); + wrapper.setProps({ + services: ['s1', 's2'], + selectedService: '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 listnere', () => { + 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); + }); +}); + +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/Monitoring/ServicesView/index.tsx b/packages/jaeger-ui/src/components/Monitoring/ServicesView/index.tsx new file mode 100644 index 0000000000..c9c8da2876 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/ServicesView/index.tsx @@ -0,0 +1,358 @@ +// 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'; +// @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 MonitoringATMEmptyState 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[]; + 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) return ''; + + const timeFrameObj = timeFrameOptions.find(t => t.value === interval); + + if (timeFrameObj === undefined) return ''; + + return timeFrameObj.label.toLowerCase(); +}; + +// export for tests +export class MonitoringATMServicesViewImpl extends React.PureComponent { + graphDivWrapper: React.RefObject; + serviceSelectorValue: string = ''; + endTime: number = Date.now(); + isReady: boolean = false; + state = { + graphWidth: 300, + serviceOpsMetrics: undefined, + searchOps: '', + }; + + constructor(props: TProps) { + super(props); + this.graphDivWrapper = React.createRef(); + } + + componentDidMount() { + const { fetchServices } = this.props; + fetchServices(); + this.fetchMetrics(); + window.addEventListener('resize', this.updateDimensions.bind(this)); + } + + componentDidUpdate(nextProps: TProps) { + const { selectedService, selectedTimeFrame } = this.props; + + if (nextProps.selectedService !== selectedService || nextProps.selectedTimeFrame !== selectedTimeFrame) { + 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() { + this.updateDimensions.apply(this); + + const { services, metrics, selectedTimeFrame } = this.props; + + if ((metrics.loading === null || metrics.loading === true) && !this.isReady) { + return ; + } + + if (!metrics.isATMActivated) { + return ; + } + + this.isReady = true; + + 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 || [], + 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', + })(MonitoringATMServicesViewImpl) +); diff --git a/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..037863d655 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/index.test.js.snap @@ -0,0 +1,1124 @@ +// 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 + +
+
+
+ + + + +
+
+
+ 736.16 ms +
+
+
+
+
+
+ + + + +
+
+
+ 0.01 req/s +
+
+
+
+
+
+ + + + +
+
+
+ 1% +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+`; diff --git a/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/opsGraph.test.js.snap b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/__snapshots__/opsGraph.test.js.snap new file mode 100644 index 0000000000..670e52c619 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/operationDetailsTable/index.css b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.css new file mode 100644 index 0000000000..574f19d95d --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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; +} + +.impact-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/Monitoring/ServicesView/operationDetailsTable/index.test.js b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.test.js new file mode 100644 index 0000000000..2a5101bbb4 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.test.js @@ -0,0 +1,229 @@ +// 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', + // state + 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('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'); + }); +}); diff --git a/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.tsx b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/index.tsx new file mode 100644 index 0000000000..0b7e979a1b --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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 = ( + + ); + } + + return { + children: ( +
+ +
{viewTraceButton}
+
+ ), + }; + }, + }, + ]; + + 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/Monitoring/ServicesView/operationDetailsTable/opsGraph.css b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/opsGraph.css new file mode 100644 index 0000000000..51bffa9f68 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/operationDetailsTable/opsGraph.test.js b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/opsGraph.test.js new file mode 100644 index 0000000000..0503f51f77 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/operationDetailsTable/opsGraph.tsx b/packages/jaeger-ui/src/components/Monitoring/ServicesView/operationDetailsTable/opsGraph.tsx new file mode 100644 index 0000000000..08e1cc2ac1 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/serviceGraph.css b/packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.css new file mode 100644 index 0000000000..467b46c770 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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/Monitoring/ServicesView/serviceGraph.test.js b/packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.test.js new file mode 100644 index 0000000000..8f501cd618 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.test.js @@ -0,0 +1,149 @@ +// 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: '', + // state + 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/Monitoring/ServicesView/serviceGraph.tsx b/packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.tsx new file mode 100644 index 0000000000..0ef5bce71e --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/ServicesView/serviceGraph.tsx @@ -0,0 +1,221 @@ +// 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; + 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={ + idx === 0 + ? (_datapoint: Points, { index }: { index: number }) => { + this.setState({ + crosshairValues: this.getData().map((d: ServiceMetricsObject) => ({ + ...d.metricPoints[index], + label: d.quantile, + })), + }); + } + : null + } + 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/Monitoring/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/Monitoring/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..34fbf7ec68 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/__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/Monitoring/index.test.js b/packages/jaeger-ui/src/components/Monitoring/index.test.js new file mode 100644 index 0000000000..3d0edf8793 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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 MonitoringATMPage 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/Monitoring/index.tsx b/packages/jaeger-ui/src/components/Monitoring/index.tsx new file mode 100644 index 0000000000..a340174ab4 --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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 MonitoringATMServicesView from './ServicesView'; + +const MonitoringATMPage = () => ; + +export default MonitoringATMPage; diff --git a/packages/jaeger-ui/src/components/Monitoring/url.test.js b/packages/jaeger-ui/src/components/Monitoring/url.test.js new file mode 100644 index 0000000000..1a87a4412a --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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('Monitoring/url', () => { + it('matches', () => { + expect(matches('/monitoring')).toBe(true); + expect(matches('/monitoring?var=123')).toBe(false); + expect(matches('/bla')).toBe(false); + }); + + it('getUrl', () => { + expect(getUrl()).toBe(ROUTE_PATH); + }); +}); diff --git a/packages/jaeger-ui/src/components/Monitoring/url.tsx b/packages/jaeger-ui/src/components/Monitoring/url.tsx new file mode 100644 index 0000000000..22f17182ca --- /dev/null +++ b/packages/jaeger-ui/src/components/Monitoring/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('/monitoring'); + +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..3417fa7d34 --- /dev/null +++ b/packages/jaeger-ui/src/reducers/metrics.mock.js @@ -0,0 +1,459 @@ +// 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 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 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 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 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: null, + 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 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, + }, +]; + +export { + serviceLatencies50, + serviceLatencies75, + serviceLatencies95, + serviceCalls95Response, + serviceErrors95Response, + serviceOpsLatencies, + serviceOpsErrors, + serviceOpsCalls, + emptyResponse, + originInitialState, + serviceMetrics, + serviceOpsMetrics, +}; 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..139bd8ee6b --- /dev/null +++ b/packages/jaeger-ui/src/reducers/metrics.test.js @@ -0,0 +1,314 @@ +// 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, +} from './metrics.mock'; + +const initialState = metricReducer(undefined, {}); + +describe('reducers/fetchAllServiceMetrics', () => { + function verifyInitialState() { + expect(initialState).toEqual(originInitialState); + } + + beforeEach(verifyInitialState); + afterEach(verifyInitialState); + + 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('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('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('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..722ed32be5 --- /dev/null +++ b/packages/jaeger-ui/src/reducers/metrics.tsx @@ -0,0 +1,318 @@ +// 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 { 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: null, + 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; + default: + 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; + default: + break; + } + } + }); + + const minMax: { + min: number; + max: number; + } = { + min: 0, + max: 0, + }; + + if (opsMetrics) { + serviceOpsMetrics = Object.keys(opsMetrics).map((key, i) => { + let impact = 0; + if ( + opsMetrics![key].metricPoints.avg.service_operation_latencies !== null && + opsMetrics![key].metricPoints.avg.service_operation_call_rate !== null + ) { + impact = + (opsMetrics![key].metricPoints.avg.service_operation_latencies! * + opsMetrics![key].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![key].name, + latency: opsMetrics![key].metricPoints.avg.service_operation_latencies || 0, + requests: opsMetrics![key].metricPoints.avg.service_operation_call_rate || 0, + errRates: opsMetrics![key].metricPoints.avg.service_operation_error_rate || 0, + impact, + dataPoints: opsMetrics![key].metricPoints, + }; + }); + + if (serviceOpsMetrics && serviceOpsMetrics.length === 1) { + serviceOpsMetrics.forEach((v, i) => { + serviceOpsMetrics![i].impact = 1; + }); + } else if (serviceOpsMetrics && serviceOpsMetrics.length > 1) { + serviceOpsMetrics.forEach((v, i) => { + 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..ac2adcc645 --- /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: null | 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 });