From 3e17a7c74b663ee690cc7081f2b988b969a73591 Mon Sep 17 00:00:00 2001 From: Everett Date: Mon, 27 Apr 2020 16:14:11 -0400 Subject: [PATCH] Trace quality view & Ddg Decorations (#564) * WIP: Action and types for decorations Signed-off-by: Everett Ross * Add PAD reducer, fix types, fix year Signed-off-by: Everett Ross * Fix and test reducer, fix types, fix another year Signed-off-by: Everett Ross * Add another pad reducer test Signed-off-by: Everett Ross * WIP: Begin testing action Signed-off-by: Everett Ross * WIP: Finish action tests TODO: Move stringSupplant Signed-off-by: Everett Ross * Move and test stringSupplant Signed-off-by: Everett Ross * Cleanup Signed-off-by: Everett Ross * WIP: Decorate nodes, selector/detail side panel Signed-off-by: Everett Ross * WIP: Style side panel Signed-off-by: Everett Ross * WIP: Continue styling side panel, fetch details in details panel, render details in details card TODO: Style table, handle list, handle styled values Signed-off-by: Everett Ross * WIP: Improve TS handling of union of arrays TODO: Style table, handle list, handle styled values Signed-off-by: Everett Ross * WIP: Limit % circle size, update cursor for clickable ddg nodes, fix resizer height css, make top offset css var TODO: Style table, handle list, handle styled values Signed-off-by: Everett Ross * WIP: Handle list, begin overflow management TODO: Style table overflow, handle styled values Signed-off-by: Everett Ross * WIP: Manage overflow, begin handling styled values TODO: Handle styled values, loading&err render, modal, beautification Signed-off-by: Everett Ross * WIP: Handle styled values, render loading&err, style card TODO: Add info modal Signed-off-by: Everett Ross * Add info modal, begin clean up TODO clean up&test Signed-off-by: Everett Ross * Fix: rowKeys, setViewModifier argument name, decoration header size, destructured variable order, DeepDependencies/index initial state, stale comments, yarn.lock @types/node, yarn.lock registry urls, 'value' in paths, existing tests, op-specific details Add: linking row cells TODO: New tests Signed-off-by: Everett Ross * Handle linked cells, fix cell sort order Signed-off-by: Everett Ross * Test existing files, track decorations viewed, memoize summary requests TODO: Test SidePanel/ index, index.track, DetailsPanel Signed-off-by: Everett Ross * Test SidePanel/ index&track WIP test DetailsPanel Signed-off-by: Everett Ross * WIP test DetailsPanel Signed-off-by: Everett Ross * Finish DetailsPanel tests Signed-off-by: Everett Ross * Clean up Signed-off-by: Everett Ross * Add skeleton components and fetch quality metrics Signed-off-by: Everett Ross * WIP: Render all data and dropdowns except banner TODO: Render banner text, style components, test Signed-off-by: Everett Ross * WIP: Style components, implement lookback TODO: Render banner text, render weight, test Signed-off-by: Everett Ross * Debounce InputNumber, limit search url length, add metric documentation tooltip, tweak styles, add BannerText, handle loading, handle error TODO: test, cleanup Signed-off-by: Everett Ross * Cleanup Signed-off-by: Everett Ross * Add support for decoration links Signed-off-by: Everett Ross * Clean up and add quality-metrics top nav link Signed-off-by: Everett Ross --- packages/jaeger-ui/package.json | 1 + .../actions/path-agnostic-decorations.test.js | 82 +-- .../src/actions/path-agnostic-decorations.tsx | 28 +- packages/jaeger-ui/src/api/jaeger.js | 3 + .../jaeger-ui/src/components/App/Page.css | 4 +- .../jaeger-ui/src/components/App/TopNav.tsx | 9 + .../jaeger-ui/src/components/App/index.js | 3 + .../__snapshots__/index.test.js.snap | 478 +++++++++++++++++- .../Graph/DdgNodeContent/constants.tsx | 5 +- .../Graph/DdgNodeContent/index.css | 8 + .../Graph/DdgNodeContent/index.test.js | 113 ++++- .../Graph/DdgNodeContent/index.tsx | 204 +++++--- .../DeepDependencies/Graph/index.tsx | 9 +- .../SidePanel/DetailsCard/index.css | 73 +++ .../SidePanel/DetailsCard/index.tsx | 222 ++++++++ .../SidePanel/DetailsPanel.css | 83 +++ .../SidePanel/DetailsPanel.test.js | 303 +++++++++++ .../SidePanel/DetailsPanel.tsx | 181 +++++++ .../__snapshots__/DetailsPanel.test.js.snap | 377 ++++++++++++++ .../__snapshots__/index.test.js.snap | 322 ++++++++++++ .../DeepDependencies/SidePanel/index.css | 91 ++++ .../DeepDependencies/SidePanel/index.test.js | 218 ++++++++ .../SidePanel/index.track.test.js | 61 +++ .../SidePanel/index.track.tsx | 33 ++ .../DeepDependencies/SidePanel/index.tsx | 143 ++++++ .../src/components/DeepDependencies/index.css | 6 + .../components/DeepDependencies/index.test.js | 42 ++ .../DeepDependencies/index.track.test.js | 9 + .../src/components/DeepDependencies/index.tsx | 66 ++- .../components/DeepDependencies/url.test.js | 23 +- .../components/QualityMetrics/BannerText.css | 22 + .../components/QualityMetrics/BannerText.tsx | 39 ++ .../components/QualityMetrics/CountCard.css | 35 ++ .../components/QualityMetrics/CountCard.tsx | 43 ++ .../QualityMetrics/ExamplesLink.tsx | 62 +++ .../src/components/QualityMetrics/Header.css | 41 ++ .../src/components/QualityMetrics/Header.tsx | 74 +++ .../components/QualityMetrics/MetricCard.css | 59 +++ .../components/QualityMetrics/MetricCard.tsx | 96 ++++ .../components/QualityMetrics/ScoreCard.css | 32 ++ .../components/QualityMetrics/ScoreCard.tsx | 55 ++ .../src/components/QualityMetrics/index.css | 38 ++ .../src/components/QualityMetrics/index.tsx | 209 ++++++++ .../src/components/QualityMetrics/types.tsx | 63 +++ .../src/components/QualityMetrics/url.tsx | 51 ++ .../src/components/SearchTracePage/url.tsx | 10 +- .../TimelineColumnResizer.css | 6 +- .../TimelineColumnResizer.tsx | 20 +- .../components/common/CircularProgressbar.tsx | 63 +++ .../jaeger-ui/src/components/common/utils.css | 4 + .../model/path-agnostic-decorations/index.tsx | 68 +++ .../model/path-agnostic-decorations/types.tsx | 51 +- packages/jaeger-ui/src/reducers/index.js | 2 + .../path-agnostic-decorations.test.js | 7 +- .../reducers/path-agnostic-decorations.tsx | 11 +- packages/jaeger-ui/src/setupProxy.js | 20 + packages/jaeger-ui/src/types/config.tsx | 3 + yarn.lock | 13 +- 58 files changed, 4161 insertions(+), 236 deletions(-) create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.css create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.css create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/index.test.js.snap create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.css create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.test.js create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.test.js create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.tsx create mode 100644 packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/BannerText.css create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/BannerText.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/CountCard.css create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/Header.css create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/Header.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/MetricCard.css create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.css create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/index.css create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/index.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/types.tsx create mode 100644 packages/jaeger-ui/src/components/QualityMetrics/url.tsx create mode 100644 packages/jaeger-ui/src/components/common/CircularProgressbar.tsx create mode 100644 packages/jaeger-ui/src/model/path-agnostic-decorations/index.tsx diff --git a/packages/jaeger-ui/package.json b/packages/jaeger-ui/package.json index 36873023d8..008a82689f 100644 --- a/packages/jaeger-ui/package.json +++ b/packages/jaeger-ui/package.json @@ -78,6 +78,7 @@ "query-string": "^6.3.0", "raven-js": "^3.22.1", "react": "^16.3.2", + "react-circular-progressbar": "^2.0.3", "react-dimensions": "^1.3.0", "react-dom": "^16.3.2", "react-ga": "^2.4.1", diff --git a/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js b/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js index 60ca164011..f364b08052 100644 --- a/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js +++ b/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js @@ -14,21 +14,23 @@ import _set from 'lodash/set'; -import { processed, getDecoration as getDecorationImpl } from './path-agnostic-decorations'; +import { _processed, getDecoration as getDecorationImpl } from './path-agnostic-decorations'; import * as getConfig from '../utils/config/get-config'; import stringSupplant from '../utils/stringSupplant'; import JaegerAPI from '../api/jaeger'; +jest.mock('lru-memoize', () => () => x => x); + describe('getDecoration', () => { let getConfigValueSpy; let fetchDecorationSpy; let resolves; let rejects; - const opUrl = 'opUrl?service=#service&operation=#operation'; - const url = 'opUrl?service=#service'; - const valuePath = 'withoutOpPath.#service'; - const opValuePath = 'opPath.#service.#operation'; + const opSummaryUrl = 'opSummaryUrl?service=#service&operation=#operation'; + const summaryUrl = 'summaryUrl?service=#service'; + const summaryPath = 'withoutOpPath.#service'; + const opSummaryPath = 'opPath.#service.#operation'; const withOpID = 'decoration id with op url and op path'; const partialID = 'decoration id with op url without op path'; const withoutOpID = 'decoration id with only url'; @@ -48,21 +50,21 @@ describe('getDecoration', () => { getConfigValueSpy = jest.spyOn(getConfig, 'getConfigValue').mockReturnValue([ { id: withOpID, - url, - opUrl, - valuePath, - opValuePath, + summaryUrl, + opSummaryUrl, + summaryPath, + opSummaryPath, }, { id: partialID, - url, - opUrl, - valuePath, + summaryUrl, + opSummaryUrl, + summaryPath, }, { id: withoutOpID, - url, - valuePath, + summaryUrl, + summaryPath, }, ]); fetchDecorationSpy = jest.spyOn(JaegerAPI, 'fetchDecoration').mockImplementation( @@ -76,7 +78,7 @@ describe('getDecoration', () => { beforeEach(() => { fetchDecorationSpy.mockClear(); - processed.clear(); + _processed.clear(); resolves = []; rejects = []; }); @@ -102,42 +104,42 @@ describe('getDecoration', () => { it('resolves to include single response for op decoration given op', async () => { const promise = getDecoration(withOpID, service, operation); - resolves[0](_set({}, stringSupplant(opValuePath, { service, operation }), testVal)); + resolves[0](_set({}, stringSupplant(opSummaryPath, { service, operation }), testVal)); const res = await promise; expect(res).toEqual(_set({}, `${withOpID}.withOp.${service}.${operation}`, testVal)); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(opUrl, { service, operation })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(opSummaryUrl, { service, operation })); }); it('resolves to include single response for op decoration not given op', async () => { const promise = getDecoration(withOpID, service); - resolves[0](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[0](_set({}, stringSupplant(summaryPath, { service }), testVal)); const res = await promise; expect(res).toEqual(_set({}, `${withOpID}.withoutOp.${service}`, testVal)); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(summaryUrl, { service })); }); it('resolves to include single response for malformed op decoration given op', async () => { const promise = getDecoration(partialID, service, operation); - resolves[0](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[0](_set({}, stringSupplant(summaryPath, { service }), testVal)); const res = await promise; expect(res).toEqual(_set({}, `${partialID}.withoutOp.${service}`, testVal)); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(summaryUrl, { service })); }); it('resolves to include single response for svc decoration given op', async () => { const promise = getDecoration(withoutOpID, service, operation); - resolves[0](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[0](_set({}, stringSupplant(summaryPath, { service }), testVal)); const res = await promise; expect(res).toEqual(_set({}, `${withoutOpID}.withoutOp.${service}`, testVal)); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(summaryUrl, { service })); }); it('resolves to include single response for svc decoration not given op', async () => { const promise = getDecoration(withoutOpID, service); - resolves[0](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[0](_set({}, stringSupplant(summaryPath, { service }), testVal)); const res = await promise; expect(res).toEqual(_set({}, `${withoutOpID}.withoutOp.${service}`, testVal)); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(summaryUrl, { service })); }); it('handles error responses', async () => { @@ -148,7 +150,7 @@ describe('getDecoration', () => { expect(res0).toEqual( _set({}, `${withoutOpID}.withoutOp.${service}`, `Unable to fetch decoration: ${message}`) ); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(summaryUrl, { service })); const err = 'foo error without message'; const promise1 = getDecoration(withOpID, service, operation); @@ -157,10 +159,10 @@ describe('getDecoration', () => { expect(res1).toEqual( _set({}, `${withOpID}.withOp.${service}.${operation}`, `Unable to fetch decoration: ${err}`) ); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(opUrl, { service, operation })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(opSummaryUrl, { service, operation })); }); - it('defaults value if valuePath not found in response', async () => { + it('defaults value if summaryPath not found in response', async () => { const promise = getDecoration(withoutOpID, service); resolves[0](); const res = await promise; @@ -168,10 +170,10 @@ describe('getDecoration', () => { _set( {}, `${withoutOpID}.withoutOp.${service}`, - `${stringSupplant(valuePath, { service })} not found in response` + `\`${stringSupplant(summaryPath, { service })}\` not found in response` ) ); - expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(summaryUrl, { service })); }); it('returns undefined if invoked before previous invocation is resolved', () => { @@ -182,13 +184,13 @@ describe('getDecoration', () => { it('resolves to include responses for all concurrent requests', async () => { const otherOp = 'other op'; const promise = getDecoration(withOpID, service, operation); - resolves[0](_set({}, stringSupplant(opValuePath, { service, operation }), testVal)); + resolves[0](_set({}, stringSupplant(opSummaryPath, { service, operation }), testVal)); getDecoration(partialID, service, operation); - resolves[1](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[1](_set({}, stringSupplant(summaryPath, { service }), testVal)); getDecoration(withOpID, service); - resolves[2](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[2](_set({}, stringSupplant(summaryPath, { service }), testVal)); getDecoration(withoutOpID, service); - resolves[3](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[3](_set({}, stringSupplant(summaryPath, { service }), testVal)); const message = 'foo error message'; getDecoration(withOpID, service, otherOp); rejects[4]({ message }); @@ -222,15 +224,15 @@ describe('getDecoration', () => { it('scopes promises to not include previous promise results', async () => { const otherOp = 'other op'; const promise0 = getDecoration(withOpID, service, operation); - resolves[0](_set({}, stringSupplant(opValuePath, { service, operation }), testVal)); + resolves[0](_set({}, stringSupplant(opSummaryPath, { service, operation }), testVal)); getDecoration(partialID, service, operation); - resolves[1](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[1](_set({}, stringSupplant(summaryPath, { service }), testVal)); const res0 = await promise0; const promise1 = getDecoration(withOpID, service); - resolves[2](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[2](_set({}, stringSupplant(summaryPath, { service }), testVal)); getDecoration(withoutOpID, service); - resolves[3](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[3](_set({}, stringSupplant(summaryPath, { service }), testVal)); const message = 'foo error message'; getDecoration(withOpID, service, otherOp); rejects[4]({ message }); @@ -272,7 +274,7 @@ describe('getDecoration', () => { it('no-ops for already processed id, service, and operation', async () => { const promise0 = getDecoration(withOpID, service, operation); - resolves[0](_set({}, stringSupplant(opValuePath, { service, operation }), testVal)); + resolves[0](_set({}, stringSupplant(opSummaryPath, { service, operation }), testVal)); const res0 = await promise0; expect(res0).toEqual(_set({}, `${withOpID}.withOp.${service}.${operation}`, testVal)); @@ -280,7 +282,7 @@ describe('getDecoration', () => { expect(promise1).toBeUndefined(); const promise2 = getDecoration(withoutOpID, service); - resolves[1](_set({}, stringSupplant(valuePath, { service }), testVal)); + resolves[1](_set({}, stringSupplant(summaryPath, { service }), testVal)); const res1 = await promise2; expect(res1).toEqual(_set({}, `${withoutOpID}.withoutOp.${service}`, testVal)); expect(fetchDecorationSpy).toHaveBeenCalledTimes(2); diff --git a/packages/jaeger-ui/src/actions/path-agnostic-decorations.tsx b/packages/jaeger-ui/src/actions/path-agnostic-decorations.tsx index 5b09d616d1..880163a7fa 100644 --- a/packages/jaeger-ui/src/actions/path-agnostic-decorations.tsx +++ b/packages/jaeger-ui/src/actions/path-agnostic-decorations.tsx @@ -15,6 +15,7 @@ import _get from 'lodash/get'; import _memoize from 'lodash/memoize'; import _set from 'lodash/set'; +import memoize from 'lru-memoize'; import { createActions, ActionFunctionAny, Action } from 'redux-actions'; import JaegerAPI from '../api/jaeger'; @@ -23,9 +24,12 @@ import { getConfigValue } from '../utils/config/get-config'; import generateActionTypes from '../utils/generate-action-types'; import stringSupplant from '../utils/stringSupplant'; +// wrapping JaegerAPI.fetchDecoration is necessary for tests to properly mock inside memoization +const fetchDecoration = memoize(10)((url: string) => JaegerAPI.fetchDecoration(url)); + export const actionTypes = generateActionTypes('@jaeger-ui/PATH_AGNOSTIC_DECORATIONS', ['GET_DECORATION']); -const getDecorationSchema = _memoize((id: string): TPathAgnosticDecorationSchema | undefined => { +export const getDecorationSchema = _memoize((id: string): TPathAgnosticDecorationSchema | undefined => { const schemas = getConfigValue('pathAgnosticDecorations') as TPathAgnosticDecorationSchema[] | undefined; if (!schemas) return undefined; return schemas.find(s => s.id === id); @@ -39,16 +43,17 @@ let resolve: undefined | ((arg: TNewData) => void); // Bespoke memoization-adjacent solution necessary as this should return `undefined`, not an old promise, on // duplicate calls -export const processed = new Map>>(); +// exported for tests +export const _processed = new Map>>(); export function getDecoration( id: string, service: string, operation?: string ): Promise | undefined { - const processedID = processed.get(id); + const processedID = _processed.get(id); if (!processedID) { - processed.set(id, new Map>([[service, new Set([operation])]])); + _processed.set(id, new Map>([[service, new Set([operation])]])); } else { const processedService = processedID.get(service); if (!processedService) processedID.set(service, new Set([operation])); @@ -67,24 +72,23 @@ export function getDecoration( } pendingCount = pendingCount ? pendingCount + 1 : 1; - const { url, opUrl, valuePath, opValuePath } = schema; + const { summaryUrl, opSummaryUrl, summaryPath, opSummaryPath } = schema; let promise: Promise>; let getPath: string; let setPath: string; - if (opValuePath && opUrl && operation) { - promise = JaegerAPI.fetchDecoration(stringSupplant(opUrl, { service, operation })); - getPath = stringSupplant(opValuePath, { service, operation }); + if (opSummaryPath && opSummaryUrl && operation) { + promise = fetchDecoration(stringSupplant(opSummaryUrl, { service, operation })); + getPath = stringSupplant(opSummaryPath, { service, operation }); setPath = `${id}.withOp.${service}.${operation}`; } else { - promise = JaegerAPI.fetchDecoration(stringSupplant(url, { service })); - getPath = stringSupplant(valuePath, { service }); - getPath = valuePath; + promise = fetchDecoration(stringSupplant(summaryUrl, { service })); + getPath = stringSupplant(summaryPath, { service }); setPath = `${id}.withoutOp.${service}`; } promise .then(res => { - return _get(res, getPath, `${getPath} not found in response`); + return _get(res, getPath, `\`${getPath}\` not found in response`); }) .catch(err => { return `Unable to fetch decoration: ${err.message || err}`; diff --git a/packages/jaeger-ui/src/api/jaeger.js b/packages/jaeger-ui/src/api/jaeger.js index daaec18525..c18bae3a96 100644 --- a/packages/jaeger-ui/src/api/jaeger.js +++ b/packages/jaeger-ui/src/api/jaeger.js @@ -80,6 +80,9 @@ const JaegerAPI = { archiveTrace(id) { return getJSON(`${this.apiRoot}archive/${id}`, { method: 'POST' }); }, + fetchQualityMetrics(service, lookback) { + return getJSON(`/qualitymetrics-v2`, { query: { service, lookback } }); + }, fetchDecoration(url) { return getJSON(url); }, diff --git a/packages/jaeger-ui/src/components/App/Page.css b/packages/jaeger-ui/src/components/App/Page.css index 620189931c..85e2558672 100644 --- a/packages/jaeger-ui/src/components/App/Page.css +++ b/packages/jaeger-ui/src/components/App/Page.css @@ -26,8 +26,8 @@ limitations under the License. display: flex; flex-direction: column; left: 0; - min-height: calc(100% - 46px); + min-height: calc(100% - var(--nav-height)); position: absolute; right: 0; - top: 46px; + top: var(--nav-height); } diff --git a/packages/jaeger-ui/src/components/App/TopNav.tsx b/packages/jaeger-ui/src/components/App/TopNav.tsx index 411a933e07..4cf3b78b6c 100644 --- a/packages/jaeger-ui/src/components/App/TopNav.tsx +++ b/packages/jaeger-ui/src/components/App/TopNav.tsx @@ -21,6 +21,7 @@ import { RouteComponentProps, Link, withRouter } from 'react-router-dom'; import TraceIDSearchInput from './TraceIDSearchInput'; import * as dependencyGraph from '../DependencyGraph/url'; 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 { ReduxState } from '../../types'; @@ -59,6 +60,14 @@ if (getConfigValue('deepDependencies.menuEnabled')) { }); } +if (getConfigValue('qualityMetrics.menuEnabled')) { + NAV_LINKS.push({ + to: qualityMetrics.getUrl(), + matches: qualityMetrics.matches, + text: 'Quality Metrics', + }); +} + function getItemLink(item: ConfigMenuItem) { const { label, anchorTarget, url } = item; return ( diff --git a/packages/jaeger-ui/src/components/App/index.js b/packages/jaeger-ui/src/components/App/index.js index 78493b617c..d09bdcbbc9 100644 --- a/packages/jaeger-ui/src/components/App/index.js +++ b/packages/jaeger-ui/src/components/App/index.js @@ -24,6 +24,8 @@ import DependencyGraph from '../DependencyGraph'; import { ROUTE_PATH as dependenciesPath } from '../DependencyGraph/url'; import DeepDependencies from '../DeepDependencies'; import { ROUTE_PATH as deepDependenciesPath } from '../DeepDependencies/url'; +import QualityMetrics from '../QualityMetrics'; +import { ROUTE_PATH as qualityMetricsPath } from '../QualityMetrics/url'; import SearchTracePage from '../SearchTracePage'; import { ROUTE_PATH as searchPath } from '../SearchTracePage/url'; import TraceDiff from '../TraceDiff'; @@ -60,6 +62,7 @@ export default class JaegerUIApp extends Component { + diff --git a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap index 2fcb38b9c8..6052a4ab34 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap +++ b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/__snapshots__/index.test.js.snap @@ -1,6 +1,56 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` DdgNodeContent.getNodeRenderer() returns a 1`] = ` +exports[` getNodeRenderer() returns a 1`] = ` +Object { + "focalNodeUrl": "/deep-dependencies?operation=the-operation&service=the-service", + "focusPathsThroughVertex": undefined, + "getGenerationVisibility": undefined, + "getVisiblePathElems": undefined, + "hideVertex": undefined, + "isFocalNode": false, + "isPositioned": false, + "operation": "the-operation", + "selectVertex": undefined, + "service": "the-service", + "setOperation": undefined, + "setViewModifier": undefined, + "updateGenerationVisibility": undefined, + "vertex": Object { + "isFocalNode": false, + "key": "some-key", + "operation": "the-operation", + "service": "the-service", + }, + "vertexKey": "some-key", +} +`; + +exports[` getNodeRenderer() returns a focal 1`] = ` +Object { + "focalNodeUrl": null, + "focusPathsThroughVertex": undefined, + "getGenerationVisibility": undefined, + "getVisiblePathElems": undefined, + "hideVertex": undefined, + "isFocalNode": true, + "isPositioned": false, + "operation": "the-operation", + "selectVertex": undefined, + "service": "the-service", + "setOperation": undefined, + "setViewModifier": undefined, + "updateGenerationVisibility": undefined, + "vertex": Object { + "isFocalNode": true, + "key": "some-key", + "operation": "the-operation", + "service": "the-service", + }, + "vertexKey": "some-key", +} +`; + +exports[` omits the operation if it is null 1`] = `
DdgNodeContent.getNodeRenderer() returns a
DdgNodeContent.getNodeRenderer() returns a @@ -45,7 +97,7 @@ exports[` DdgNodeContent.getNodeRenderer() returns a
@@ -56,20 +108,144 @@ exports[` DdgNodeContent.getNodeRenderer() returns a + + + + + + + + + + + + Set focus + + + + + + + + View traces + + + + + + + + Focus paths through this node + + + + + + + + Hide node + + +
+ +`; + +exports[` omits the operation if it is null 2`] = ` +
+
+
+

+ +

+
+
+ `; -exports[` omits the operation if it is null 1`] = ` +exports[` renders correctly when decorationValue is a string 2`] = `
omits the operation if it is null 1`] = `
`; -exports[` omits the operation if it is null 2`] = ` +exports[` renders correctly when given decorationProgressbar 1`] = `
omits the operation if it is null 2`] = ` >
omits the operation if it is null 2`] = ` wordRegexp={/\\\\W\\*\\\\w\\+\\\\W\\*/g} /> +
+ +
+
+
+ +
+`; + +exports[` renders correctly when given decorationProgressbar 2`] = ` +
+ + Test progressbar + +
+
+

+ +

+
+ +
renders correctly when isFocalNode = true and focalNod >
renders correctly when isFocalNode = true and focalNod >
renders the number of operations if there are multiple >
renders the number of operations if there are multiple >
', () => { - const vertexKey = 'some-key'; - const service = 'some-service'; + const decorationID = 'test decorationID'; + const decorationValue = 42; const operation = 'some-operation'; const operationArray = ['op0', 'op1', 'op2', 'op3']; + const service = 'some-service'; + const vertexKey = 'some-key'; const props = { focalNodeUrl: 'some-url', focusPathsThroughVertex: jest.fn(), + getDecoration: jest.fn(), getGenerationVisibility: jest.fn(), getVisiblePathElems: jest.fn(), hideVertex: jest.fn(), isFocalNode: false, operation, + selectVertex: jest.fn(), setOperation: jest.fn(), setViewModifier: jest.fn(), service, updateGenerationVisibility: jest.fn(), + vertex: { + key: vertexKey, + }, vertexKey, }; let wrapper; beforeEach(() => { + props.getDecoration.mockClear(); props.getGenerationVisibility.mockReturnValue(null).mockClear(); props.getVisiblePathElems.mockReset(); + props.selectVertex.mockReset(); props.setViewModifier.mockReset(); props.updateGenerationVisibility.mockReset(); wrapper = shallow(); @@ -84,10 +98,65 @@ describe('', () => { expect(wrapper).toMatchSnapshot(); }); + it('renders correctly when given decorationProgressbar', () => { + expect(wrapper).toMatchSnapshot(); + + const decorationProgressbar = Test progressbar; + wrapper.setProps({ decorationProgressbar, decorationValue }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders correctly when decorationValue is a string', () => { + expect(wrapper).toMatchSnapshot(); + + wrapper.setProps({ decorationValue: 'Error: Status Code 418' }); + expect(wrapper).toMatchSnapshot(); + }); + + describe('getDecoration', () => { + it('gets decoration on mount or change of props.decorationID iff props.decorationID is truthy', () => { + expect(props.getDecoration).not.toHaveBeenCalled(); + + wrapper.setProps({ decorationID }); + expect(props.getDecoration).toHaveBeenCalledTimes(1); + expect(props.getDecoration).toHaveBeenLastCalledWith(decorationID, service, operation); + + wrapper.setProps({ decorationID }); + expect(props.getDecoration).toHaveBeenCalledTimes(1); + + const newDecorationID = `new ${decorationID}`; + wrapper.setProps({ decorationID: newDecorationID }); + expect(props.getDecoration).toHaveBeenCalledTimes(2); + expect(props.getDecoration).toHaveBeenLastCalledWith(newDecorationID, service, operation); + + wrapper.setProps({ decorationID, operation: operationArray }); + expect(props.getDecoration).toHaveBeenCalledTimes(3); + expect(props.getDecoration).toHaveBeenLastCalledWith(decorationID, service, undefined); + + shallow(); + expect(props.getDecoration).toHaveBeenCalledTimes(4); + expect(props.getDecoration).toHaveBeenLastCalledWith(decorationID, service, operation); + }); + }); + + describe('handleClick', () => { + it('calls props.selectVertex iff props.decorationValue is truthy', () => { + expect(props.selectVertex).not.toHaveBeenCalled(); + + wrapper.find('.DdgNodeContent--core').simulate('click'); + expect(props.selectVertex).not.toHaveBeenCalled(); + + wrapper.setProps({ decorationValue }); + wrapper.find('.DdgNodeContent--core').simulate('click'); + expect(props.selectVertex).toHaveBeenCalledTimes(1); + expect(props.selectVertex).toHaveBeenLastCalledWith(props.vertex); + }); + }); + describe('measureNode', () => { it('returns twice the RADIUS with a buffer for svg border', () => { const diameterWithBuffer = 2 * RADIUS + 2; - expect(DdgNodeContent.measureNode()).toEqual({ + expect(measureNode()).toEqual({ height: diameterWithBuffer, width: diameterWithBuffer, }); @@ -174,6 +243,13 @@ describe('', () => { }); expect(props.setViewModifier).toHaveBeenCalledWith([], EViewModifier.Hovered, true); }); + + it('clears hoveredIndices on mouse out', () => { + wrapper.simulate('mouseover', { type: 'mouseover' }); + expect(wrapper.instance().hoveredIndices).not.toEqual(new Set()); + wrapper.simulate('mouseout', { type: 'mouseout' }); + expect(wrapper.instance().hoveredIndices).toEqual(new Set()); + }); }); describe('node interactions', () => { @@ -487,7 +563,7 @@ describe('', () => { }); }); - describe('DdgNodeContent.getNodeRenderer()', () => { + describe('getNodeRenderer()', () => { const ddgVertex = { isFocalNode: false, key: 'some-key', @@ -497,27 +573,28 @@ describe('', () => { const noOp = () => {}; it('returns a ', () => { - const ddgNode = DdgNodeContent.getNodeRenderer( - noOp, - noOp, - EDdgDensity.PreventPathEntanglement, - true, - 'testBaseUrl', - { maxDuration: '100ms' } - )(ddgVertex); + const ddgNode = getNodeRenderer(noOp, noOp, EDdgDensity.PreventPathEntanglement, true, 'testBaseUrl', { + maxDuration: '100ms', + })(ddgVertex); expect(ddgNode).toBeDefined(); - expect(shallow(ddgNode)).toMatchSnapshot(); - expect(ddgNode.type).toBe(DdgNodeContent); + expect(ddgNode.props).toMatchSnapshot(); }); it('returns a focal ', () => { - const focalNode = DdgNodeContent.getNodeRenderer(noOp, noOp)({ + const focalNode = getNodeRenderer(noOp, noOp)({ ...ddgVertex, isFocalNode: true, }); expect(focalNode).toBeDefined(); - expect(shallow(focalNode)).toMatchSnapshot(); - expect(focalNode.type).toBe(DdgNodeContent); + expect(focalNode.props).toMatchSnapshot(); + }); + }); + + describe('mapDispatchToProps()', () => { + it('creates the actions correctly', () => { + expect(mapDispatchToProps(() => {})).toEqual({ + getDecoration: expect.any(Function), + }); }); }); }); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx index e901a5d88e..8687ce7e9a 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/Graph/DdgNodeContent/index.tsx @@ -18,6 +18,8 @@ import cx from 'classnames'; import { TLayoutVertex } from '@jaegertracing/plexus/lib/types'; import IoAndroidLocate from 'react-icons/lib/io/android-locate'; import MdVisibilityOff from 'react-icons/lib/md/visibility-off'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; import calcPositioning from './calc-positioning'; import { @@ -26,6 +28,7 @@ import { MIN_LENGTH, OP_PADDING_TOP, PARAM_NAME_LENGTH, + PROGRESS_BAR_STROKE_WIDTH, RADIUS, WORD_RX, } from './constants'; @@ -36,6 +39,7 @@ import BreakableText from '../../../common/BreakableText'; import FilteredList from '../../../common/FilteredList'; import NewWindowIcon from '../../../common/NewWindowIcon'; import { getUrl as getSearchUrl } from '../../../SearchTracePage/url'; +import padActions from '../../../../actions/path-agnostic-decorations'; import { ECheckedStatus, EDdgDensity, @@ -44,91 +48,113 @@ import { TDdgVertex, PathElem, } from '../../../../model/ddg/types'; +import extractDecorationFromState, { + TDecorationFromState, +} from '../../../../model/path-agnostic-decorations'; +import { ReduxState } from '../../../../types/index'; import './index.css'; -type TProps = { - focalNodeUrl: string | null; +type TDispatchProps = { + getDecoration: (id: string, svc: string, op?: string) => void; +}; + +type TProps = TDispatchProps & + TDecorationFromState & { + focalNodeUrl: string | null; + focusPathsThroughVertex: (vertexKey: string) => void; + getGenerationVisibility: (vertexKey: string, direction: EDirection) => ECheckedStatus | null; + getVisiblePathElems: (vertexKey: string) => PathElem[] | undefined; + hideVertex: (vertexKey: string) => void; + isFocalNode: boolean; + isPositioned: boolean; + operation: string | string[] | null; + selectVertex: (selectedVertex: TDdgVertex) => void; + service: string; + setOperation: (operation: string) => void; + setViewModifier: (visIndices: number[], viewModifier: EViewModifier, isEnabled: boolean) => void; + updateGenerationVisibility: (vertexKey: string, direction: EDirection) => void; + vertex: TDdgVertex; + vertexKey: string; + }; + +type TState = { + childrenVisibility?: ECheckedStatus | null; + parentVisibility?: ECheckedStatus | null; +}; + +export function getNodeRenderer({ + baseUrl, + density, + extraUrlArgs, + focusPathsThroughVertex, + getGenerationVisibility, + getVisiblePathElems, + hideVertex, + selectVertex, + setOperation, + setViewModifier, + updateGenerationVisibility, +}: { + baseUrl: string; + density: EDdgDensity; + extraUrlArgs?: { [key: string]: unknown }; focusPathsThroughVertex: (vertexKey: string) => void; getGenerationVisibility: (vertexKey: string, direction: EDirection) => ECheckedStatus | null; getVisiblePathElems: (vertexKey: string) => PathElem[] | undefined; hideVertex: (vertexKey: string) => void; - isFocalNode: boolean; - isPositioned: boolean; - operation: string | string[] | null; - service: string; + selectVertex: (selectedVertex: TDdgVertex) => void; setOperation: (operation: string) => void; setViewModifier: (visIndices: number[], viewModifier: EViewModifier, isEnabled: boolean) => void; updateGenerationVisibility: (vertexKey: string, direction: EDirection) => void; - vertexKey: string; -}; +}) { + return function renderNode(vertex: TDdgVertex, _: unknown, lv: TLayoutVertex | null) { + const { isFocalNode, key, operation, service } = vertex; + return ( + + ); + }; +} -type TState = { - childrenVisibility?: ECheckedStatus | null; - parentVisibility?: ECheckedStatus | null; -}; +export function measureNode() { + const diameter = 2 * (RADIUS + 1); -export default class DdgNodeContent extends React.PureComponent { + return { + height: diameter, + width: diameter, + }; +} + +export class UnconnectedDdgNodeContent extends React.PureComponent { state: TState = {}; + hoveredIndices: Set = new Set(); - static measureNode() { - const diameter = 2 * (RADIUS + 1); + constructor(props: TProps) { + super(props); - return { - height: diameter, - width: diameter, - }; + this.getDecoration(); } - static getNodeRenderer({ - baseUrl, - density, - extraUrlArgs, - focusPathsThroughVertex, - getGenerationVisibility, - getVisiblePathElems, - hideVertex, - setOperation, - setViewModifier, - updateGenerationVisibility, - }: { - baseUrl: string; - density: EDdgDensity; - extraUrlArgs?: { [key: string]: unknown }; - focusPathsThroughVertex: (vertexKey: string) => void; - getGenerationVisibility: (vertexKey: string, direction: EDirection) => ECheckedStatus | null; - getVisiblePathElems: (vertexKey: string) => PathElem[] | undefined; - hideVertex: (vertexKey: string) => void; - setOperation: (operation: string) => void; - setViewModifier: (visIndices: number[], viewModifier: EViewModifier, enable: boolean) => void; - updateGenerationVisibility: (vertexKey: string, direction: EDirection) => void; - }) { - return function renderNode(vertex: TDdgVertex, _: unknown, lv: TLayoutVertex | null) { - const { isFocalNode, key, operation, service } = vertex; - return ( - - ); - }; + componentDidUpdate(prevProps: TProps) { + if (prevProps.decorationID !== this.props.decorationID) this.getDecoration(); } - hoveredIndices: Set = new Set(); - componentWillUnmount() { if (this.hoveredIndices.size) { this.props.setViewModifier(Array.from(this.hoveredIndices), EViewModifier.Hovered, false); @@ -136,11 +162,24 @@ export default class DdgNodeContent extends React.PureComponent } } + private getDecoration() { + const { decorationID, getDecoration, operation, service } = this.props; + + if (decorationID) { + getDecoration(decorationID, service, typeof operation === 'string' ? operation : undefined); + } + } + private focusPaths = () => { const { focusPathsThroughVertex, vertexKey } = this.props; focusPathsThroughVertex(vertexKey); }; + private handleClick = () => { + const { decorationValue, selectVertex, vertex } = this.props; + if (decorationValue) selectVertex(vertex); + }; + private hideVertex = () => { const { hideVertex, vertexKey } = this.props; hideVertex(vertexKey); @@ -214,19 +253,33 @@ export default class DdgNodeContent extends React.PureComponent render() { const { childrenVisibility, parentVisibility } = this.state; - const { focalNodeUrl, isFocalNode, isPositioned, operation, service } = this.props; + const { + decorationProgressbar, + decorationValue, + focalNodeUrl, + isFocalNode, + isPositioned, + operation, + service, + } = this.props; const { radius, svcWidth, opWidth, svcMarginTop } = calcPositioning(service, operation); - const scaleFactor = RADIUS / radius; + const trueRadius = decorationProgressbar ? RADIUS - PROGRESS_BAR_STROKE_WIDTH : RADIUS; + const scaleFactor = trueRadius / radius; const transform = `translate(${RADIUS - radius}px, ${RADIUS - radius}px) scale(${scaleFactor})`; return (
+ {decorationProgressbar}
@@ -319,3 +372,18 @@ export default class DdgNodeContent extends React.PureComponent ); } } + +export function mapDispatchToProps(dispatch: Dispatch): TDispatchProps { + const { getDecoration } = bindActionCreators(padActions, dispatch); + + return { + getDecoration, + }; +} + +const DdgNodeContent = connect( + extractDecorationFromState, + mapDispatchToProps +)(UnconnectedDdgNodeContent); + +export default DdgNodeContent; diff --git a/packages/jaeger-ui/src/components/DeepDependencies/Graph/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/Graph/index.tsx index 1269737189..39a0b80b01 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/Graph/index.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/Graph/index.tsx @@ -19,7 +19,7 @@ import { TSetProps, TFromGraphStateFn, TDefEntry } from '@jaegertracing/plexus/l import { TEdge } from '@jaegertracing/plexus/lib/types'; import TNonEmptyArray from '@jaegertracing/plexus/lib/types/TNonEmptyArray'; -import DdgNodeContent from './DdgNodeContent'; +import { getNodeRenderer, measureNode } from './DdgNodeContent'; import getNodeRenderers from './getNodeRenderers'; import getSetOnEdge from './getSetOnEdge'; import { @@ -43,6 +43,7 @@ type TProps = { getGenerationVisibility: (vertexKey: string, direction: EDirection) => ECheckedStatus | null; getVisiblePathElems: (vertexKey: string) => PathElem[] | undefined; hideVertex: (vertexKey: string) => void; + selectVertex: (selectedVertex: TDdgVertex) => void; setOperation: (operation: string) => void; setViewModifier: (visIndices: number[], viewModifier: EViewModifier, enable: boolean) => void; uiFindMatches: Set | undefined; @@ -73,7 +74,7 @@ const edgesDefs: TNonEmptyArray> = [ export default class Graph extends PureComponent { private getNodeRenderers = memoize(getNodeRenderers); - private getNodeContentRenderer = memoize(DdgNodeContent.getNodeRenderer); + private getNodeContentRenderer = memoize(getNodeRenderer); private getSetOnEdge = memoize(getSetOnEdge); private layoutManager: LayoutManager = new LayoutManager({ @@ -102,6 +103,7 @@ export default class Graph extends PureComponent { getGenerationVisibility, getVisiblePathElems, hideVertex, + selectVertex, setOperation, setViewModifier, uiFindMatches, @@ -154,7 +156,7 @@ export default class Graph extends PureComponent { key: 'nodes/content', layerType: 'html', measurable: true, - measureNode: DdgNodeContent.measureNode, + measureNode, renderNode: this.getNodeContentRenderer({ baseUrl, density, @@ -163,6 +165,7 @@ export default class Graph extends PureComponent { getGenerationVisibility, getVisiblePathElems, hideVertex, + selectVertex, setOperation, setViewModifier, updateGenerationVisibility, diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.css b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.css new file mode 100644 index 0000000000..8259c3c353 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.css @@ -0,0 +1,73 @@ +/* +Copyright (c) 2020 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. +*/ + +.DetailsCard { + display: flex; + flex-direction: column; + padding: 0.5em; +} + +.DetailsCard--ButtonHeaderWrapper { + display: flex; +} + +.DetailsCard--Collapser { + border: none; + background: transparent; + cursor: pointer; + flex: 0; + font-size: 1.5rem; + transition: transform 0.25s ease-out; +} + +.DetailsCard--Collapser:focus { + outline: none; +} + +.DetailsCard--Collapser.is-collapsed { + transform: rotate(-90deg); +} + +.DetailsCard--HeaderWrapper { + flex-grow: 1; +} + +.DetailsCard--Header { + border-bottom: solid 1px #ddd; + box-shadow: 0px 5px 5px -5px rgba(0, 0, 0, 0.3); + font-size: 1.5em; + padding: 0.1em 0.3em; +} + +.DetailsCard--Description { + margin-bottom: 0; + max-width: 90%; +} + +.DetailsCard--DetailsWrapper { + overflow: scroll; + margin-top: 0.5em; + transition: max-height 0.25s ease-out; + max-height: 60vh; +} + +.DetailsCard--DetailsWrapper.is-collapsed { + max-height: 0px; +} + +.DetailsCard--DetailsWrapper th { + min-width: 65px; +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx new file mode 100644 index 0000000000..b41fef0929 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsCard/index.tsx @@ -0,0 +1,222 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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, Table } from 'antd'; +import cx from 'classnames'; +import _isEmpty from 'lodash/isEmpty'; +import MdKeyboardArrowDown from 'react-icons/lib/md/keyboard-arrow-down'; + +import { + TPadColumnDef, + TPadColumnDefs, + TPadDetails, + TPadRow, + TStyledValue, +} from '../../../../model/path-agnostic-decorations/types'; + +import './index.css'; + +const { Column } = Table; +const { Item } = List; + +type TProps = { + className?: string; + collapsible?: boolean; + columnDefs?: TPadColumnDefs; + description?: string; + details: TPadDetails; + header: string; +}; + +type TState = { + collapsed: boolean; +}; + +function isList(arr: string[] | TPadRow[]): arr is string[] { + return typeof arr[0] === 'string'; +} + +export default class DetailsCard extends React.PureComponent { + state: TState; + + static renderList(details: string[]) { + return ( + ( + + {s} + + )} + /> + ); + } + + static renderColumn(def: TPadColumnDef | string) { + let dataIndex: string; + let key: string; + let sortable: boolean = true; + let style: React.CSSProperties | undefined; + let title: string; + if (typeof def === 'string') { + // eslint-disable-next-line no-multi-assign + key = title = dataIndex = def; + } else { + // eslint-disable-next-line no-multi-assign + key = title = dataIndex = def.key; + if (def.label) title = def.label; + if (def.styling) style = def.styling; + if (def.preventSort) sortable = false; + } + + const props = { + dataIndex, + key, + title, + onCell: (row: TPadRow) => { + const cellData = row[dataIndex]; + if (!cellData || typeof cellData !== 'object') return null; + const { styling } = cellData; + if (_isEmpty(styling)) return null; + return { + style: styling, + }; + }, + onHeaderCell: () => ({ + style, + }), + render: (cellData: undefined | string | TStyledValue) => { + if (!cellData || typeof cellData !== 'object') return cellData; + if (!cellData.linkTo) return cellData.value; + return ( + + {cellData.value} + + ); + }, + sorter: + sortable && + ((a: TPadRow, b: TPadRow) => { + const aData = a[dataIndex]; + const aValue = typeof aData === 'object' && typeof aData.value === 'string' ? aData.value : aData; + const bData = b[dataIndex]; + const bValue = typeof bData === 'object' && typeof bData.value === 'string' ? bData.value : bData; + if (aValue < bValue) return -1; + return bValue < aValue ? 1 : 0; + }), + }; + + return ; + } + + constructor(props: TProps) { + super(props); + + this.state = { collapsed: Boolean(props.collapsible) }; + } + + renderTable(details: TPadRow[]) { + const { columnDefs: _columnDefs } = this.props; + const columnDefs: TPadColumnDefs = _columnDefs ? _columnDefs.slice() : []; + const knownColumns = new Set( + columnDefs.map(keyOrObj => { + if (typeof keyOrObj === 'string') return keyOrObj; + return keyOrObj.key; + }) + ); + details.forEach(row => { + Object.keys(row).forEach((col: string) => { + if (!knownColumns.has(col)) { + knownColumns.add(col); + columnDefs.push(col); + } + }); + }); + + return ( + + JSON.stringify(row, function replacer( + key: string, + value: TPadRow | string | number | TStyledValue + ) { + function isRow(v: typeof value): v is TPadRow { + return v === row; + } + if (isRow(value)) return value; + if (typeof value === 'object') { + if (typeof value.value === 'string') return value.value; + return value.value.key || 'Unknown'; + } + return value; + }) + } + > + {columnDefs.map(DetailsCard.renderColumn)} +
+ ); + } + + renderDetails() { + const { details } = this.props; + + if (Array.isArray(details)) { + if (details.length === 0) return null; + + if (isList(details)) return DetailsCard.renderList(details); + return this.renderTable(details); + } + + return {details}; + } + + toggleCollapse = () => { + this.setState((prevState: TState) => ({ + collapsed: !prevState.collapsed, + })); + }; + + render() { + const { collapsed } = this.state; + const { className, collapsible, description, header } = this.props; + + return ( +
+
+ {collapsible && ( + + )} +
+ {header} + {description &&

{description}

} +
+
+
+ {this.renderDetails()} +
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.css b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.css new file mode 100644 index 0000000000..348c23a21e --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.css @@ -0,0 +1,83 @@ +/* +Copyright (c) 2020 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. +*/ + +.Ddg--DetailsPanel { + background-color: #fafafa; + border-left: solid 1px #ddd; + box-shadow: -3px 0 3px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + height: 100%; +} + +.Ddg--DetailsPanel--SvcOpHeader { + border-bottom: solid 1px #ddd; + box-shadow: 0px 3px 3px rgba(0, 0, 0, 0.1); + font-size: 1.5em; + text-align: right; + padding: 0.1em 0.3em; +} + +.Ddg--DetailsPanel--DecorationHeader { + font-size: 1.3em; + text-align: center; + padding: 0.3em; +} + +.Ddg--DetailsPanel--DecorationHeader > span { + border-bottom: solid 1px #bbb; + box-shadow: 0px 3px 3px -3px rgba(0, 0, 0, 0.1); +} + +.Ddg--DetailsPanel--DetailLink { + padding-left: 0.2em; +} + +.Ddg--DetailsPanel--errorMsg { + color: #e58c33; +} + +.Ddg--DetailsPanel--DetailsCard { + background-color: #ffffff; + border: solid 2px rgba(0, 0, 0, 0.3); + margin: 0.5em; + overflow: hidden; +} + +.Ddg--DetailsPanel--DetailsCard.is-error { + color: #e58c33; +} + +.Ddg--DetailsPanel--LoadingIndicator { + display: block; + margin: auto; + position: absolute; + left: 0; + right: 0; + top: 50%; + line-height: 0; +} + +.Ddg--DetailsPanel--LoadingWrapper { + flex-grow: 1; + position: relative; +} + +.Ddg--DetailsPanel--PercentCircleWrapper { + margin: 0 auto; + max-width: 20vh; + padding: 0 3%; +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js new file mode 100644 index 0000000000..a349d4fb59 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.test.js @@ -0,0 +1,303 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 _set from 'lodash/set'; + +import stringSupplant from '../../../utils/stringSupplant'; +import JaegerAPI from '../../../api/jaeger'; +import ColumnResizer from '../../TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer'; +import { UnconnectedDetailsPanel as DetailsPanel } from './DetailsPanel'; + +describe('', () => { + const service = 'test svc'; + const opString = 'test op'; + const props = { + decorationSchema: { + detailUrl: 'http://test.detail.url?someParam=#{service}', + detailPath: 'test.detail.path', + opDetailUrl: 'http://test.opDetail.url?someParam=#{service}&otherParam=#{operation}', + opDetailPath: 'test.opDetail.path', + name: 'Decorating #{service}', + }, + decorationValue: 'test decorationValue', + service, + }; + const supplantedUrl = stringSupplant(props.decorationSchema.detailUrl, { service }); + const supplantedOpUrl = stringSupplant(props.decorationSchema.opDetailUrl, { + operation: opString, + service, + }); + let fetchDecorationSpy; + let promise; + let res; + let rej; + + beforeAll(() => { + fetchDecorationSpy = jest.spyOn(JaegerAPI, 'fetchDecoration').mockImplementation(() => { + promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + return promise; + }); + }); + + beforeEach(() => { + fetchDecorationSpy.mockClear(); + }); + + describe('fetchDetails', () => { + it('fetches correct details url, perferring op-scoped details, or does not fetch at all', async () => { + const details = 'test details'; + const columnDefs = ['test', 'column', 'defs']; + + const tests = []; + + ['detailUrl#{service}', undefined].forEach(detailUrl => { + ['detail.path.#{service}', undefined].forEach(detailPath => { + ['detail.column.def.path.#{service}', undefined].forEach(detailColumnDefPath => { + ['opDetailUrl#{service}#{operation}', undefined].forEach(opDetailUrl => { + ['op.detail.path.#{service}.#{operation}', undefined].forEach(opDetailPath => { + ['op.detail.column.def.path.#{service}', undefined].forEach(opDetailColumnDefPath => { + ['op', ['op0', 'op1'], undefined].forEach(operation => { + [{ message: 'Err obj with message' }, 'error message', false].forEach(error => { + [true, false].forEach(hasDetails => { + [true, false].forEach(hasColumnDefPath => { + tests.push(async () => { + fetchDecorationSpy.mockClear(); + const detailsPanel = new DetailsPanel({ + operation, + service, + decorationSchema: { + detailUrl, + detailPath, + detailColumnDefPath, + opDetailUrl, + opDetailPath, + opDetailColumnDefPath, + }, + }); + + const setStateSpy = jest.spyOn(detailsPanel, 'setState').mockImplementation(); + detailsPanel.fetchDetails(); + + let supplantedFetchUrl; + let supplantedColumnDefPath; + let supplantedDetailsPath; + + if ( + typeof opDetailUrl === 'string' && + typeof opDetailPath === 'string' && + typeof operation === 'string' + ) { + supplantedFetchUrl = stringSupplant(opDetailUrl, { service, operation }); + supplantedDetailsPath = stringSupplant(opDetailPath, { service, operation }); + if (opDetailColumnDefPath) + supplantedColumnDefPath = stringSupplant(opDetailColumnDefPath, { + service, + operation, + }); + } else if (typeof detailUrl === 'string' && typeof detailPath === 'string') { + supplantedFetchUrl = stringSupplant(detailUrl, { service }); + supplantedDetailsPath = stringSupplant(detailPath, { service }); + if (detailColumnDefPath) + supplantedColumnDefPath = stringSupplant(detailColumnDefPath, { service }); + } else { + expect(fetchDecorationSpy).not.toHaveBeenCalled(); + return; + } + + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(supplantedFetchUrl); + expect(setStateSpy).toHaveBeenLastCalledWith({ detailsLoading: true }); + + const expectedSetStateArg = { + detailsLoading: false, + detailsErred: Boolean(error || !hasDetails), + }; + + if (!error) { + const result = {}; + + if (hasDetails) { + _set(result, supplantedDetailsPath, details); + expectedSetStateArg.details = details; + } else { + expectedSetStateArg.details = `\`${supplantedDetailsPath}\` not found in response`; + } + + if (hasColumnDefPath && supplantedColumnDefPath) { + _set(result, supplantedColumnDefPath, columnDefs); + expectedSetStateArg.columnDefs = columnDefs; + } else { + expectedSetStateArg.columnDefs = []; + } + + res(result); + await promise; + expect(setStateSpy).toHaveBeenLastCalledWith(expectedSetStateArg); + } else { + const errorMessage = error.message || error; + expectedSetStateArg.details = `Unable to fetch decoration: ${errorMessage}`; + rej(error); + await promise.catch(() => {}); + expect(setStateSpy).toHaveBeenLastCalledWith(expectedSetStateArg); + } + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + const errors = []; + await Promise.all(tests.map(test => test().catch(err => errors.push(err)))); + if (errors.length) throw errors; + }); + }); + + describe('render', () => { + it('renders', () => { + const wrapper = shallow(); + wrapper.setState({ detailsLoading: false }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders with operation', () => { + const wrapper = shallow(); + wrapper.setState({ detailsLoading: false }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders omitted array of operations', () => { + const wrapper = shallow(); + wrapper.setState({ detailsLoading: false }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders with progressbar', () => { + const progressbar =
stand-in progressbar
; + const wrapper = shallow(); + wrapper.setState({ detailsLoading: false }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders while loading', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders details', () => { + const wrapper = shallow(); + wrapper.setState({ detailsLoading: false, details: 'details string' }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders details error', () => { + const wrapper = shallow(); + wrapper.setState({ detailsLoading: false, details: 'details error', detailsErred: true }); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders detailLink', () => { + const schemaWithLink = { + ...props.decorationSchema, + detailLink: 'test details link', + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('componentDidMount', () => { + it('fetches details', () => { + expect(fetchDecorationSpy).not.toHaveBeenCalled(); + + shallow(); + expect(fetchDecorationSpy).toHaveBeenCalled(); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(supplantedUrl); + }); + }); + + describe('componentDidUpdate', () => { + const expectedState = expect.objectContaining({ + details: undefined, + detailsErred: false, + detailsLoading: true, + }); + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + wrapper.setState({ + details: 'test details', + detailsErred: false, + detailsLoading: false, + }); + expect(fetchDecorationSpy).toHaveBeenCalledTimes(1); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(supplantedUrl); + }); + + it('fetches details and clears relevant state if decorationSchema changes', () => { + const detailUrl = 'http://new.schema.detailsUrl?service=#{service}'; + const newSchema = { + ...props.decorationSchema, + detailUrl, + }; + wrapper.setProps({ decorationSchema: newSchema }); + expect(fetchDecorationSpy).toHaveBeenCalledTimes(2); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(detailUrl, { service })); + expect(wrapper.state()).toEqual(expectedState); + }); + + it('fetches details and clears relevant state if operation changes', () => { + wrapper.setProps({ operation: opString }); + expect(fetchDecorationSpy).toHaveBeenCalledTimes(2); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(supplantedOpUrl); + expect(wrapper.state()).toEqual(expectedState); + }); + + it('fetches details and clears relevant state if service changes', () => { + const newService = 'different test service'; + wrapper.setProps({ service: newService }); + expect(fetchDecorationSpy).toHaveBeenCalledTimes(2); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith( + stringSupplant(props.decorationSchema.detailUrl, { service: newService }) + ); + expect(wrapper.state()).toEqual(expectedState); + }); + + it('does nothing if decorationSchema, operation, and service are unchanged', () => { + wrapper.setProps({ decorationValue: `not_${props.decorationValue}` }); + expect(fetchDecorationSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('onResize', () => { + it('updates state', () => { + const width = 60; + const wrapper = shallow(); + expect(wrapper.state('width')).not.toBe(width); + + wrapper.find(ColumnResizer).prop('onChange')(width); + expect(wrapper.state('width')).toBe(width); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx new file mode 100644 index 0000000000..18451fa3b3 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/DetailsPanel.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { Tooltip } from 'antd'; +import _get from 'lodash/get'; +import { connect } from 'react-redux'; + +import BreakableText from '../../common/BreakableText'; +import LoadingIndicator from '../../common/LoadingIndicator'; +import NewWindowIcon from '../../common/NewWindowIcon'; +import JaegerAPI from '../../../api/jaeger'; +import ColumnResizer from '../../TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer'; +import extractDecorationFromState, { TDecorationFromState } from '../../../model/path-agnostic-decorations'; +import { + TPathAgnosticDecorationSchema, + TPadColumnDefs, + TPadDetails, +} from '../../../model/path-agnostic-decorations/types'; +import stringSupplant from '../../../utils/stringSupplant'; +import DetailsCard from './DetailsCard'; + +import './DetailsPanel.css'; + +type TProps = TDecorationFromState & { + decorationSchema: TPathAgnosticDecorationSchema; + service: string; + operation?: string | string[] | null; +}; + +type TState = { + columnDefs?: TPadColumnDefs; + details?: TPadDetails; + detailsErred?: boolean; + detailsLoading?: boolean; + width?: number; +}; + +export class UnconnectedDetailsPanel extends React.PureComponent { + state: TState = {}; + + componentDidMount() { + this.fetchDetails(); + } + + componentDidUpdate(prevProps: TProps) { + if ( + prevProps.operation !== this.props.operation || + prevProps.service !== this.props.service || + prevProps.decorationSchema !== this.props.decorationSchema + ) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + details: undefined, + detailsErred: false, + detailsLoading: false, + }); + this.fetchDetails(); + } + } + + fetchDetails() { + const { + decorationSchema: { + detailUrl, + detailPath, + detailColumnDefPath, + opDetailUrl, + opDetailPath, + opDetailColumnDefPath, + }, + operation: _op, + service, + } = this.props; + + const operation = _op && !Array.isArray(_op) ? _op : undefined; + + let fetchUrl: string | undefined; + let getDetailPath: string | undefined; + let getDefPath: string | undefined; + if (opDetailUrl && opDetailPath && operation) { + fetchUrl = stringSupplant(opDetailUrl, { service, operation }); + getDetailPath = stringSupplant(opDetailPath, { service, operation }); + getDefPath = opDetailColumnDefPath && stringSupplant(opDetailColumnDefPath, { service, operation }); + } else if (detailUrl && detailPath) { + fetchUrl = stringSupplant(detailUrl, { service }); + getDetailPath = stringSupplant(detailPath, { service }); + getDefPath = detailColumnDefPath && stringSupplant(detailColumnDefPath, { service }); + } + + if (!fetchUrl || !getDetailPath) return; + + this.setState({ detailsLoading: true }); + + JaegerAPI.fetchDecoration(fetchUrl) + .then((res: unknown) => { + let detailsErred = false; + let details = _get(res, getDetailPath as string); + if (details === undefined) { + details = `\`${getDetailPath}\` not found in response`; + detailsErred = true; + } + const columnDefs: TPadColumnDefs = getDefPath ? _get(res, getDefPath, []) : []; + + this.setState({ columnDefs, details, detailsErred, detailsLoading: false }); + }) + .catch((err: Error) => { + this.setState({ + details: `Unable to fetch decoration: ${err.message || err}`, + detailsErred: true, + detailsLoading: false, + }); + }); + } + + onResize = (width: number) => { + this.setState({ width }); + }; + + render() { + const { decorationProgressbar, decorationSchema, decorationValue, operation: _op, service } = this.props; + const { detailLink } = decorationSchema; + const { width = 0.3 } = this.state; + const operation = _op && !Array.isArray(_op) ? _op : undefined; + return ( +
+
+ + {operation && } +
+
+ {stringSupplant(decorationSchema.name, { service, operation })} + {detailLink && ( + + + + + + )} +
+ {decorationProgressbar ? ( +
{decorationProgressbar}
+ ) : ( + {decorationValue} + )} + {this.state.detailsLoading && ( +
+ +
+ )} + {this.state.details && ( + + )} + +
+ ); + } +} + +export default connect(extractDecorationFromState)(UnconnectedDetailsPanel); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap new file mode 100644 index 0000000000..64143fcf26 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/DetailsPanel.test.js.snap @@ -0,0 +1,377 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render renders 1`] = ` +
+
+ +
+
+ + Decorating test svc + +
+ + test decorationValue + + +
+`; + +exports[` render renders detailLink 1`] = ` +
+
+ +
+
+ + Decorating test svc + + + + + + +
+ + test decorationValue + +
+ +
+ +
+`; + +exports[` render renders details 1`] = ` +
+
+ +
+
+ + Decorating test svc + +
+ + test decorationValue + + + +
+`; + +exports[` render renders details error 1`] = ` +
+
+ +
+
+ + Decorating test svc + +
+ + test decorationValue + + + +
+`; + +exports[` render renders omitted array of operations 1`] = ` +
+
+ +
+
+ + Decorating test svc + +
+ + test decorationValue + + +
+`; + +exports[` render renders while loading 1`] = ` +
+
+ +
+
+ + Decorating test svc + +
+ + test decorationValue + +
+ +
+ +
+`; + +exports[` render renders with operation 1`] = ` +
+
+ + +
+
+ + Decorating test svc + +
+ + test decorationValue + + +
+`; + +exports[` render renders with progressbar 1`] = ` +
+
+ +
+
+ + Decorating test svc + +
+
+
+ stand-in progressbar +
+
+ +
+`; diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/index.test.js.snap b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000..64999374e4 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/__snapshots__/index.test.js.snap @@ -0,0 +1,322 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` info button opens info modal 1`] = ` +Object { + "content": , + "maskClosable": true, + "title": "Decoration Options", + "width": "60vw", +} +`; + +exports[` render ignores selectedVertex without selected decoration 1`] = ` +
+
+ +
+ + + + +
+ +
+
+
+`; + +exports[` render renders config decorations with clear button 1`] = ` +
+
+ +
+ + + + +
+ +
+
+
+`; + +exports[` render renders selected decoration 1`] = ` +
+
+ +
+ + + + +
+ +
+
+
+`; + +exports[` render renders sidePanel and closeBtn when vertex and decoration are both selected 1`] = ` +
+
+ +
+ + + + +
+ +
+
+ +
+
+`; diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.css b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.css new file mode 100644 index 0000000000..6c4e1dba2f --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.css @@ -0,0 +1,91 @@ +/* +Copyright (c) 2020 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. +*/ + +.Ddg--SidePanel { + background-color: #f6f6f6; + border-left: solid 1px #ddd; + box-shadow: -3px 0 3px rgba(0, 0, 0, 0.1); + display: flex; +} + +.Ddg--SidePanel--Btns, +.Ddg--SidePanel--DecorationBtns { + display: flex; + flex-direction: column; +} + +.Ddg--SidePanel--Btns { + justify-content: space-between; +} + +.Ddg--SidePanel--Btns button { + cursor: pointer; +} + +.Ddg--SidePanel--closeBtn, +.Ddg--SidePanel--infoBtn { + background: transparent; + border: none; + margin: 5px 0px; +} + +.Ddg--SidePanel--closeBtn > svg, +.Ddg--SidePanel--infoBtn > svg { + height: 80%; + width: 80%; +} + +.Ddg--SidePanel--closeBtn:focus > svg, +.Ddg--SidePanel--infoBtn:focus > svg { + filter: drop-shadow(0 0 2px #006c99); +} + +.Ddg--SidePanel--closeBtn:focus, +.Ddg--SidePanel--infoBtn:focus { + outline: none; +} + +.Ddg--SidePanel--closeBtn.is-hidden { + visibility: hidden; + pointer-events: none; +} + +.Ddg--SidePanel--DecorationBtns { + flex-grow: 1; + justify-content: center; +} + +.Ddg--SidePanel--decorationBtn { + border-radius: 100%; + margin: 3px; + padding-bottom: 25%; + padding-top: 25%; +} + +.Ddg--SidePanel--decorationBtn.is-selected { + box-shadow: 0px 0px 4px 2px #006c99; +} + +.Ddg--SidePanel--decorationBtn:focus { + box-shadow: inset 0 0 5px 2px #00b4ff; + outline: none; +} + +.Ddg--SidePanel--decorationBtn.is-selected:focus { + border: none; + margin: 4px; + box-shadow: inset 0 0 5px 2px #00b4ff, 0px 0px 5px 3px #006c99; +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.test.js b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.test.js new file mode 100644 index 0000000000..7f914ac71e --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.test.js @@ -0,0 +1,218 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { Modal } from 'antd'; + +import SidePanel from '.'; +import * as track from './index.track'; +import * as getConfig from '../../../utils/config/get-config'; + +describe('', () => { + let getConfigValueSpy; + const testAcronym = 'TA'; + const testID = 'test ID'; + const mockConfig = [ + { + id: 'first', + acronym: '1st', + name: 'First Decoration', + }, + { + id: testID, + acronym: testAcronym, + name: 'Decoration to test interactions', + }, + { + id: 'last', + acronym: 'LO', + name: 'The last one', + }, + ]; + const testVertex = { service: 'svc', operation: 'op' }; + + let trackDecorationSelectedSpy; + let trackDecorationViewDetailsSpy; + + beforeAll(() => { + getConfigValueSpy = jest.spyOn(getConfig, 'getConfigValue').mockReturnValue(mockConfig); + trackDecorationSelectedSpy = jest.spyOn(track, 'trackDecorationSelected'); + trackDecorationViewDetailsSpy = jest.spyOn(track, 'trackDecorationViewDetails'); + }); + + beforeEach(() => { + trackDecorationSelectedSpy.mockReset(); + trackDecorationViewDetailsSpy.mockReset(); + }); + + describe('constructor', () => { + it('inits decorations', () => { + const wrapper = shallow(); + expect(wrapper.instance().decorations).toBe(mockConfig); + }); + + it('tracks initial selection', () => { + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(0); + + shallow(); + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(1); + expect(trackDecorationSelectedSpy).toHaveBeenLastCalledWith(testID); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(0); + + shallow(); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(testVertex); + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('render', () => { + it('renders null if there are no decorations', () => { + getConfigValueSpy.mockReturnValueOnce(undefined); + const wrapper = shallow(); + expect(wrapper.getElement()).toBe(null); + }); + + it('renders config decorations with clear button', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders selected decoration', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('ignores selectedVertex without selected decoration', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders sidePanel and closeBtn when vertex and decoration are both selected', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('componentDidUpdate', () => { + it('tracks change in vertex from absent to present', () => { + const wrapper = shallow(); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(0); + + wrapper.setProps({ selectedVertex: testVertex }); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(testVertex); + }); + + it('tracks change in vertex from present to absent', () => { + const wrapper = shallow(); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + + wrapper.setProps({ selectedVertex: undefined }); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(2); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(undefined); + }); + + it('tracks change in vertex between different vertexes', () => { + const wrapper = shallow(); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(testVertex); + + const newVertex = { ...testVertex }; + wrapper.setProps({ selectedVertex: newVertex }); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(2); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(newVertex); + }); + + it('does not track unchanged vertex', () => { + const wrapper = shallow(); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(testVertex); + + wrapper.setProps({ selectedDecoration: testID }); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('clearSelected', () => { + it('clears selected and tracks clearing', () => { + const clearSelected = jest.fn(); + const wrapper = shallow(); + expect(clearSelected).toHaveBeenCalledTimes(0); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(1); + + wrapper + .find('button') + .at(0) + .simulate('click'); + expect(clearSelected).toHaveBeenCalledTimes(1); + expect(trackDecorationViewDetailsSpy).toHaveBeenCalledTimes(2); + expect(trackDecorationViewDetailsSpy).toHaveBeenLastCalledWith(); + }); + }); + + describe('selectDecoration', () => { + const selectDecoration = jest.fn(); + + beforeEach(() => { + selectDecoration.mockReset(); + }); + + it('selects decoration and tracks selection', () => { + const wrapper = shallow(); + expect(selectDecoration).toHaveBeenCalledTimes(0); + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(0); + + wrapper.find(`button[children="${testAcronym}"]`).simulate('click'); + expect(selectDecoration).toHaveBeenCalledTimes(1); + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(1); + expect(trackDecorationSelectedSpy).toHaveBeenLastCalledWith(testID); + }); + + it('clears decoration and tracks clear', () => { + const wrapper = shallow(); + expect(selectDecoration).toHaveBeenCalledTimes(0); + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(1); + + wrapper + .find('.Ddg--SidePanel--DecorationBtns > button') + .last() + .simulate('click'); + expect(selectDecoration).toHaveBeenCalledTimes(1); + expect(trackDecorationSelectedSpy).toHaveBeenCalledTimes(2); + expect(trackDecorationSelectedSpy).toHaveBeenLastCalledWith(undefined); + }); + }); + + describe('info button ', () => { + let modalInfoSpy; + + beforeAll(() => { + modalInfoSpy = jest.spyOn(Modal, 'info'); + }); + + it('opens info modal', () => { + const wrapper = shallow(); + expect(modalInfoSpy).toHaveBeenCalledTimes(0); + + wrapper + .find('button') + .last() + .simulate('click'); + expect(modalInfoSpy).toHaveBeenCalledTimes(1); + expect(modalInfoSpy.mock.calls[0][0]).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.test.js b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.test.js new file mode 100644 index 0000000000..7d1694996e --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.test.js @@ -0,0 +1,61 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 trackingUtils from '../../../utils/tracking'; +import { + CATEGORY_DECORATION_SELECTION, + CATEGORY_DECORATION_VIEW_DETAILS, + ACTION_CLEAR, + ACTION_SET, + trackDecorationSelected, + trackDecorationViewDetails, +} from './index.track'; + +describe('PAD SidePanel tracking', () => { + let trackEvent; + + beforeAll(() => { + trackEvent = jest.spyOn(trackingUtils, 'trackEvent').mockImplementation(); + }); + + beforeEach(() => { + trackEvent.mockClear(); + }); + + describe('trackDecorationSelected', () => { + it('tracks decoration selection with label', () => { + const decoration = 'test decoration ID'; + trackDecorationSelected(decoration); + expect(trackEvent).toHaveBeenCalledWith(CATEGORY_DECORATION_SELECTION, ACTION_SET, decoration); + }); + + it('tracks decoration cleared', () => { + trackDecorationSelected(); + expect(trackEvent).toHaveBeenCalledWith(CATEGORY_DECORATION_SELECTION, ACTION_CLEAR); + }); + }); + + describe('trackDecorationViewDetails', () => { + it('tracks details viewed', () => { + const truthyArg = { service: 'svc', operation: 'op' }; + trackDecorationViewDetails(truthyArg); + expect(trackEvent).toHaveBeenCalledWith(CATEGORY_DECORATION_VIEW_DETAILS, ACTION_SET); + }); + + it('tracks details closed', () => { + trackDecorationViewDetails(); + expect(trackEvent).toHaveBeenCalledWith(CATEGORY_DECORATION_VIEW_DETAILS, ACTION_CLEAR); + }); + }); +}); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.tsx new file mode 100644 index 0000000000..41be6d9123 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.track.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { trackEvent } from '../../../utils/tracking'; + +// export for tests +export const CATEGORY_DECORATION_SELECTION = 'jaeger/ux/ddg/decoration-selection'; +export const CATEGORY_DECORATION_VIEW_DETAILS = 'jaeger/ux/ddg/decoration-view-details'; + +// export for tests +export const ACTION_CLEAR = 'clear'; +export const ACTION_SET = 'set'; + +export function trackDecorationSelected(decoration?: string) { + if (decoration) trackEvent(CATEGORY_DECORATION_SELECTION, ACTION_SET, decoration); + else trackEvent(CATEGORY_DECORATION_SELECTION, ACTION_CLEAR); +} + +export function trackDecorationViewDetails(value?: unknown) { + if (value) trackEvent(CATEGORY_DECORATION_VIEW_DETAILS, ACTION_SET); + else trackEvent(CATEGORY_DECORATION_VIEW_DETAILS, ACTION_CLEAR); +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.tsx new file mode 100644 index 0000000000..5daae1e527 --- /dev/null +++ b/packages/jaeger-ui/src/components/DeepDependencies/SidePanel/index.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { Modal, Table } from 'antd'; +import MdExitToApp from 'react-icons/lib/md/exit-to-app'; +import MdInfoOutline from 'react-icons/lib/md/info-outline'; + +import { TDdgVertex } from '../../../model/ddg/types'; +import { TPathAgnosticDecorationSchema } from '../../../model/path-agnostic-decorations/types'; +import { getConfigValue } from '../../../utils/config/get-config'; +import DetailsPanel from './DetailsPanel'; +import * as track from './index.track'; + +import './index.css'; + +type TProps = { + clearSelected: () => void; + selectDecoration: (decoration?: string) => void; + selectedDecoration?: string; + selectedVertex?: TDdgVertex; +}; + +export default class SidePanel extends React.PureComponent { + decorations: TPathAgnosticDecorationSchema[] | undefined; + + constructor(props: TProps) { + super(props); + + const { selectedDecoration, selectedVertex } = props; + if (selectedDecoration) track.trackDecorationSelected(selectedDecoration); + if (selectedVertex) track.trackDecorationViewDetails(selectedVertex); + + this.decorations = getConfigValue('pathAgnosticDecorations'); + } + + componentDidUpdate(prevProps: TProps) { + if (prevProps.selectedVertex !== this.props.selectedVertex) { + track.trackDecorationViewDetails(this.props.selectedVertex); + } + } + + clearSelected = () => { + track.trackDecorationViewDetails(); + this.props.clearSelected(); + }; + + selectDecoration = (decoration?: string) => { + track.trackDecorationSelected(decoration); + this.props.selectDecoration(decoration); + }; + + openInfoModal = () => { + Modal.info({ + content: ( +
schema.id} + /> + ), + maskClosable: true, + title: 'Decoration Options', + width: '60vw', + }); + }; + + render() { + if (!this.decorations) return null; + + const { selectedDecoration, selectedVertex } = this.props; + + const selectedSchema = this.decorations.find(({ id }) => id === selectedDecoration); + + return ( +
+
+ +
+ {this.decorations.map(({ acronym, id }) => ( + + ))} + +
+ +
+
+ {selectedVertex && selectedSchema && ( + + )} +
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/index.css b/packages/jaeger-ui/src/components/DeepDependencies/index.css index 2f99030ed9..c70e0d123d 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/index.css +++ b/packages/jaeger-ui/src/components/DeepDependencies/index.css @@ -30,6 +30,12 @@ limitations under the License. } .Ddg--graphWrapper { + display: flex; flex: 1; + flex-direction: column; overflow: hidden; } + +.Ddg--graphWrapper.is-horizontal { + flex-direction: row; +} diff --git a/packages/jaeger-ui/src/components/DeepDependencies/index.test.js b/packages/jaeger-ui/src/components/DeepDependencies/index.test.js index 67a6ca24c2..e1bccf5d25 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/index.test.js +++ b/packages/jaeger-ui/src/components/DeepDependencies/index.test.js @@ -270,6 +270,26 @@ describe('DeepDependencyGraphPage', () => { }); }); + describe('setDecoration', () => { + it('updates url with provided density', () => { + const decoration = 'decoration-id'; + ddgPageImpl.setDecoration(decoration); + expect(getUrlSpy).toHaveBeenLastCalledWith( + Object.assign({}, props.urlState, { decoration }), + undefined + ); + }); + + it('clears density from url', () => { + const decoration = undefined; + ddgPageImpl.setDecoration(decoration); + expect(getUrlSpy).toHaveBeenLastCalledWith( + Object.assign({}, props.urlState, { decoration }), + undefined + ); + }); + }); + describe('setDensity', () => { it('updates url with provided density', () => { const density = EDdgDensity.PreventPathEntanglement; @@ -492,6 +512,28 @@ describe('DeepDependencyGraphPage', () => { }); }); + describe('select vertex', () => { + let wrapper; + const selectedVertex = { key: 'test vertex' }; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('selects a vertex', () => { + expect(wrapper.state('selectedVertex')).toBeUndefined(); + wrapper.instance().selectVertex(selectedVertex); + expect(wrapper.state('selectedVertex')).toEqual(selectedVertex); + }); + + it('clears a vertex', () => { + wrapper.setState({ selectedVertex }); + expect(wrapper.state('selectedVertex')).toEqual(selectedVertex); + wrapper.instance().selectVertex(); + expect(wrapper.state('selectedVertex')).toBeUndefined(); + }); + }); + describe('view modifiers', () => { const visibilityIndices = ['visId0', 'visId1', 'visId2']; const targetVM = EViewModifier.Emphasized; diff --git a/packages/jaeger-ui/src/components/DeepDependencies/index.track.test.js b/packages/jaeger-ui/src/components/DeepDependencies/index.track.test.js index cbd2ad98d3..31cf161b73 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/index.track.test.js +++ b/packages/jaeger-ui/src/components/DeepDependencies/index.track.test.js @@ -29,6 +29,7 @@ import { ACTION_HIDE_PARENTS, ACTION_INCREASE, ACTION_SET_FOCUS, + ACTION_SET_OPERATION, ACTION_SHOW, ACTION_SHOW_CHILDREN, ACTION_SHOW_PARENTS, @@ -41,6 +42,7 @@ import { trackSetFocus, trackShowMatches, trackToggleShowOp, + trackVertexSetOperation, trackViewTraces, } from './index.track'; import { EDdgDensity, EDirection } from '../../model/ddg/types'; @@ -235,6 +237,13 @@ describe('DeepDependencies tracking', () => { ); }); + describe('trackVertexSetOperation', () => { + it('calls trackEvent with the match category and show action', () => { + trackVertexSetOperation(); + expect(trackEvent).toHaveBeenCalledWith(CATEGORY_VERTEX_INTERACTIONS, ACTION_SET_OPERATION); + }); + }); + describe('trackViewTraces', () => { it('calls trackViewTraces with the vertex category and view traces action', () => { trackViewTraces(); diff --git a/packages/jaeger-ui/src/components/DeepDependencies/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/index.tsx index 380cf9dcb3..8df2a3525f 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/index.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/index.tsx @@ -19,8 +19,9 @@ import { bindActionCreators, Dispatch } from 'redux'; import { connect } from 'react-redux'; import { trackClearOperation, trackFocusPaths, trackHide, trackSetService, trackShow } from './index.track'; -import Header from './Header'; import Graph from './Graph'; +import Header from './Header'; +import SidePanel from './SidePanel'; import { getUrl, getUrlState, sanitizeUrlState, ROUTE_PATH } from './url'; import ErrorMessage from '../common/ErrorMessage'; import LoadingIndicator from '../common/LoadingIndicator'; @@ -38,6 +39,7 @@ import { EViewModifier, TDdgModelParams, TDdgSparseUrlState, + TDdgVertex, } from '../../model/ddg/types'; import { encode, encodeDistance } from '../../model/ddg/visibility-codec'; import { getConfigValue } from '../../utils/config/get-config'; @@ -75,8 +77,12 @@ export type TOwnProps = { export type TProps = TDispatchProps & TReduxProps & TOwnProps; +type TState = { + selectedVertex?: TDdgVertex; +}; + // export for tests -export class DeepDependencyGraphPageImpl extends React.PureComponent { +export class DeepDependencyGraphPageImpl extends React.PureComponent { static defaultProps = { showSvcOpsHeader: true, baseUrl: ROUTE_PATH, @@ -90,6 +96,8 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { } } + state: TState = {}; + constructor(props: TProps) { super(props); DeepDependencyGraphPageImpl.fetchModelIfStale(props); @@ -207,6 +215,10 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { }); }; + selectVertex = (selectedVertex?: TDdgVertex) => { + this.setState({ selectedVertex }); + }; + showVertices = (vertexKeys: string[]) => { const { graph, urlState } = this.props; const { visEncoding } = urlState; @@ -238,6 +250,7 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { }; render() { + const { selectedVertex } = this.state; const { baseUrl, extraUrlArgs, @@ -257,6 +270,7 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { const hiddenUiFindMatches = graph && graph.getHiddenUiFindMatches(uiFind, visEncoding); let content: React.ReactElement | null = null; + let wrapperClassName: string = ''; if (!graphState) { content =

Enter query above

; } else if (graphState.state === fetchedState.DONE && graph) { @@ -267,26 +281,36 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { viewModifiers ); if (vertices.length > 1) { + wrapperClassName = 'is-horizontal'; // TODO: using `key` here is a hack, debug digraph to fix the underlying issue content = ( - + <> + + + ); } else if ( graphState.model.distanceToPathElems.has(-1) || @@ -362,7 +386,7 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { visEncoding={visEncoding} /> -
{content}
+
{content}
); } diff --git a/packages/jaeger-ui/src/components/DeepDependencies/url.test.js b/packages/jaeger-ui/src/components/DeepDependencies/url.test.js index 0386c22949..81ab2f25f8 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/url.test.js +++ b/packages/jaeger-ui/src/components/DeepDependencies/url.test.js @@ -70,6 +70,7 @@ describe('DeepDependencyGraph/url', () => { }); describe('getUrlState', () => { + const decoration = 'test decoration'; const density = 'test density'; const end = '900'; const hash = 'test hash'; @@ -79,6 +80,7 @@ describe('DeepDependencyGraph/url', () => { const start = '400'; const visEncoding = 'vis encoding'; const acceptableParams = { + decoration, density, end, hash, @@ -89,6 +91,7 @@ describe('DeepDependencyGraph/url', () => { visEncoding, }; const expectedParams = { + decoration, density, end: Number.parseInt(end, 10), hash, @@ -152,7 +155,7 @@ describe('DeepDependencyGraph/url', () => { }); it('omits falsy values', () => { - ['end', 'hash', 'operation', 'service', 'start', 'visEncoding'].forEach(param => { + ['decoration', 'end', 'hash', 'operation', 'service', 'start', 'visEncoding'].forEach(param => { [null, undefined, ''].forEach(falsyPossibility => { parseSpy.mockReturnValue({ ...expectedParams, [param]: falsyPossibility }); expect(Reflect.has(getUrlState(getSearch()), param)).toBe(false); @@ -161,14 +164,16 @@ describe('DeepDependencyGraph/url', () => { }); it('handles and warns on duplicate values', () => { - ['end', 'hash', 'operation', 'service', 'showOp', 'start', 'visEncoding'].forEach(param => { - const secondParam = `second ${acceptableParams[param]}`; - parseSpy.mockReturnValue({ - ...acceptableParams, - [param]: [acceptableParams[param], secondParam], - }); - expect(getUrlState(getSearch())[param]).toBe(expectedParams[param]); - }); + ['decoration', 'end', 'hash', 'operation', 'service', 'showOp', 'start', 'visEncoding'].forEach( + param => { + const secondParam = `second ${acceptableParams[param]}`; + parseSpy.mockReturnValue({ + ...acceptableParams, + [param]: [acceptableParams[param], secondParam], + }); + expect(getUrlState(getSearch())[param]).toBe(expectedParams[param]); + } + ); }); it('memoizes correctly', () => { diff --git a/packages/jaeger-ui/src/components/QualityMetrics/BannerText.css b/packages/jaeger-ui/src/components/QualityMetrics/BannerText.css new file mode 100644 index 0000000000..83e86a6adf --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/BannerText.css @@ -0,0 +1,22 @@ +/* +Copyright (c) 2020 Uber Technologies, Inc. + +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. +*/ + +.BannerText { + font-size: 1.5em; + font-weight: heavy; + padding: 0.5rem 1rem 0.5rem 1rem; + text-align: center; +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/BannerText.tsx b/packages/jaeger-ui/src/components/QualityMetrics/BannerText.tsx new file mode 100644 index 0000000000..457feb01c5 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/BannerText.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { TQualityMetrics } from './types'; + +import './BannerText.css'; + +export type TProps = { + bannerText: TQualityMetrics['bannerText']; +}; + +export default class BannerText extends React.PureComponent { + render() { + const { bannerText } = this.props; + if (!bannerText) return null; + + const { styling = undefined, value: text } = + typeof bannerText === 'object' ? bannerText : { value: bannerText }; + + return ( +
+ {text} +
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/CountCard.css b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.css new file mode 100644 index 0000000000..78703c24dc --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.css @@ -0,0 +1,35 @@ +/* +Copyright (c) 2020 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. +*/ + +.CountCard { + display: flex; + flex-direction: column; + margin: 5px 5%; + padding: 5px; + text-align: center; +} + +.CountCard--Count { + font-size: 1.6em; + padding: 0.3em; +} + +.CountCard--TitleHeader { + border-bottom: solid 1px #ddd; + box-shadow: 0px 5px 5px -5px rgba(0, 0, 0, 0.3); + font-size: 1.1em; + margin: 0 auto; +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx new file mode 100644 index 0000000000..64700c8218 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/CountCard.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 ExamplesLink from './ExamplesLink'; + +import { TExample } from './types'; + +import './CountCard.css'; + +export type TProps = { + count?: number; + title?: string; + examples?: TExample[]; +}; + +export default class ScoreCard extends React.PureComponent { + render() { + const { count, title, examples } = this.props; + + if (count === undefined || title === undefined) return null; + + return ( +
+ {title} + {count} + +
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx b/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx new file mode 100644 index 0000000000..14e91a8ee3 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/ExamplesLink.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 NewWindowIcon from '../common/NewWindowIcon'; +import { getUrl } from '../SearchTracePage/url'; + +import { TExample } from './types'; + +export type TProps = { + examples?: TExample[]; + includeText?: boolean; +}; + +type TExampleWithSpans = { + traceID: string; + spanIDs: string[]; +}; + +function hasSpans(example: TExample | TExampleWithSpans): example is TExampleWithSpans { + return Boolean(example.spanIDs && example.spanIDs.length); +} + +function getGetUrlArg(examples: TExample[]): { spanLinks: Record; traceID: string[] } { + const spanLinks: Record = {}; + const traceID: string[] = []; + examples.forEach((example: TExample) => { + if (hasSpans(example)) spanLinks[example.traceID] = example.spanIDs.join(' '); + else traceID.push(example.traceID); + }); + return { + spanLinks, + traceID, + }; +} + +export default class ExamplesLink extends React.PureComponent { + render() { + const { examples, includeText } = this.props; + + if (!examples || !examples.length) return null; + + return ( + + {includeText && 'Examples '} + + + ); + } +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/Header.css b/packages/jaeger-ui/src/components/QualityMetrics/Header.css new file mode 100644 index 0000000000..1a603efbc8 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/Header.css @@ -0,0 +1,41 @@ +/* +Copyright (c) 2020 Uber Technologies, Inc. + +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. +*/ + +.QualityMetrics--Header { + align-items: center; + background: #fafafa; + border-bottom: 1px solid #ddd; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: row; + padding: 0.75rem 1.25rem 0.75rem 1.25rem; + position: relative; + z-index: 10; +} + +.QualityMetrics--Header--LookbackLabel { + color: var(--tx-color-muted); + cursor: pointer; + font-size: 1.5em; + margin: 0 0.6em 0 0; + outline: 4px solid transparent; +} + +.QualityMetrics--Header--LookbackSuffix { + color: var(--tx-color-muted); + margin: 0 0 0 0.6em; + outline: 4px solid transparent; +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/Header.tsx b/packages/jaeger-ui/src/components/QualityMetrics/Header.tsx new file mode 100644 index 0000000000..51ad574043 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/Header.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { InputNumber } from 'antd'; +import _debounce from 'lodash/debounce'; + +import NameSelector from '../DeepDependencies/Header/NameSelector'; + +import './Header.css'; + +type TProps = { + lookback: number; + service?: string; + services?: string[] | null; + setLookback: (lookback: number | string | undefined) => void; + setService: (service: string) => void; +}; + +type TState = { + ownInputValue: number | undefined; +}; + +export default class Header extends React.PureComponent { + state: TState = { + ownInputValue: undefined, + }; + + setLookback = _debounce((lookback: number | string | undefined) => { + this.setState({ ownInputValue: undefined }); + this.props.setLookback(lookback); + }, 350); + + handleInputChange = (value: string | number | undefined) => { + if (typeof value === 'string') return; + this.setState({ ownInputValue: value }); + this.setLookback(value); + }; + + render() { + const { lookback, service, services, setService } = this.props; + const { ownInputValue } = this.state; + const lookbackValue = ownInputValue !== undefined ? ownInputValue : lookback; + + return ( +
+ + + + (in hours) +
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.css b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.css new file mode 100644 index 0000000000..de4705d2ba --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.css @@ -0,0 +1,59 @@ +/* +Copyright (c) 2020 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. +*/ + +.MetricCard { + border-radius: 7px; + border: 1px rgba(0, 0, 0, 0.5) solid; + display: flex; + margin: 5px 5%; +} + +.MetricCard--Body { + border-left: solid 1px rgba(0, 0, 0, 0.3); + box-shadow: -3px 0 3px rgba(0, 0, 0, 0.1); + flex-grow: 1; + padding: 5px; + text-align: center; +} + +.MetricCard--CircularProgressbarWrapper { + flex-shrink: 0; + max-width: 180px; + padding: 5px; + width: 25%; +} + +.MetricCard--CountsWrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-around; +} + +.MetricCard--Description { + margin: 0 auto; + width: 60%; +} + +.MetricCard--Details { + text-align: left; + padding: 0.3em; +} + +.MetricCard--TitleHeader { + border-bottom: solid 1px #ddd; + box-shadow: 0px 5px 5px -5px rgba(0, 0, 0, 0.3); + font-size: 1.5em; +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx new file mode 100644 index 0000000000..82dad7c4d4 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/MetricCard.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { Tooltip } from 'antd'; + +import CircularProgressbar from '../common/CircularProgressbar'; +import NewWindowIcon from '../common/NewWindowIcon'; +import DetailsCard from '../DeepDependencies/SidePanel/DetailsCard'; +import CountCard from './CountCard'; + +import { TQualityMetrics } from './types'; + +import './MetricCard.css'; + +export type TProps = { + metric: TQualityMetrics['metrics'][0]; +}; + +const dividToFixedFloorPercentage = (pass: number, fail: number) => { + const str = `${(pass / (pass + fail)) * 100}.0`; + return `${str.substring(0, str.indexOf('.') + 2)}%`; +}; + +export default class MetricCard extends React.PureComponent { + render() { + const { + metric: { + name, + description, + metricDocumentationLink, + passCount, + passExamples, + failureCount, + failureExamples, + exemptionCount, + exemptionExamples, + details, + }, + } = this.props; + return ( +
+
+ +
+
+ + {name}{' '} + + + + + + +

{description}

+
+ + + +
+ {details && + details.map( + detail => + Boolean(detail.rows && detail.rows.length) && ( + + ) + )} +
+
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.css b/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.css new file mode 100644 index 0000000000..171748f9d6 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.css @@ -0,0 +1,32 @@ +/* +Copyright (c) 2020 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. +*/ + +.ScoreCard { + margin: 10px; + max-width: 25%; + min-width: 180px; + text-align: center; +} + +.ScoreCard--CircularProgressbarWrapper { + padding: 3px; +} + +.ScoreCard--TitleHeader { + border-bottom: solid 1px #ddd; + box-shadow: 0px 5px 5px -5px rgba(0, 0, 0, 0.3); + font-size: 1.8em; +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.tsx b/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.tsx new file mode 100644 index 0000000000..bc1be005df --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/ScoreCard.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 CircularProgressbar from '../common/CircularProgressbar'; +import NewWindowIcon from '../common/NewWindowIcon'; + +import { TQualityMetrics } from './types'; + +import './ScoreCard.css'; + +export type TProps = { + link: string; + score: TQualityMetrics['scores'][0]; +}; + +export default class ScoreCard extends React.PureComponent { + render() { + const { + link, + score: { label, max: maxValue, value }, + } = this.props; + const linkText = value < maxValue ? 'How to improve ' : 'Great! What does this mean '; + return ( +
+ {label} +
+ +
+ + {linkText} + + +
+ ); + } +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.css b/packages/jaeger-ui/src/components/QualityMetrics/index.css new file mode 100644 index 0000000000..c77383e12f --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.css @@ -0,0 +1,38 @@ +/* +Copyright (c) 2020 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. +*/ + +.QualityMetrics { + display: flex; + flex-direction: column; + height: calc(100vh - var(--nav-height)); +} + +.QualityMetrics--Body { + overflow: scroll; +} + +.QualityMetrics--Error { + font-size: 1.7em; + margin: 0 auto; + max-width: 80%; + padding: 1em; +} + +.QualityMetrics--ScoreCards { + display: flex; + flex-wrap: wrap; + justify-content: space-around; +} diff --git a/packages/jaeger-ui/src/components/QualityMetrics/index.tsx b/packages/jaeger-ui/src/components/QualityMetrics/index.tsx new file mode 100644 index 0000000000..bb5766d556 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/index.tsx @@ -0,0 +1,209 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { History as RouterHistory, Location } from 'history'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import * as jaegerApiActions from '../../actions/jaeger-api'; +import JaegerAPI from '../../api/jaeger'; +import LoadingIndicator from '../common/LoadingIndicator'; +import DetailsCard from '../DeepDependencies/SidePanel/DetailsCard'; +import BannerText from './BannerText'; +import ExamplesLink from './ExamplesLink'; +import Header from './Header'; +import MetricCard from './MetricCard'; +import ScoreCard from './ScoreCard'; +import { getUrl, getUrlState } from './url'; + +import { ReduxState } from '../../types'; +import { TQualityMetrics } from './types'; + +import './index.css'; + +type TOwnProps = { + history: RouterHistory; + location: Location; +}; + +type TDispatchProps = { + fetchServices: () => void; +}; + +type TReduxProps = { + lookback: number; + service?: string; + services?: string[] | null; +}; + +export type TProps = TDispatchProps & TReduxProps & TOwnProps; + +type TState = { + qualityMetrics?: TQualityMetrics; + error?: Error; + loading?: boolean; +}; + +export class UnconnectedQualityMetrics extends React.PureComponent { + state: TState = {}; + + constructor(props: TProps) { + super(props); + + const { fetchServices, services } = props; + if (!services) { + fetchServices(); + } + } + + componentDidMount() { + this.fetchQualityMetrics(); + } + + componentDidUpdate(prevProps: TProps) { + if (prevProps.service !== this.props.service || prevProps.lookback !== this.props.lookback) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + qualityMetrics: undefined, + error: undefined, + loading: false, + }); + this.fetchQualityMetrics(); + } + } + + fetchQualityMetrics() { + const { lookback, service } = this.props; + if (!service) return; + + this.setState({ loading: true }); + + JaegerAPI.fetchQualityMetrics(service, lookback ? `${lookback}h` : undefined) + .then((qualityMetrics: TQualityMetrics) => { + this.setState({ qualityMetrics, loading: false }); + }) + .catch((error: Error) => { + this.setState({ + error, + loading: false, + }); + }); + } + + setLookback = (lookback: number | string | undefined) => { + if (!lookback || typeof lookback === 'string') return; + if (lookback < 1 || lookback !== Math.floor(lookback)) return; + + const { history, service = '' } = this.props; + history.push(getUrl({ lookback, service })); + }; + + setService = (service: string) => { + const { history, lookback } = this.props; + history.push(getUrl({ lookback, service })); + }; + + render() { + const { lookback, service, services } = this.props; + const { qualityMetrics, error, loading } = this.state; + return ( +
+
+ {qualityMetrics && ( + <> + +
+
+ {qualityMetrics.scores.map(score => ( + + ))} +
+
+ {qualityMetrics.metrics.map(metric => ( + + ))} +
+ ({ + ...clientRow, + examples: { + value: ( + + ), + }, + }))} + header="Client Versions" + /> +
+ + )} + {loading && } + {error &&
{error.message}
} +
+ ); + } +} + +export function mapStateToProps(state: ReduxState, ownProps: TOwnProps): TReduxProps { + const { services: stServices } = state; + const { services } = stServices; + return { + ...getUrlState(ownProps.location.search), + services, + }; +} + +export function mapDispatchToProps(dispatch: Dispatch): TDispatchProps { + const { fetchServices } = bindActionCreators(jaegerApiActions, dispatch); + + return { + fetchServices, + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(UnconnectedQualityMetrics); diff --git a/packages/jaeger-ui/src/components/QualityMetrics/types.tsx b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx new file mode 100644 index 0000000000..37efcacea7 --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/types.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 { TPadColumnDef, TPadRow } from '../../model/path-agnostic-decorations/types'; + +export type TExample = { + spanIDs?: string[]; + traceID: string; +}; + +export type TQualityMetrics = { + traceQualityDocumentationLink: string; + bannerText?: + | string + | { + value: string; + styling: React.CSSProperties; + }; + scores: { + key: string; + label: string; + max: number; + value: number; + }[]; + metrics: { + name: string; + category: string; // matching some scores.key; + description: string; + metricDocumentationLink: string; + metricWeight: number; + passCount: number; + passExamples?: TExample[]; + failureCount: number; + failureExamples?: TExample[]; + exemptionCount?: number; + exemptionExamples?: TExample[]; + details?: { + description?: string; + header?: string; + columns: TPadColumnDef[]; + rows: TPadRow[]; + }[]; + }[]; + clients: { + version: string; + minVersion: string; + count: number; + examples: TExample[]; + }[]; +}; diff --git a/packages/jaeger-ui/src/components/QualityMetrics/url.tsx b/packages/jaeger-ui/src/components/QualityMetrics/url.tsx new file mode 100644 index 0000000000..71031db32c --- /dev/null +++ b/packages/jaeger-ui/src/components/QualityMetrics/url.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 memoizeOne from 'memoize-one'; +import queryString from 'query-string'; +import { matchPath } from 'react-router-dom'; + +import prefixUrl from '../../utils/prefix-url'; + +export const ROUTE_PATH = prefixUrl('/quality-metrics'); + +const ROUTE_MATCHER = { path: ROUTE_PATH, strict: true, exact: true }; + +export function matches(path: string) { + return Boolean(matchPath(path, ROUTE_MATCHER)); +} + +export function getUrl(queryParams?: Record) { + if (!queryParams) return ROUTE_PATH; + + return `${ROUTE_PATH}?${queryString.stringify(queryParams)}`; +} + +type TReturnValue = { + lookback: number; + service?: string; +}; + +export const getUrlState = memoizeOne(function getUrlState(search: string): TReturnValue { + const { lookback: lookbackFromUrl, service: serviceFromUrl } = queryString.parse(search); + const service = Array.isArray(serviceFromUrl) ? serviceFromUrl[0] : serviceFromUrl; + const lookbackStr = Array.isArray(lookbackFromUrl) ? lookbackFromUrl[0] : lookbackFromUrl; + const lookback = lookbackStr && Number.parseInt(lookbackStr, 10); + const rv: TReturnValue = { + lookback: 1, + }; + if (service) rv.service = service; + if (lookback) rv.lookback = lookback; + return rv; +}); diff --git a/packages/jaeger-ui/src/components/SearchTracePage/url.tsx b/packages/jaeger-ui/src/components/SearchTracePage/url.tsx index f1567d2b48..756bf0396d 100644 --- a/packages/jaeger-ui/src/components/SearchTracePage/url.tsx +++ b/packages/jaeger-ui/src/components/SearchTracePage/url.tsx @@ -17,6 +17,7 @@ import queryString from 'query-string'; import { matchPath } from 'react-router-dom'; import prefixUrl from '../../utils/prefix-url'; +import { MAX_LENGTH } from '../DeepDependencies/Graph/DdgNodeContent/constants'; import { SearchQuery } from '../../types/search'; @@ -55,7 +56,14 @@ export function getUrl(query?: TUrlState) { }, []), traceID: ids && ids.length ? ids : undefined, }; - return `${searchUrl}?${queryString.stringify(stringifyArg)}`; + + const fullUrl = `${searchUrl}?${queryString.stringify(stringifyArg)}`; + if (fullUrl.length <= MAX_LENGTH) return fullUrl; + + const truncated = fullUrl.slice(0, MAX_LENGTH + 1); + if (truncated[MAX_LENGTH] === '&') return truncated.slice(0, -1); + + return truncated.slice(0, truncated.lastIndexOf('&')); } export const getUrlState: (search: string) => TUrlState = memoizeOne(function getUrlState( diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css index 8af3140f11..6e00ed0cc7 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.css @@ -21,6 +21,10 @@ limitations under the License. top: 0; } +.TimelineColumnResizer.is-flipped { + transform: scaleX(-1); +} + .TimelineColumnResizer--wrapper { bottom: 0; position: absolute; @@ -30,7 +34,7 @@ limitations under the License. .TimelineColumnResizer--dragger { border-left: 2px solid transparent; cursor: col-resize; - height: 5000px; + height: calc(100vh - var(--nav-height)); margin-left: -1px; position: absolute; top: 0; diff --git a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx index 445361bfa9..246e3a8600 100644 --- a/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx +++ b/packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx @@ -21,10 +21,11 @@ import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../../../u import './TimelineColumnResizer.css'; type TimelineColumnResizerProps = { - min: number; max: number; + min: number; onChange: (newSize: number) => void; position: number; + rightSide?: boolean; }; type TimelineColumnResizerState = { @@ -67,7 +68,9 @@ export default class TimelineColumnResizer extends React.PureComponent< throw new Error('invalid state'); } const { left: clientXLeft, width } = this._rootElm.getBoundingClientRect(); - const { min, max } = this.props; + const { rightSide } = this.props; + let { min, max } = this.props; + if (rightSide) [min, max] = [1 - max, 1 - min]; return { clientXLeft, width, @@ -77,20 +80,22 @@ export default class TimelineColumnResizer extends React.PureComponent< }; _handleDragUpdate = ({ value }: DraggingUpdate) => { - this.setState({ dragPosition: value }); + const dragPosition = this.props.rightSide ? 1 - value : value; + this.setState({ dragPosition }); }; _handleDragEnd = ({ manager, value }: DraggingUpdate) => { manager.resetBounds(); this.setState({ dragPosition: null }); - this.props.onChange(value); + const dragPosition = this.props.rightSide ? 1 - value : value; + this.props.onChange(dragPosition); }; render() { let left; let draggerStyle; let isDraggingCls = ''; - const { position } = this.props; + const { position, rightSide } = this.props; const { dragPosition } = this.state; left = `${position * 100}%`; const gripStyle = { left }; @@ -113,7 +118,10 @@ export default class TimelineColumnResizer extends React.PureComponent< draggerStyle = gripStyle; } return ( -
+
{ + render() { + const { backgroundHue, decorationHue = 0, maxValue, strokeWidth, text, value } = this.props; + const scale = (value / maxValue) ** (1 / 4); + const saturation = 20 + Math.ceil(scale * 80); + const light = 50 + Math.ceil((1 - scale) * 30); + const decorationColor = `hsl(${decorationHue}, ${saturation}%, ${light}%)`; + const backgroundScale = ((maxValue - value) / maxValue) ** (1 / 4); + const backgroundSaturation = 20 + Math.ceil(backgroundScale * 80); + const backgroundLight = 50 + Math.ceil((1 - backgroundScale) * 30); + const decorationBackgroundColor = `hsl(${backgroundHue}, ${backgroundSaturation}%, ${backgroundLight}%)`; + + return ( + + ); + } +} diff --git a/packages/jaeger-ui/src/components/common/utils.css b/packages/jaeger-ui/src/components/common/utils.css index 0cf06bc0cc..d56333a1b6 100644 --- a/packages/jaeger-ui/src/components/common/utils.css +++ b/packages/jaeger-ui/src/components/common/utils.css @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +:root { + --nav-height: 46px; +} + .u-width-100 { width: 100%; } diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/index.tsx b/packages/jaeger-ui/src/model/path-agnostic-decorations/index.tsx new file mode 100644 index 0000000000..b9e971cc49 --- /dev/null +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/index.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// 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 _get from 'lodash/get'; +import queryString from 'query-string'; + +import CircularProgressbar from '../../components/common/CircularProgressbar'; +import { + PROGRESS_BAR_STROKE_WIDTH, + RADIUS, +} from '../../components/DeepDependencies/Graph/DdgNodeContent/constants'; +import { ReduxState } from '../../types/index'; + +export type TDecorationFromState = { + decorationID?: string; + decorationProgressbar?: React.ReactNode; + decorationValue?: string | number; +}; + +export default function extractDecorationFromState( + state: ReduxState, + { service, operation }: { service: string; operation?: string | string[] | null } +): TDecorationFromState { + const { decoration } = queryString.parse(state.router.location.search); + const decorationID = Array.isArray(decoration) ? decoration[0] : decoration; + + if (!decorationID) return {}; + + let decorationValue = _get(state, `pathAgnosticDecorations.${decorationID}.withOp.${service}.${operation}`); + let decorationMax = _get(state, `pathAgnosticDecorations.${decorationID}.withOpMax`); + if (!decorationValue) { + decorationValue = _get(state, `pathAgnosticDecorations.${decorationID}.withoutOp.${service}`); + decorationMax = _get(state, `pathAgnosticDecorations.${decorationID}.withoutOpMax`); + } + + const decorationProgressbar = + typeof decorationValue === 'number' ? ( + + ) : ( + undefined + ); + + return { + decorationProgressbar, + decorationID, + decorationValue, + }; +} diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx index 0993f44018..f691ea6b81 100644 --- a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx @@ -12,29 +12,46 @@ // See the License for the specific language governing permissions and // limitations under the License. +import React from 'react'; + export type TPathAgnosticDecorationSchema = { + acronym: string; id: string; - label: string; - icon?: string; - url: string; - opUrl?: string; - valuePath: string; - opValuePath?: string; - /* - gradient: { - startPercentage: number; - inclusiveStart: boolean; - endPercentage: number; - inclusiveEnd: boolean; - }[]; - */ + name: string; + summaryUrl: string; + opSummaryUrl?: string; + summaryPath: string; + opSummaryPath?: string; + detailLink?: string; + detailUrl?: string; + detailPath?: string; + detailColumnDefPath?: string; + opDetailUrl?: string; + opDetailPath?: string; + opDetailColumnDefPath?: string; +}; + +export type TStyledValue = { + linkTo?: string; + styling?: React.CSSProperties; + value: string | React.ReactElement; }; -export type TPadEntry = { - value: number | string; // string or other type is for data unavailable - // renderData: unknown; +export type TPadColumnDef = { + key: string; + label?: string; + preventSort?: boolean; + styling?: React.CSSProperties; }; +export type TPadColumnDefs = (string | TPadColumnDef)[]; + +export type TPadRow = Record; + +export type TPadDetails = string | string[] | TPadRow[]; + +export type TPadEntry = number | string; + export type TNewData = Record< string, { diff --git a/packages/jaeger-ui/src/reducers/index.js b/packages/jaeger-ui/src/reducers/index.js index e37f6e41a7..7b23c2ab32 100644 --- a/packages/jaeger-ui/src/reducers/index.js +++ b/packages/jaeger-ui/src/reducers/index.js @@ -17,6 +17,7 @@ import { reducer as formReducer } from 'redux-form'; import config from './config'; import dependencies from './dependencies'; import ddg from './ddg'; +import pathAgnosticDecorations from './path-agnostic-decorations'; import embedded from './embedded'; import services from './services'; import trace from './trace'; @@ -26,6 +27,7 @@ export default { dependencies, ddg, embedded, + pathAgnosticDecorations, services, trace, form: formReducer, diff --git a/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js index f870fd4fa1..dc0a6e075c 100644 --- a/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js +++ b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js @@ -36,9 +36,6 @@ describe('pathAgnosticDecoration reducers', () => { const service = svc[_service]; const isWithOp = Boolean(operation); - const valueObj = { - value, - }; const payloadKey = isWithOp ? 'withOp' : 'withoutOp'; const payload = { @@ -46,9 +43,9 @@ describe('pathAgnosticDecoration reducers', () => { [payloadKey]: { [service]: isWithOp ? { - [operation]: valueObj, + [operation]: value, } - : valueObj, + : value, }, }, }; diff --git a/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx index 12f4e6cf52..4c0f2d8cda 100644 --- a/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx +++ b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx @@ -26,7 +26,7 @@ export function getDecorationDone(state: TPathAgnosticDecorationsState, payload? const newWithoutOpValues: number[] = []; if (withoutOp) { Object.keys(withoutOp).forEach(service => { - const { value } = withoutOp[service]; + const value = withoutOp[service]; if (typeof value === 'number') newWithoutOpValues.push(value); }); } @@ -35,7 +35,7 @@ export function getDecorationDone(state: TPathAgnosticDecorationsState, payload? if (withOp) { Object.keys(withOp).forEach(service => { Object.keys(withOp[service]).forEach(operation => { - const { value } = withOp[service][operation]; + const value = withOp[service][operation]; if (typeof value === 'number') newWithOpValues.push(value); }); }); @@ -83,9 +83,10 @@ export function getDecorationDone(state: TPathAgnosticDecorationsState, payload? export default handleActions( { - [actionTypes.GET_DECORATION]: guardReducer( - getDecorationDone - ), + [`${actionTypes.GET_DECORATION}_FULFILLED`]: guardReducer< + TPathAgnosticDecorationsState, + TNewData | undefined + >(getDecorationDone), }, {} ); diff --git a/packages/jaeger-ui/src/setupProxy.js b/packages/jaeger-ui/src/setupProxy.js index 34bb0a779b..8a3ce75c4f 100644 --- a/packages/jaeger-ui/src/setupProxy.js +++ b/packages/jaeger-ui/src/setupProxy.js @@ -36,4 +36,24 @@ module.exports = function setupProxy(app) { xfwd: true, }) ); + app.use( + proxy('/serviceedges', { + target: 'http://localhost:16686', + logLevel: 'silent', + secure: false, + changeOrigin: true, + ws: true, + xfwd: true, + }) + ); + app.use( + proxy('/qualitymetrics-v2', { + target: 'http://localhost:16686', + logLevel: 'silent', + secure: false, + changeOrigin: true, + ws: true, + xfwd: true, + }) + ); }; diff --git a/packages/jaeger-ui/src/types/config.tsx b/packages/jaeger-ui/src/types/config.tsx index abd6b5e57c..a34a461f46 100644 --- a/packages/jaeger-ui/src/types/config.tsx +++ b/packages/jaeger-ui/src/types/config.tsx @@ -46,6 +46,9 @@ export type Config = { dependencies?: { dagMaxServicesLen?: number; menuEnabled?: boolean }; menu: (ConfigMenuGroup | ConfigMenuItem)[]; pathAgnosticDecorations?: TPathAgnosticDecorationSchema[]; + qualityMetrics?: { + menuEnabled?: boolean; + }; search?: { maxLookback: { label: string; value: string }; maxLimit: number }; scripts?: TScript[]; topTagPrefixes?: string[]; diff --git a/yarn.lock b/yarn.lock index 8a8eee15c7..08a4750c2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5585,7 +5585,7 @@ fsevents@^1.2.3, fsevents@^1.2.7: fstream@1.0.12, fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + integrity sha1-Touo7i1Ivk99DeUFRVVI6uWTIEU= dependencies: graceful-fs "^4.1.2" inherits "~2.0.0" @@ -5909,7 +5909,7 @@ handle-thing@^2.0.0: handlebars@4.1.2, handlebars@^4.0.3, handlebars@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.1.2.tgz#b6b37c1ced0306b221e094fc7aca3ec23b131b67" - integrity sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw== + integrity sha1-trN8HO0DBrIh4JT8eso+wjsTG2c= dependencies: neo-async "^2.6.0" optimist "^0.6.1" @@ -7248,7 +7248,7 @@ js-tokens@^3.0.2: js-yaml@3.13.1, js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + integrity sha1-r/FRswv9+o5J4F2iLnQV6d+jeEc= dependencies: argparse "^1.0.7" esprima "^4.0.0" @@ -10448,6 +10448,11 @@ react-app-rewired@2.0.1: dotenv "^6.2.0" semver "^5.6.0" +react-circular-progressbar@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.0.3.tgz#fa8eb59f8db168d2904bae4590641792c80f5991" + integrity sha1-+o61n42xaNKQS65FkGQXksgPWZE= + react-dev-utils@^7.0.0: version "7.0.5" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-7.0.5.tgz#cb95375d01ae71ca27b3c7616006ef7a77d14e8e" @@ -12144,7 +12149,7 @@ tapable@^1.0.0, tapable@^1.1.0: tar@2.2.2, tar@^2.0.0: version "2.2.2" resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" - integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== + integrity sha1-DKiEhWLHKZuLRG/2pNYM27I+3EA= dependencies: block-stream "*" fstream "^1.0.12"