diff --git a/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js b/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js new file mode 100644 index 0000000000..60ca164011 --- /dev/null +++ b/packages/jaeger-ui/src/actions/path-agnostic-decorations.test.js @@ -0,0 +1,288 @@ +// 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 _set from 'lodash/set'; + +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'; + +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 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'; + const service = 'svc'; + const operation = 'op'; + const testVal = 42; + + // wrapper is necessary to prevent cross pollution between tests + let couldBePending = []; + const getDecoration = (...args) => { + const promise = getDecorationImpl(...args); + if (promise) couldBePending.push(promise); + return promise; + }; + + beforeAll(() => { + getConfigValueSpy = jest.spyOn(getConfig, 'getConfigValue').mockReturnValue([ + { + id: withOpID, + url, + opUrl, + valuePath, + opValuePath, + }, + { + id: partialID, + url, + opUrl, + valuePath, + }, + { + id: withoutOpID, + url, + valuePath, + }, + ]); + fetchDecorationSpy = jest.spyOn(JaegerAPI, 'fetchDecoration').mockImplementation( + () => + new Promise((res, rej) => { + resolves.push(res); + rejects.push(rej); + }) + ); + }); + + beforeEach(() => { + fetchDecorationSpy.mockClear(); + processed.clear(); + resolves = []; + rejects = []; + }); + + afterEach(async () => { + resolves.forEach(resolve => resolve()); + await Promise.all(couldBePending); + couldBePending = []; + }); + + it('returns undefined if no schemas exist in config', () => { + getConfigValueSpy.mockReturnValueOnce(); + expect(getDecoration('foo', service, operation)).toBeUndefined(); + }); + + it('returns undefined if schema is not found for id', () => { + expect(getDecoration('missing id', service, operation)).toBeUndefined(); + }); + + it('returns a promise for its first call', () => { + expect(getDecoration(withOpID, service, operation)).toEqual(expect.any(Promise)); + }); + + 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)); + const res = await promise; + expect(res).toEqual(_set({}, `${withOpID}.withOp.${service}.${operation}`, testVal)); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(opUrl, { 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)); + const res = await promise; + expect(res).toEqual(_set({}, `${withOpID}.withoutOp.${service}`, testVal)); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { 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)); + const res = await promise; + expect(res).toEqual(_set({}, `${partialID}.withoutOp.${service}`, testVal)); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { 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)); + const res = await promise; + expect(res).toEqual(_set({}, `${withoutOpID}.withoutOp.${service}`, testVal)); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { 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)); + const res = await promise; + expect(res).toEqual(_set({}, `${withoutOpID}.withoutOp.${service}`, testVal)); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + }); + + it('handles error responses', async () => { + const message = 'foo error message'; + const promise0 = getDecoration(withoutOpID, service); + rejects[0]({ message }); + const res0 = await promise0; + expect(res0).toEqual( + _set({}, `${withoutOpID}.withoutOp.${service}`, `Unable to fetch decoration: ${message}`) + ); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + + const err = 'foo error without message'; + const promise1 = getDecoration(withOpID, service, operation); + rejects[1](err); + const res1 = await promise1; + expect(res1).toEqual( + _set({}, `${withOpID}.withOp.${service}.${operation}`, `Unable to fetch decoration: ${err}`) + ); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(opUrl, { service, operation })); + }); + + it('defaults value if valuePath not found in response', async () => { + const promise = getDecoration(withoutOpID, service); + resolves[0](); + const res = await promise; + expect(res).toEqual( + _set( + {}, + `${withoutOpID}.withoutOp.${service}`, + `${stringSupplant(valuePath, { service })} not found in response` + ) + ); + expect(fetchDecorationSpy).toHaveBeenLastCalledWith(stringSupplant(url, { service })); + }); + + it('returns undefined if invoked before previous invocation is resolved', () => { + getDecoration(withOpID, service, operation); + expect(getDecoration(withoutOpID, service)).toBeUndefined(); + }); + + 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)); + getDecoration(partialID, service, operation); + resolves[1](_set({}, stringSupplant(valuePath, { service }), testVal)); + getDecoration(withOpID, service); + resolves[2](_set({}, stringSupplant(valuePath, { service }), testVal)); + getDecoration(withoutOpID, service); + resolves[3](_set({}, stringSupplant(valuePath, { service }), testVal)); + const message = 'foo error message'; + getDecoration(withOpID, service, otherOp); + rejects[4]({ message }); + const res = await promise; + + expect(res).toEqual({ + [withOpID]: { + withOp: { + [service]: { + [operation]: testVal, + [otherOp]: `Unable to fetch decoration: ${message}`, + }, + }, + withoutOp: { + [service]: testVal, + }, + }, + [partialID]: { + withoutOp: { + [service]: testVal, + }, + }, + [withoutOpID]: { + withoutOp: { + [service]: testVal, + }, + }, + }); + }); + + 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)); + getDecoration(partialID, service, operation); + resolves[1](_set({}, stringSupplant(valuePath, { service }), testVal)); + const res0 = await promise0; + + const promise1 = getDecoration(withOpID, service); + resolves[2](_set({}, stringSupplant(valuePath, { service }), testVal)); + getDecoration(withoutOpID, service); + resolves[3](_set({}, stringSupplant(valuePath, { service }), testVal)); + const message = 'foo error message'; + getDecoration(withOpID, service, otherOp); + rejects[4]({ message }); + const res1 = await promise1; + + expect(res0).toEqual({ + [withOpID]: { + withOp: { + [service]: { + [operation]: testVal, + }, + }, + }, + [partialID]: { + withoutOp: { + [service]: testVal, + }, + }, + }); + + expect(res1).toEqual({ + [withOpID]: { + withOp: { + [service]: { + [otherOp]: `Unable to fetch decoration: ${message}`, + }, + }, + withoutOp: { + [service]: testVal, + }, + }, + [withoutOpID]: { + withoutOp: { + [service]: testVal, + }, + }, + }); + }); + + 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)); + const res0 = await promise0; + expect(res0).toEqual(_set({}, `${withOpID}.withOp.${service}.${operation}`, testVal)); + + const promise1 = getDecoration(withOpID, service, operation); + expect(promise1).toBeUndefined(); + + const promise2 = getDecoration(withoutOpID, service); + resolves[1](_set({}, stringSupplant(valuePath, { 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 new file mode 100644 index 0000000000..5b09d616d1 --- /dev/null +++ b/packages/jaeger-ui/src/actions/path-agnostic-decorations.tsx @@ -0,0 +1,118 @@ +// 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 _get from 'lodash/get'; +import _memoize from 'lodash/memoize'; +import _set from 'lodash/set'; +import { createActions, ActionFunctionAny, Action } from 'redux-actions'; + +import JaegerAPI from '../api/jaeger'; +import { TNewData, TPathAgnosticDecorationSchema } from '../model/path-agnostic-decorations/types'; +import { getConfigValue } from '../utils/config/get-config'; +import generateActionTypes from '../utils/generate-action-types'; +import stringSupplant from '../utils/stringSupplant'; + +export const actionTypes = generateActionTypes('@jaeger-ui/PATH_AGNOSTIC_DECORATIONS', ['GET_DECORATION']); + +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); +}); + +let doneCount: undefined | number; +let pendingCount: undefined | number; +let pendingData: undefined | TNewData; +let pendingPromise: undefined | Promise; +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>>(); + +export function getDecoration( + id: string, + service: string, + operation?: string +): Promise | undefined { + const processedID = processed.get(id); + if (!processedID) { + processed.set(id, new Map>([[service, new Set([operation])]])); + } else { + const processedService = processedID.get(service); + if (!processedService) processedID.set(service, new Set([operation])); + else if (!processedService.has(operation)) processedService.add(operation); + else return undefined; + } + + const schema = getDecorationSchema(id); + if (!schema) return undefined; + + const returnPromise = !resolve || !pendingPromise; + if (returnPromise) { + pendingPromise = new Promise(res => { + resolve = res; + }); + } + + pendingCount = pendingCount ? pendingCount + 1 : 1; + const { url, opUrl, valuePath, opValuePath } = 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 }); + setPath = `${id}.withOp.${service}.${operation}`; + } else { + promise = JaegerAPI.fetchDecoration(stringSupplant(url, { service })); + getPath = stringSupplant(valuePath, { service }); + getPath = valuePath; + setPath = `${id}.withoutOp.${service}`; + } + + promise + .then(res => { + return _get(res, getPath, `${getPath} not found in response`); + }) + .catch(err => { + return `Unable to fetch decoration: ${err.message || err}`; + }) + .then(value => { + if (!pendingData) pendingData = {}; + _set(pendingData, setPath, value); + doneCount = doneCount ? doneCount + 1 : 1; + + if (doneCount === pendingCount) { + if (resolve) resolve(pendingData); + // istanbul ignore next : Unreachable error to appease TS, resolve made to exist at top at function + else throw new Error('`resolve` unexpectedly undefined'); + + // eslint-disable-next-line no-multi-assign + doneCount = pendingCount = pendingData = pendingPromise = resolve = undefined; + } + }); + + if (returnPromise) return pendingPromise; + return undefined; +} + +const fullActions = createActions | undefined>({ + [actionTypes.GET_DECORATION]: getDecoration, +}); + +export default (fullActions as any).jaegerUi.pathAgnosticDecorations as Record< + string, + ActionFunctionAny | undefined>> +>; diff --git a/packages/jaeger-ui/src/api/jaeger.js b/packages/jaeger-ui/src/api/jaeger.js index a1a115dd96..daaec18525 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' }); }, + fetchDecoration(url) { + return getJSON(url); + }, fetchDeepDependencyGraph(query) { return getJSON(`${ANALYTICS_ROOT}v1/dependencies`, { query }); }, diff --git a/packages/jaeger-ui/src/components/DeepDependencies/index.tsx b/packages/jaeger-ui/src/components/DeepDependencies/index.tsx index 19bf5f30be..380cf9dcb3 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/index.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/index.tsx @@ -157,6 +157,8 @@ export class DeepDependencyGraphPageImpl extends React.PureComponent { this.updateUrlState({ visEncoding }); }; + setDecoration = (decoration: string | undefined) => this.updateUrlState({ decoration }); + setDensity = (density: EDdgDensity) => this.updateUrlState({ density }); setDistance = (distance: number, direction: EDirection) => { diff --git a/packages/jaeger-ui/src/components/DeepDependencies/url.tsx b/packages/jaeger-ui/src/components/DeepDependencies/url.tsx index 69c25284df..60af0cf308 100644 --- a/packages/jaeger-ui/src/components/DeepDependencies/url.tsx +++ b/packages/jaeger-ui/src/components/DeepDependencies/url.tsx @@ -54,6 +54,7 @@ function firstParam(arg: string | string[]): string { export const getUrlState = memoizeOne(function getUrlState(search: string): TDdgSparseUrlState { const { density = EDdgDensity.PreventPathEntanglement, + decoration, end, hash, operation, @@ -65,6 +66,9 @@ export const getUrlState = memoizeOne(function getUrlState(search: string): TDdg const rv: TDdgSparseUrlState = { density: firstParam(density) as EDdgDensity, }; + if (decoration) { + rv.decoration = firstParam(decoration); + } if (end) { rv.end = Number.parseInt(firstParam(end), 10); } diff --git a/packages/jaeger-ui/src/model/ddg/types.tsx b/packages/jaeger-ui/src/model/ddg/types.tsx index 8ea7311ec0..abea39415f 100644 --- a/packages/jaeger-ui/src/model/ddg/types.tsx +++ b/packages/jaeger-ui/src/model/ddg/types.tsx @@ -101,6 +101,7 @@ export type TDdgVertex = TVertex<{ export type TDdgSparseUrlState = { density: EDdgDensity; + decoration?: string; end?: number; hash?: string; operation?: string; diff --git a/packages/jaeger-ui/src/model/link-patterns.tsx b/packages/jaeger-ui/src/model/link-patterns.tsx index 8ab8975ea5..60f76011cb 100644 --- a/packages/jaeger-ui/src/model/link-patterns.tsx +++ b/packages/jaeger-ui/src/model/link-patterns.tsx @@ -14,13 +14,13 @@ import _uniq from 'lodash/uniq'; import memoize from 'lru-memoize'; + import { getConfigValue } from '../utils/config/get-config'; +import { encodedStringSupplant, getParamNames } from '../utils/stringSupplant'; import { getParent } from './span'; import { TNil } from '../types'; import { Span, Link, KeyValuePair, Trace } from '../types/trace'; -const parameterRegExp = /#\{([^{}]*)\}/g; - type ProcessedTemplate = { parameters: string[]; template: (template: { [key: string]: any }) => string; @@ -38,22 +38,6 @@ type ProcessedLinkPattern = { type TLinksRV = { url: string; text: string }[]; -function getParamNames(str: string) { - const names = new Set(); - str.replace(parameterRegExp, (match, name) => { - names.add(name); - return match; - }); - return Array.from(names); -} - -function stringSupplant(str: string, encodeFn: (unencoded: any) => string, map: Record) { - return str.replace(parameterRegExp, (_, name) => { - const value = map[name]; - return value == null ? '' : encodeFn(value); - }); -} - export function processTemplate(template: any, encodeFn: (unencoded: any) => string): ProcessedTemplate { if (typeof template !== 'string') { /* @@ -68,7 +52,7 @@ export function processTemplate(template: any, encodeFn: (unencoded: any) => str } return { parameters: getParamNames(template), - template: stringSupplant.bind(null, template, encodeFn), + template: encodedStringSupplant.bind(null, template, encodeFn), }; } diff --git a/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx new file mode 100644 index 0000000000..0993f44018 --- /dev/null +++ b/packages/jaeger-ui/src/model/path-agnostic-decorations/types.tsx @@ -0,0 +1,44 @@ +// 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. + +export type TPathAgnosticDecorationSchema = { + id: string; + label: string; + icon?: string; + url: string; + opUrl?: string; + valuePath: string; + opValuePath?: string; + /* + gradient: { + startPercentage: number; + inclusiveStart: boolean; + endPercentage: number; + inclusiveEnd: boolean; + }[]; + */ +}; + +export type TPadEntry = { + value: number | string; // string or other type is for data unavailable + // renderData: unknown; +}; + +export type TNewData = Record< + string, + { + withoutOp?: Record; + withOp?: Record>; + } +>; diff --git a/packages/jaeger-ui/src/reducers/ddg.test.js b/packages/jaeger-ui/src/reducers/ddg.test.js index 04006c8e5f..f1c4b09141 100644 --- a/packages/jaeger-ui/src/reducers/ddg.test.js +++ b/packages/jaeger-ui/src/reducers/ddg.test.js @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 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. diff --git a/packages/jaeger-ui/src/reducers/ddg.tsx b/packages/jaeger-ui/src/reducers/ddg.tsx index 8e36924446..61ccd776d2 100644 --- a/packages/jaeger-ui/src/reducers/ddg.tsx +++ b/packages/jaeger-ui/src/reducers/ddg.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Uber Technologies, Inc. +// Copyright (c) 2019 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. diff --git a/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js new file mode 100644 index 0000000000..f870fd4fa1 --- /dev/null +++ b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.test.js @@ -0,0 +1,203 @@ +// Copyright (c) 2019 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 _merge from 'lodash/merge'; + +import { getDecorationDone } from './path-agnostic-decorations'; + +describe('pathAgnosticDecoration reducers', () => { + const decorationID = { + 0: 'decoration id 0', + 1: 'decoration id 1', + }; + const op = { + 0: 'op 0', + 1: 'op 1', + }; + const svc = { + 0: 'svc 0', + 1: 'svc 1', + }; + + function genStateAndPayload(_id, _service, _operation, value) { + const id = decorationID[_id]; + const operation = op[_operation]; + const service = svc[_service]; + + const isWithOp = Boolean(operation); + const valueObj = { + value, + }; + + const payloadKey = isWithOp ? 'withOp' : 'withoutOp'; + const payload = { + [id]: { + [payloadKey]: { + [service]: isWithOp + ? { + [operation]: valueObj, + } + : valueObj, + }, + }, + }; + + const stateKey = isWithOp ? 'withOpMax' : 'withoutOpMax'; + const state = { + [id]: { + ...payload[id], + [stateKey]: typeof value === 'number' ? value : -Infinity, + }, + }; + return { id, operation, service, payload, state }; + } + + // variable names are type(payload|state)+decoration(0|1)+service(0|1)+operation(0|1|u)+value(0|1|s) + // u for undefined, or opless + // s for string, or errored request + const { payload: payload00u0, state: state00u0 } = genStateAndPayload(0, 0, undefined, 0); + const { payload: payload00us, state: state00us } = genStateAndPayload(0, 0, undefined, 'Error: 404'); + const { state: state00u1 } = genStateAndPayload(0, 0, undefined, 1); + const { payload: payload01u1, state: state01u1 } = genStateAndPayload(0, 1, undefined, 1); + const { payload: payload10u1, state: state10u1 } = genStateAndPayload(1, 0, undefined, 1); + + const { payload: payload0000, state: state0000 } = genStateAndPayload(0, 0, 0, 0); + const { payload: payload000s, state: state000s } = genStateAndPayload(0, 0, 0, 'Error: 404'); + const { payload: payload0011, state: state0011 } = genStateAndPayload(0, 0, 1, 1); + const { payload: payload0101, state: state0101 } = genStateAndPayload(0, 1, 0, 1); + const { payload: payload1001, state: state1001 } = genStateAndPayload(1, 0, 0, 1); + + function mergeAndObjectContaining(...args) { + const merged = _merge({}, ...args); + const objectContaining = {}; + Object.keys(merged).forEach(key => { + objectContaining[key] = expect.objectContaining(merged[key]); + }); + return objectContaining; + } + + it('returns existing state if payload is undefined', () => { + const priorState = {}; + expect(getDecorationDone(priorState)).toBe(priorState); + }); + + describe('withoutOp', () => { + it('adds service decoration to empty state', () => { + expect(getDecorationDone({}, payload00u0)).toEqual(mergeAndObjectContaining(state00u0)); + }); + + it('adds service decoration error to empty state', () => { + expect(getDecorationDone({}, payload00us)).toEqual(mergeAndObjectContaining(state00us)); + }); + + it('adds service decoration to state with different decoration', () => { + expect(getDecorationDone(state00u0, payload10u1)).toEqual( + mergeAndObjectContaining(state00u0, state10u1) + ); + }); + + it('adds service decoration to state with existing decoration and updates withoutOpMax', () => { + expect(getDecorationDone(state00u0, payload01u1)).toEqual( + mergeAndObjectContaining(state00u0, state01u1) + ); + }); + + it('adds service decoration to state with existing decoration without overriding higher withoutOpMax', () => { + expect(getDecorationDone(state01u1, payload00u0)).toEqual( + mergeAndObjectContaining(state00u0, state01u1) + ); + }); + + it('adds service decoration error to state with existing decoration without overriding existing withoutOpMax', () => { + expect(getDecorationDone(state01u1, payload00us)).toEqual( + mergeAndObjectContaining(state00us, state01u1) + ); + }); + }); + + describe('withOp', () => { + it('adds operation decoration to empty state', () => { + expect(getDecorationDone({}, payload0000)).toEqual(mergeAndObjectContaining(state0000)); + }); + + it('adds operation decoration error to empty state', () => { + expect(getDecorationDone({}, payload000s)).toEqual(mergeAndObjectContaining(state000s)); + }); + + it('adds operation decoration to state with different decoration', () => { + expect(getDecorationDone(state0000, payload1001)).toEqual( + mergeAndObjectContaining(state0000, state1001) + ); + }); + + it('adds operation decoration to state with same decoration but different service and updates withOpMax', () => { + expect(getDecorationDone(state0000, payload0101)).toEqual( + mergeAndObjectContaining(state0000, state0101) + ); + }); + + it('adds operation decoration to state with same decoration but different service without overriding higher withoutOpMax', () => { + expect(getDecorationDone(state0101, payload0000)).toEqual( + mergeAndObjectContaining(state0000, state0101) + ); + }); + + it('adds operation decoration to state with existing decoration and same service and updates withoutOpMax', () => { + expect(getDecorationDone(state0000, payload0011)).toEqual( + mergeAndObjectContaining(state0000, state0011) + ); + }); + + it('adds operation decoration to state with existing decoration and same service without overriding higher withoutOpMax', () => { + expect(getDecorationDone(state0011, payload0000)).toEqual( + mergeAndObjectContaining(state0000, state0011) + ); + }); + + it('adds operation decoration error to state with existing decoration without overriding existing withoutOpMax', () => { + expect(getDecorationDone(state0011, payload000s)).toEqual( + mergeAndObjectContaining(state000s, state0011) + ); + }); + }); + + describe('mixed', () => { + it('adds service decoration to state with only operation decorations', () => { + expect(getDecorationDone(state0011, payload00u0)).toEqual( + mergeAndObjectContaining(state00u0, state0011) + ); + }); + + it('adds operation decoration to state with only service decorations', () => { + expect(getDecorationDone(state00u1, payload0000)).toEqual( + mergeAndObjectContaining(state0000, state00u1) + ); + }); + + it('adds multiple operation and service decortions to state with multiple operation and service decorations', () => { + const initialState = _merge({}, state00us, state00u0, state1001); + const payload = _merge({}, payload01u1, payload10u1, payload10u1, payload000s, payload0101); + const expectedState = mergeAndObjectContaining( + {}, + state000s, + initialState, + state01u1, + state10u1, + state0101 + ); + + expect(getDecorationDone(initialState, payload)).toEqual(expectedState); + }); + }); +}); diff --git a/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx new file mode 100644 index 0000000000..12f4e6cf52 --- /dev/null +++ b/packages/jaeger-ui/src/reducers/path-agnostic-decorations.tsx @@ -0,0 +1,91 @@ +// 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 { handleActions } from 'redux-actions'; + +import { actionTypes } from '../actions/path-agnostic-decorations'; +import { TNewData } from '../model/path-agnostic-decorations/types'; +import TPathAgnosticDecorationsState from '../types/TPathAgnosticDecorationsState'; +import guardReducer from '../utils/guardReducer'; + +export function getDecorationDone(state: TPathAgnosticDecorationsState, payload?: TNewData) { + if (!payload) return state; + return Object.keys(payload).reduce((newState, decorationID) => { + const { withOp, withoutOp } = payload[decorationID]; + const newWithoutOpValues: number[] = []; + if (withoutOp) { + Object.keys(withoutOp).forEach(service => { + const { value } = withoutOp[service]; + if (typeof value === 'number') newWithoutOpValues.push(value); + }); + } + + const newWithOpValues: number[] = []; + if (withOp) { + Object.keys(withOp).forEach(service => { + Object.keys(withOp[service]).forEach(operation => { + const { value } = withOp[service][operation]; + if (typeof value === 'number') newWithOpValues.push(value); + }); + }); + } + + if (newState[decorationID]) { + const { withOpMax: currWithOpMax, withoutOpMax: currWithoutOpMax } = newState[decorationID]; + if (typeof currWithOpMax === 'number') newWithOpValues.push(currWithOpMax); + if (typeof currWithoutOpMax === 'number') newWithoutOpValues.push(currWithoutOpMax); + const withOpMax = Math.max(...newWithOpValues); + const withoutOpMax = Math.max(...newWithoutOpValues); + return { + ...newState, + [decorationID]: { + withOp: withOp + ? Object.keys(withOp).reduce( + (newWithOp, service) => ({ + ...newWithOp, + [service]: Object.assign({}, newWithOp[service], withOp[service]), + }), + newState[decorationID].withOp || {} + ) + : newState[decorationID].withOp, + withOpMax, + withoutOp: withoutOp + ? Object.assign({}, newState[decorationID].withoutOp, withoutOp) + : newState[decorationID].withoutOp, + withoutOpMax, + }, + }; + } + const withOpMax = Math.max(...newWithOpValues); + const withoutOpMax = Math.max(...newWithoutOpValues); + return { + ...newState, + [decorationID]: { + withOp, + withOpMax, + withoutOp, + withoutOpMax, + }, + }; + }, state); +} + +export default handleActions( + { + [actionTypes.GET_DECORATION]: guardReducer( + getDecorationDone + ), + }, + {} +); diff --git a/packages/jaeger-ui/src/types/TPathAgnosticDecorationsState.tsx b/packages/jaeger-ui/src/types/TPathAgnosticDecorationsState.tsx new file mode 100644 index 0000000000..f9244f88de --- /dev/null +++ b/packages/jaeger-ui/src/types/TPathAgnosticDecorationsState.tsx @@ -0,0 +1,28 @@ +// 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 { TPadEntry } from '../model/path-agnostic-decorations/types'; + +type TPathAgnosticDecorations = Record< + string, + { + withOpMax?: number; + withoutOpMax?: number; + withoutOp?: Record; + withOp?: Record>; + } +>; + +// eslint-disable-next-line no-undef +export default TPathAgnosticDecorations; diff --git a/packages/jaeger-ui/src/types/config.tsx b/packages/jaeger-ui/src/types/config.tsx index d0fc4a3f5e..abd6b5e57c 100644 --- a/packages/jaeger-ui/src/types/config.tsx +++ b/packages/jaeger-ui/src/types/config.tsx @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { TPathAgnosticDecorationSchema } from '../model/path-agnostic-decorations/types'; import { TNil } from '.'; export type ConfigMenuItem = { @@ -39,9 +40,12 @@ export type LinkPatternsConfig = { export type Config = { archiveEnabled?: boolean; - deepDependencies?: { menuEnabled?: boolean }; + deepDependencies?: { + menuEnabled?: boolean; + }; dependencies?: { dagMaxServicesLen?: number; menuEnabled?: boolean }; menu: (ConfigMenuGroup | ConfigMenuItem)[]; + pathAgnosticDecorations?: TPathAgnosticDecorationSchema[]; search?: { maxLookback: { label: string; value: string }; maxLimit: number }; scripts?: TScript[]; topTagPrefixes?: string[]; diff --git a/packages/jaeger-ui/src/utils/stringSupplant.test.js b/packages/jaeger-ui/src/utils/stringSupplant.test.js new file mode 100644 index 0000000000..744dcb39df --- /dev/null +++ b/packages/jaeger-ui/src/utils/stringSupplant.test.js @@ -0,0 +1,56 @@ +// 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. + +import stringSupplant, { encodedStringSupplant, getParamNames } from './stringSupplant'; + +describe('stringSupplant', () => { + const value0 = 'val0'; + const value1 = 'val1'; + + it('replaces values', () => { + expect(stringSupplant('key0: #{value0}; key1: #{value1}', { value0, value1 })).toBe( + `key0: ${value0}; key1: ${value1}` + ); + }); + + it('omits missing values', () => { + expect(stringSupplant('key0: #{value0}; key1: #{value1}', { value0 })).toBe(`key0: ${value0}; key1: `); + }); + + describe('encodedStringSupplant', () => { + it('encodes present values', () => { + const reverse = str => + str + .split('') + .reverse() + .join(''); + const encodeFn = jest.fn(reverse); + expect(encodedStringSupplant('key0: #{value0}; key1: #{value1}', encodeFn, { value0, value1 })).toBe( + `key0: ${reverse(value0)}; key1: ${reverse(value1)}` + ); + + const callCount = encodeFn.mock.calls.length; + encodedStringSupplant('key0: #{value0}; key1: #{value1}', encodeFn, { value0 }); + expect(encodeFn.mock.calls.length).toBe(callCount + 1); + }); + }); +}); + +describe('getParamNames', () => { + it('gets unique names', () => { + const name0 = 'name 0'; + const name1 = 'name 1'; + expect(getParamNames(`foo #{${name0}} bar #{${name1}} baz #{${name0}}`)).toEqual([name0, name1]); + }); +}); diff --git a/packages/jaeger-ui/src/utils/stringSupplant.tsx b/packages/jaeger-ui/src/utils/stringSupplant.tsx new file mode 100644 index 0000000000..8b1ae7b23f --- /dev/null +++ b/packages/jaeger-ui/src/utils/stringSupplant.tsx @@ -0,0 +1,40 @@ +// 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. + +const PARAMETER_REG_EXP = /#\{([^{}]*)\}/g; + +export function encodedStringSupplant( + str: string, + encodeFn: null | ((unencoded: string | number) => string), + map: Record +) { + return str.replace(PARAMETER_REG_EXP, (_, name) => { + const mapValue = map[name]; + const value = mapValue != null && encodeFn ? encodeFn(mapValue) : mapValue; + return value == null ? '' : `${value}`; + }); +} + +export default function stringSupplant(str: string, map: Record) { + return encodedStringSupplant(str, null, map); +} + +export function getParamNames(str: string) { + const names = new Set(); + str.replace(PARAMETER_REG_EXP, (match, name) => { + names.add(name); + return match; + }); + return Array.from(names); +}