Explore: Refactor trace view and move to core (#61938)

* Move TraceView to core grafana

* Remove unused code

* yarn install

* Remove jaeger-ui-components from CODEOWNERS and other tools

* Type fixes

* yarn install

* Remove mock that we no longer need

* Fix merge conflicts

* Re-add Apache license for trace view components

* Use an exclamation-circle instead of triangle to denote errors

* Remove eslint disables and update betterer results instead
This commit is contained in:
Andre Pereira
2023-01-27 14:13:17 +00:00
committed by GitHub
parent 7c02d9bb8a
commit afd39c18ba
194 changed files with 1310 additions and 1378 deletions

View File

@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2015 Grafana Labs
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.

View File

@@ -1,58 +0,0 @@
{
"name": "@jaegertracing/jaeger-ui-components",
"version": "9.4.0-pre",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@grafana/tsconfig": "^1.2.0-rc1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "14.4.3",
"@types/deep-freeze": "^0.1.1",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/jest": "29.2.3",
"@types/lodash": "4.14.187",
"@types/prop-types": "15.7.5",
"@types/react": "17.0.42",
"@types/react-icons": "2.2.7",
"@types/sinon": "^10.0.13",
"@types/slate-react": "0.22.9",
"@types/testing-library__jest-dom": "5.14.5",
"@types/tinycolor2": "1.4.3",
"sinon": "14.0.1",
"typescript": "4.8.4"
},
"dependencies": {
"@emotion/css": "11.10.5",
"@grafana/data": "9.4.0-pre",
"@grafana/e2e-selectors": "9.4.0-pre",
"@grafana/runtime": "9.4.0-pre",
"@grafana/ui": "9.4.0-pre",
"chance": "^1.0.10",
"classnames": "^2.2.5",
"combokeys": "^3.0.0",
"copy-to-clipboard": "^3.1.0",
"deep-freeze": "^0.0.1",
"fuzzy": "^0.1.3",
"hoist-non-react-statics": "^3.3.2",
"json-markup": "^1.1.0",
"lodash": "4.17.21",
"lru-memoize": "^1.1.0",
"memoize-one": "6.0.0",
"moment": "2.29.4",
"moment-timezone": "0.5.38",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-icons": "2.2.7",
"reselect": "4.1.6",
"tinycolor2": "1.4.2",
"tslib": "2.4.1",
"tween-functions": "^1.2.0"
}
}

View File

@@ -1,285 +0,0 @@
// Copyright (c) 2017 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.
jest.mock('./scroll-page');
import traceGenerator from '../src/demo/trace-generators';
import ScrollManager, { Accessors } from './ScrollManager';
import { scrollBy, scrollTo } from './scroll-page';
import { Trace, TraceSpanData, TraceSpanReference } from './types/trace';
const SPAN_HEIGHT = 2;
function getTrace(): Trace {
const generatedTrace = traceGenerator.trace({ numberOfSpans: 10 });
generatedTrace.duration = 2000;
generatedTrace.startTime = 1000;
generatedTrace.spans.forEach((span: TraceSpanData, index: number) => {
span.duration = 1;
span.startTime = 1000;
span.spanID = (index + 1).toString();
});
return generatedTrace;
}
function getAccessors() {
return {
getViewRange: jest.fn(() => [0, 1] as [number, number]),
getSearchedSpanIDs: jest.fn(),
getCollapsedChildren: jest.fn(),
getViewHeight: jest.fn(() => SPAN_HEIGHT * 2),
getBottomRowIndexVisible: jest.fn(),
getTopRowIndexVisible: jest.fn(),
getRowPosition: jest.fn(),
mapRowIndexToSpanIndex: jest.fn((n) => n),
mapSpanIndexToRowIndex: jest.fn((n) => n),
};
}
describe('ScrollManager', () => {
let trace: Trace;
let accessors: Accessors;
let manager: ScrollManager;
beforeEach(() => {
jest.mocked(scrollBy).mockReset();
jest.mocked(scrollTo).mockReset();
trace = getTrace();
accessors = getAccessors();
manager = new ScrollManager(trace, { scrollBy, scrollTo });
manager.setAccessors(accessors);
});
it('saves the accessors', () => {
accessors = getAccessors();
manager.setAccessors(accessors);
expect(manager._accessors).toBe(accessors);
});
describe('_scrollPast()', () => {
it('throws if accessors is not set', () => {
expect(manager._scrollPast).toThrow();
});
it('is a noop if an invalid rowPosition is returned by the accessors', () => {
// eslint-disable-next-line no-console
const oldWarn = console.warn;
// eslint-disable-next-line no-console
console.warn = () => {};
manager._scrollPast(-2, 1);
expect(jest.mocked(accessors.getRowPosition).mock.calls.length).toBe(1);
expect(jest.mocked(accessors.getViewHeight).mock.calls.length).toBe(0);
expect(jest.mocked(scrollTo).mock.calls.length).toBe(0);
// eslint-disable-next-line no-console
console.warn = oldWarn;
});
it('scrolls up with direction is `-1`', () => {
const y = 10;
const expectTo = y - 0.5 * accessors.getViewHeight();
jest.mocked(accessors.getRowPosition).mockReturnValue({ y, height: SPAN_HEIGHT });
manager._scrollPast(NaN, -1);
expect(jest.mocked(scrollTo).mock.calls).toEqual([[expectTo]]);
});
it('scrolls down with direction `1`', () => {
const y = 10;
const vh = accessors.getViewHeight();
const expectTo = y + SPAN_HEIGHT - 0.5 * vh;
jest.mocked(accessors.getRowPosition).mockReturnValue({ y, height: SPAN_HEIGHT });
manager._scrollPast(NaN, 1);
expect(jest.mocked(scrollTo).mock.calls).toEqual([[expectTo]]);
});
});
describe('_scrollToVisibleSpan()', () => {
function getRefs(spanID: string | undefined) {
return [{ refType: 'CHILD_OF', spanID }] as TraceSpanReference[];
}
let scrollPastMock: jest.Mock;
beforeEach(() => {
scrollPastMock = jest.fn();
manager._scrollPast = scrollPastMock;
});
it('throws if accessors is not set', () => {
expect(manager._scrollToVisibleSpan).toThrow();
});
it('exits if the trace is not set', () => {
manager.setTrace(null);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock.mock.calls.length).toBe(0);
});
it('does nothing if already at the boundary', () => {
jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(0);
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(trace.spans.length - 1);
manager._scrollToVisibleSpan(-1);
expect(scrollPastMock.mock.calls.length).toBe(0);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock.mock.calls.length).toBe(0);
});
it('centers the current top or bottom span', () => {
jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(5);
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(5);
manager._scrollToVisibleSpan(-1);
expect(scrollPastMock).lastCalledWith(5, -1);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock).lastCalledWith(5, 1);
});
it('skips spans that are out of view', () => {
trace.spans[4].startTime = trace.startTime + trace.duration * 0.5;
accessors.getViewRange = () => [0.4, 0.6];
jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(trace.spans.length - 1);
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock).lastCalledWith(4, 1);
manager._scrollToVisibleSpan(-1);
expect(scrollPastMock).lastCalledWith(4, -1);
});
it('skips spans that do not match the text search', () => {
jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(trace.spans.length - 1);
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0);
accessors.getSearchedSpanIDs = () => new Set([trace.spans[4].spanID]);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock).lastCalledWith(4, 1);
manager._scrollToVisibleSpan(-1);
expect(scrollPastMock).lastCalledWith(4, -1);
});
it('scrolls to boundary when scrolling away from closest spanID in findMatches', () => {
const closetFindMatchesSpanID = 4;
jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(closetFindMatchesSpanID - 1);
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(closetFindMatchesSpanID + 1);
accessors.getSearchedSpanIDs = () => new Set([trace.spans[closetFindMatchesSpanID].spanID]);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock).lastCalledWith(trace.spans.length - 1, 1);
manager._scrollToVisibleSpan(-1);
expect(scrollPastMock).lastCalledWith(0, -1);
});
it('scrolls to last visible row when boundary is hidden', () => {
const parentOfLastRowWithHiddenChildrenIndex = trace.spans.length - 2;
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0);
accessors.getCollapsedChildren = () => new Set([trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID]);
accessors.getSearchedSpanIDs = () => new Set([trace.spans[0].spanID]);
trace.spans[trace.spans.length - 1].references = getRefs(
trace.spans[parentOfLastRowWithHiddenChildrenIndex].spanID
);
manager._scrollToVisibleSpan(1);
expect(scrollPastMock).lastCalledWith(parentOfLastRowWithHiddenChildrenIndex, 1);
});
describe('scrollToNextVisibleSpan() and scrollToPrevVisibleSpan()', () => {
beforeEach(() => {
// change spans so 0 and 4 are top-level and their children are collapsed
const spans = trace.spans;
let parentID;
for (let i = 0; i < spans.length; i++) {
switch (i) {
case 0:
case 4:
parentID = spans[i].spanID;
break;
default:
spans[i].references = getRefs(parentID);
}
}
// set which spans are "in-view" and which have collapsed children
jest.mocked(accessors.getTopRowIndexVisible).mockReturnValue(trace.spans.length - 1);
jest.mocked(accessors.getBottomRowIndexVisible).mockReturnValue(0);
jest.mocked(accessors.getCollapsedChildren).mockReturnValue(new Set([spans[0].spanID, spans[4].spanID]));
});
it('skips spans that are hidden because their parent is collapsed', () => {
manager.scrollToNextVisibleSpan();
expect(scrollPastMock).lastCalledWith(4, 1);
manager.scrollToPrevVisibleSpan();
expect(scrollPastMock).lastCalledWith(4, -1);
});
it('ignores references with unknown types', () => {
// modify spans[2] so that it has an unknown refType
const spans = trace.spans;
spans[2].references = [{ refType: 'OTHER' }] as unknown as TraceSpanReference[];
manager.scrollToNextVisibleSpan();
expect(scrollPastMock).lastCalledWith(2, 1);
manager.scrollToPrevVisibleSpan();
expect(scrollPastMock).lastCalledWith(4, -1);
});
it('handles more than one level of ancestry', () => {
// modify spans[2] so that it has an unknown refType
const spans = trace.spans;
spans[2].references = getRefs(spans[1].spanID);
manager.scrollToNextVisibleSpan();
expect(scrollPastMock).lastCalledWith(4, 1);
manager.scrollToPrevVisibleSpan();
expect(scrollPastMock).lastCalledWith(4, -1);
});
});
describe('scrollToFirstVisibleSpan', () => {
beforeEach(() => {
jest.spyOn(manager, '_scrollToVisibleSpan');
});
it('calls _scrollToVisibleSpan searching downwards from first span', () => {
manager.scrollToFirstVisibleSpan();
expect(manager._scrollToVisibleSpan).toHaveBeenCalledWith(1, 0);
});
});
});
describe('scrollPageDown() and scrollPageUp()', () => {
it('scrolls by +/~ viewHeight when invoked', () => {
manager.scrollPageDown();
expect(scrollBy).lastCalledWith(0.95 * accessors.getViewHeight(), true);
manager.scrollPageUp();
expect(scrollBy).lastCalledWith(-0.95 * accessors.getViewHeight(), true);
});
it('is a no-op if _accessors or _scroller is not defined', () => {
manager._accessors = null;
manager.scrollPageDown();
manager.scrollPageUp();
expect(jest.mocked(scrollBy).mock.calls.length).toBe(0);
manager._accessors = accessors;
manager._scroller = null;
manager.scrollPageDown();
manager.scrollPageUp();
expect(jest.mocked(scrollBy).mock.calls.length).toBe(0);
});
});
describe('destroy()', () => {
it('disposes', () => {
expect(manager._trace).toBeDefined();
expect(manager._accessors).toBeDefined();
expect(manager._scroller).toBeDefined();
manager.destroy();
expect(manager._trace).not.toBeDefined();
expect(manager._accessors).not.toBeDefined();
expect(manager._scroller).not.toBeDefined();
});
});
});

View File

@@ -1,274 +0,0 @@
// Copyright (c) 2017 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 { TNil } from './types';
import { TraceSpan, TraceSpanReference, Trace } from './types/trace';
/**
* `Accessors` is necessary because `ScrollManager` needs to be created by
* `TracePage` so it can be passed into the keyboard shortcut manager. But,
* `ScrollManager` needs to know about the state of `ListView` and `Positions`,
* which are very low-level. And, storing their state info in redux or
* `TracePage#state` would be inefficient because the state info only rarely
* needs to be accessed (when a keyboard shortcut is triggered). `Accessors`
* allows that state info to be accessed in a loosely coupled fashion on an
* as-needed basis.
*/
export type Accessors = {
getViewRange: () => [number, number];
getSearchedSpanIDs: () => Set<string> | TNil;
getCollapsedChildren: () => Set<string> | TNil;
getViewHeight: () => number;
getBottomRowIndexVisible: () => number;
getTopRowIndexVisible: () => number;
getRowPosition: (rowIndex: number) => { height: number; y: number };
mapRowIndexToSpanIndex: (rowIndex: number) => number;
mapSpanIndexToRowIndex: (spanIndex: number) => number;
};
interface Scroller {
scrollTo: (rowIndex: number) => void;
// TODO arg names throughout
scrollBy: (rowIndex: number, opt?: boolean) => void;
}
/**
* Returns `{ isHidden: true, ... }` if one of the parents of `span` is
* collapsed, e.g. has children hidden.
*
* @param {TraceSpan} span The Span to check for.
* @param {Set<string>} childrenAreHidden The set of Spans known to have hidden
* children, either because it is
* collapsed or has a collapsed parent.
* @param {Map<string, TraceSpan | TNil} spansMap Mapping from spanID to Span.
* @returns {{ isHidden: boolean, parentIds: Set<string> }}
*/
function isSpanHidden(span: TraceSpan, childrenAreHidden: Set<string>, spansMap: Map<string, TraceSpan | TNil>) {
const parentIDs = new Set<string>();
let { references }: { references: TraceSpanReference[] | TNil } = span;
let parentID: undefined | string;
const checkRef = (ref: TraceSpanReference) => {
if (ref.refType === 'CHILD_OF' || ref.refType === 'FOLLOWS_FROM') {
parentID = ref.spanID;
parentIDs.add(parentID);
return childrenAreHidden.has(parentID);
}
return false;
};
while (Array.isArray(references) && references.length) {
const isHidden = references.some(checkRef);
if (isHidden) {
return { isHidden, parentIDs };
}
if (!parentID) {
break;
}
const parent = spansMap.get(parentID);
parentID = undefined;
references = parent && parent.references;
}
return { parentIDs, isHidden: false };
}
/**
* ScrollManager is intended for scrolling the TracePage. Has two modes, paging
* and scrolling to the previous or next visible span.
*/
export default class ScrollManager {
_trace: Trace | TNil;
_scroller: Scroller | TNil;
_accessors: Accessors | TNil;
constructor(trace: Trace | TNil, scroller: Scroller) {
this._trace = trace;
this._scroller = scroller;
this._accessors = undefined;
}
_scrollPast(rowIndex: number, direction: 1 | -1) {
const xrs = this._accessors;
/* istanbul ignore next */
if (!xrs) {
throw new Error('Accessors not set');
}
const isUp = direction < 0;
const position = xrs.getRowPosition(rowIndex);
if (!position) {
// eslint-disable-next-line no-console
console.warn('Invalid row index');
return;
}
let { y } = position;
const vh = xrs.getViewHeight();
if (!isUp) {
y += position.height;
// scrollTop is based on the top of the window
y -= vh;
}
y += direction * 0.5 * vh;
this._scroller?.scrollTo(y);
}
_scrollToVisibleSpan(direction: 1 | -1, startRow?: number) {
const xrs = this._accessors;
/* istanbul ignore next */
if (!xrs) {
throw new Error('Accessors not set');
}
if (!this._trace) {
return;
}
const { duration, spans, startTime: traceStartTime } = this._trace;
const isUp = direction < 0;
let boundaryRow: number;
if (startRow != null) {
boundaryRow = startRow;
} else if (isUp) {
boundaryRow = xrs.getTopRowIndexVisible();
} else {
boundaryRow = xrs.getBottomRowIndexVisible();
}
const spanIndex = xrs.mapRowIndexToSpanIndex(boundaryRow);
if ((spanIndex === 0 && isUp) || (spanIndex === spans.length - 1 && !isUp)) {
return;
}
// fullViewSpanIndex is one row inside the view window unless already at the top or bottom
let fullViewSpanIndex = spanIndex;
if (spanIndex !== 0 && spanIndex !== spans.length - 1) {
fullViewSpanIndex -= direction;
}
const [viewStart, viewEnd] = xrs.getViewRange();
const checkVisibility = viewStart !== 0 || viewEnd !== 1;
// use NaN as fallback to make flow happy
const startTime = checkVisibility ? traceStartTime + duration * viewStart : NaN;
const endTime = checkVisibility ? traceStartTime + duration * viewEnd : NaN;
const findMatches = xrs.getSearchedSpanIDs();
const _collapsed = xrs.getCollapsedChildren();
const childrenAreHidden = _collapsed ? new Set(_collapsed) : null;
// use empty Map as fallback to make flow happy
const spansMap: Map<string, TraceSpan> = childrenAreHidden
? new Map(spans.map((s) => [s.spanID, s] as [string, TraceSpan]))
: new Map();
const boundary = direction < 0 ? -1 : spans.length;
let nextSpanIndex: number | undefined;
for (let i = fullViewSpanIndex + direction; i !== boundary; i += direction) {
const span = spans[i];
const { duration: spanDuration, spanID, startTime: spanStartTime } = span;
const spanEndTime = spanStartTime + spanDuration;
if (checkVisibility && (spanStartTime > endTime || spanEndTime < startTime)) {
// span is not visible within the view range
continue;
}
if (findMatches && !findMatches.has(spanID)) {
// skip to search matches (when searching)
continue;
}
if (childrenAreHidden) {
// make sure the span is not collapsed
const { isHidden, parentIDs } = isSpanHidden(span, childrenAreHidden, spansMap);
if (isHidden) {
parentIDs.forEach((id) => childrenAreHidden.add(id));
continue;
}
}
nextSpanIndex = i;
break;
}
if (!nextSpanIndex || nextSpanIndex === boundary) {
// might as well scroll to the top or bottom
nextSpanIndex = boundary - direction;
// If there are hidden children, scroll to the last visible span
if (childrenAreHidden) {
let isFallbackHidden: boolean;
do {
const { isHidden, parentIDs } = isSpanHidden(spans[nextSpanIndex], childrenAreHidden, spansMap);
if (isHidden) {
parentIDs.forEach((id) => childrenAreHidden.add(id));
nextSpanIndex--;
}
isFallbackHidden = isHidden;
} while (isFallbackHidden);
}
}
const nextRow = xrs.mapSpanIndexToRowIndex(nextSpanIndex);
this._scrollPast(nextRow, direction);
}
/**
* Sometimes the ScrollManager is created before the trace is loaded. This
* setter allows the trace to be set asynchronously.
*/
setTrace(trace: Trace | TNil) {
this._trace = trace;
}
/**
* `setAccessors` is bound in the ctor, so it can be passed as a prop to
* children components.
*/
setAccessors = (accessors: Accessors) => {
this._accessors = accessors;
};
/**
* Scrolls around one page down (0.95x). It is bounds in the ctor, so it can
* be used as a keyboard shortcut handler.
*/
scrollPageDown = () => {
if (!this._scroller || !this._accessors) {
return;
}
this._scroller.scrollBy(0.95 * this._accessors.getViewHeight(), true);
};
/**
* Scrolls around one page up (0.95x). It is bounds in the ctor, so it can
* be used as a keyboard shortcut handler.
*/
scrollPageUp = () => {
if (!this._scroller || !this._accessors) {
return;
}
this._scroller.scrollBy(-0.95 * this._accessors.getViewHeight(), true);
};
/**
* Scrolls to the next visible span, ignoring spans that do not match the
* text filter, if there is one. It is bounds in the ctor, so it can
* be used as a keyboard shortcut handler.
*/
scrollToNextVisibleSpan = () => {
this._scrollToVisibleSpan(1);
};
/**
* Scrolls to the previous visible span, ignoring spans that do not match the
* text filter, if there is one. It is bounds in the ctor, so it can
* be used as a keyboard shortcut handler.
*/
scrollToPrevVisibleSpan = () => {
this._scrollToVisibleSpan(-1);
};
scrollToFirstVisibleSpan = () => {
this._scrollToVisibleSpan(1, 0);
};
destroy() {
this._trace = undefined;
this._scroller = undefined as any;
this._accessors = undefined;
}
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2017 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 tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
/**
* Tries to get a dark variant color. Either by simply inverting the luminosity and darkening or lightening the color
* a bit, or if base is provided, tries 2 variants of lighter and darker colors and checks which is more readable with
* the base.
* @param theme
* @param hex
* @param base
*/
export function autoColor(theme: GrafanaTheme2, hex: string, base?: string) {
if (theme.isLight) {
return hex;
} else {
if (base) {
const color = tinycolor(hex);
return tinycolor
.mostReadable(
base,
[
color.clone().lighten(25),
color.clone().lighten(10),
color,
color.clone().darken(10),
color.clone().darken(25),
],
{
includeFallbackColors: false,
}
)
.toHex8String();
}
const color = tinycolor(hex).toHsl();
color.l = 1 - color.l;
const newColor = tinycolor(color);
return newColor.isLight() ? newColor.darken(5).toHex8String() : newColor.lighten(5).toHex8String();
}
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2017 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 { render } from '@testing-library/react';
import React from 'react';
import { createTheme } from '@grafana/data';
import { UnthemedCanvasSpanGraph } from './CanvasSpanGraph';
describe('CanvasSpanGraph tests', () => {
it('renders without exploding', () => {
expect(() =>
render(<UnthemedCanvasSpanGraph items={items} valueWidth={4000} theme={createTheme()} />)
).not.toThrow();
});
});
const items = [
{
valueWidth: 1,
valueOffset: 1,
serviceName: 'service-name-0',
},
];

View File

@@ -1,85 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { withTheme2, stylesFactory } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { TNil } from '../../types';
import { getRgbColorByKey } from '../../utils/color-generator';
import renderIntoCanvas from './render-into-canvas';
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
CanvasSpanGraph: css`
label: CanvasSpanGraph;
background: ${autoColor(theme, '#fafafa')};
height: 60px;
position: absolute;
width: 100%;
`,
};
});
type CanvasSpanGraphProps = {
items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>;
valueWidth: number;
theme: GrafanaTheme2;
};
export class UnthemedCanvasSpanGraph extends React.PureComponent<CanvasSpanGraphProps> {
_canvasElm: HTMLCanvasElement | TNil;
constructor(props: CanvasSpanGraphProps) {
super(props);
this._canvasElm = undefined;
}
getColor = (key: string) => getRgbColorByKey(key, this.props.theme);
componentDidMount() {
this._draw();
}
componentDidUpdate() {
this._draw();
}
_setCanvasRef = (elm: HTMLCanvasElement | TNil) => {
this._canvasElm = elm;
};
_draw() {
if (this._canvasElm) {
const { valueWidth: totalValueWidth, items } = this.props;
renderIntoCanvas(this._canvasElm, items, totalValueWidth, this.getColor, autoColor(this.props.theme, '#fff'));
}
}
render() {
return (
<canvas
className={getStyles(this.props.theme).CanvasSpanGraph}
ref={this._setCanvasRef}
data-testid="CanvasSpanGraph"
/>
);
}
}
export default withTheme2(UnthemedCanvasSpanGraph);

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import GraphTicks, { GraphTicksProps } from './GraphTicks';
const setup = (propOverrides?: GraphTicksProps) => {
const defaultProps = {
items: [
{ valueWidth: 100, valueOffset: 25, serviceName: 'a' },
{ valueWidth: 100, valueOffset: 50, serviceName: 'b' },
],
valueWidth: 200,
numTicks: 4,
...propOverrides,
};
return render(
<svg>
<GraphTicks {...defaultProps} />
</svg>
);
};
describe('GraphTicks tests', () => {
it('creates a <g> for ticks', () => {
setup();
expect(screen.getByTestId('ticks')).toBeInTheDocument();
});
it('creates a line for each ticks excluding the first and last', () => {
setup({ numTicks: 6 });
// defaultProps.numTicks - 1 === expect
expect(screen.getByTestId('ticks').children).toHaveLength(5);
});
});

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import React from 'react';
import { useStyles2 } from '@grafana/ui';
const getStyles = () => {
return {
GraphTick: css`
label: GraphTick;
stroke: #aaa;
stroke-width: 1px;
`,
};
};
export type GraphTicksProps = {
numTicks: number;
};
export default function GraphTicks(props: GraphTicksProps) {
const { numTicks } = props;
const styles = useStyles2(getStyles);
const ticks = [];
// i starts at 1, limit is `i < numTicks` so the first and last ticks aren't drawn
for (let i = 1; i < numTicks; i++) {
const x = `${(i / numTicks) * 100}%`;
ticks.push(<line className={styles.GraphTick} x1={x} y1="0%" x2={x} y2="100%" key={i / numTicks} />);
}
return (
<g data-testid="ticks" aria-hidden="true">
{ticks}
</g>
);
}

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2017 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 { render, screen, fireEvent, within } from '@testing-library/react';
import React from 'react';
import Scrubber, { ScrubberProps } from './Scrubber';
describe('<Scrubber>', () => {
const defaultProps = {
position: 0,
};
let rerender: (arg0: JSX.Element) => void;
beforeEach(() => {
({ rerender } = render(
<svg>
<Scrubber {...(defaultProps as ScrubberProps)} />
</svg>
));
});
it('contains the proper svg components', () => {
const scrubberComponent = screen.getByTestId('scrubber-component');
const scrubberComponentG = screen.getByTestId('scrubber-component-g');
expect(within(scrubberComponent).getByTestId('scrubber-component-g')).toBeTruthy();
expect(within(scrubberComponent).getByTestId('scrubber-component-line')).toBeTruthy();
expect(within(scrubberComponentG).getByTestId('scrubber-component-rect-1')).toBeTruthy();
expect(within(scrubberComponentG).getByTestId('scrubber-component-rect-2')).toBeTruthy();
});
it('calculates the correct x% for a timestamp', () => {
rerender(
<svg>
<Scrubber {...(defaultProps as ScrubberProps)} position={0.5} />
</svg>
);
const line = screen.getByTestId('scrubber-component-line');
const rect = screen.getByTestId('scrubber-component-rect-1');
expect(line).toHaveAttribute('x1', '50%');
expect(line).toHaveAttribute('x2', '50%');
expect(rect).toHaveAttribute('x', '50%');
});
it('supports onMouseDown', () => {
expect(fireEvent.mouseDown(screen.getByTestId('scrubber-component-g'))).toBeTruthy();
});
});

View File

@@ -1,123 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import React from 'react';
import { useStyles2 } from '@grafana/ui';
export const getStyles = () => {
return {
ScrubberHandleExpansion: cx(
css`
label: ScrubberHandleExpansion;
cursor: col-resize;
fill-opacity: 0;
fill: #44f;
`,
'scrubber-handle-expansion'
),
ScrubberHandle: cx(
css`
label: ScrubberHandle;
cursor: col-resize;
fill: #555;
`,
'scrubber-handle'
),
ScrubberLine: cx(
css`
label: ScrubberLine;
pointer-events: none;
stroke: #555;
`,
'scrubber-line'
),
ScrubberDragging: css`
label: ScrubberDragging;
& .scrubber-handle-expansion {
fill-opacity: 1;
}
& .scrubber-handle {
fill: #44f;
}
& > .scrubber-line {
stroke: #44f;
}
`,
ScrubberHandles: css`
label: ScrubberHandles;
&:hover > .scrubber-handle-expansion {
fill-opacity: 1;
}
&:hover > .scrubber-handle {
fill: #44f;
}
&:hover + .scrubber.line {
stroke: #44f;
}
`,
};
};
export type ScrubberProps = {
isDragging: boolean;
position: number;
onMouseDown: (evt: React.MouseEvent<any>) => void;
onMouseEnter: (evt: React.MouseEvent<any>) => void;
onMouseLeave: (evt: React.MouseEvent<any>) => void;
};
export default function Scrubber({ isDragging, onMouseDown, onMouseEnter, onMouseLeave, position }: ScrubberProps) {
const xPercent = `${position * 100}%`;
const styles = useStyles2(getStyles);
const className = cx({ [styles.ScrubberDragging]: isDragging });
return (
<g className={className} data-testid="scrubber-component">
<g
data-testid="scrubber-component-g"
className={styles.ScrubberHandles}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* handleExpansion is only visible when `isDragging` is true */}
<rect
data-testid="scrubber-component-rect-1"
x={xPercent}
className={styles.ScrubberHandleExpansion}
style={{ transform: `translate(-4.5px)` }}
width="9"
height="20"
/>
<rect
data-testid="scrubber-component-rect-2"
x={xPercent}
className={styles.ScrubberHandle}
style={{ transform: `translate(-1.5px)` }}
width="3"
height="20"
/>
</g>
<line
className={styles.ScrubberLine}
y2="100%"
x1={xPercent}
x2={xPercent}
data-testid="scrubber-component-line"
/>
</g>
);
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import TickLabels from './TickLabels';
describe('<TickLabels>', () => {
const defaultProps = {
numTicks: 4,
duration: 5000,
};
let ticks: HTMLElement[];
beforeEach(() => {
render(<TickLabels {...defaultProps} />);
ticks = screen.getAllByTestId('tick');
});
it('renders the right number of ticks', () => {
expect(ticks).toHaveLength(defaultProps.numTicks + 1);
});
it('places the first tick on the left', () => {
const firstTick = ticks[0];
expect(firstTick).toHaveStyle(`left: 0%;`);
});
it('places the last tick on the right', () => {
const lastTick = ticks[ticks.length - 1];
expect(lastTick).toHaveStyle(`right: 0%;`);
});
it('places middle ticks at proper intervals', () => {
const positions = ['25%', '50%', '75%'];
positions.forEach((pos, i) => {
const tick = ticks.at(i + 1);
expect(tick).toHaveStyle(`left: ${pos};`);
});
});
it('shows the correct value above each tick', () => {
expect(screen.getByText('0μs')).toBeTruthy();
expect(screen.getByText('1.25ms')).toBeTruthy();
expect(screen.getByText('2.5ms')).toBeTruthy();
expect(screen.getByText('3.75ms')).toBeTruthy();
expect(screen.getByText('5ms')).toBeTruthy();
});
});

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import React from 'react';
import { useStyles2 } from '@grafana/ui';
import { formatDuration } from '../../utils/date';
const getStyles = () => {
return {
TickLabels: css`
label: TickLabels;
height: 1rem;
position: relative;
`,
TickLabelsLabel: css`
label: TickLabelsLabel;
color: #717171;
font-size: 0.7rem;
position: absolute;
user-select: none;
`,
};
};
type TickLabelsProps = {
numTicks: number;
duration: number;
};
export default function TickLabels(props: TickLabelsProps) {
const { numTicks, duration } = props;
const styles = useStyles2(getStyles);
const ticks = [];
for (let i = 0; i < numTicks + 1; i++) {
const portion = i / numTicks;
const style = portion === 1 ? { right: '0%' } : { left: `${portion * 100}%` };
ticks.push(
<div key={portion} className={styles.TickLabelsLabel} style={style} data-testid="tick">
{formatDuration(duration * portion)}
</div>
);
}
return (
<div className={styles.TickLabels} data-testid="TickLabels">
{ticks}
</div>
);
}

View File

@@ -1,114 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame';
import ViewingLayer, { ViewingLayerProps } from './ViewingLayer';
function getViewRange(viewStart: number, viewEnd: number) {
return {
time: {
current: [viewStart, viewEnd] as [number, number],
},
};
}
describe('<UnthemedViewingLayer>', () => {
polyfillAnimationFrame(window);
let props: ViewingLayerProps;
beforeEach(() => {
props = {
height: 60,
numTicks: 5,
updateNextViewRangeTime: jest.fn(),
updateViewRangeTime: jest.fn(),
viewRange: getViewRange(0, 1),
} as unknown as ViewingLayerProps;
});
it('does not render ViewingLayerCursorGuide if the cursor position is not defined', () => {
render(<ViewingLayer {...props} />);
expect(screen.queryByTestId('ViewingLayerCursorGuide')).toBeNull();
});
it('renders ViewingLayerCursorGuide when the cursor position is defined', () => {
props = { ...props, viewRange: { time: { current: [0.1, 1], cursor: 0.5 } } };
render(<ViewingLayer {...props} />);
expect(screen.getByTestId('ViewingLayerCursorGuide')).toBeTruthy();
});
it('renders <GraphTicks />', () => {
render(<ViewingLayer {...props} />);
expect(screen.getByTestId('ticks')).toBeTruthy();
});
it('renders the scrubber component lines in the correct locations when an area of the minimap is selected', () => {
props = { ...props, viewRange: { time: { current: [0.3, 0.7] } } };
render(<ViewingLayer {...props} />);
expect(screen.getAllByTestId('scrubber-component-line')[0]).toHaveAttribute('x1', '30%');
expect(screen.getAllByTestId('scrubber-component-line')[1]).toHaveAttribute('x1', '70%');
});
it('renders the scrubbers', () => {
render(<ViewingLayer {...props} />);
expect(screen.getAllByTestId('scrubber-component')).toBeTruthy();
});
it('renders a filtering box if leftBound exists', () => {
props = { ...props, viewRange: { time: { current: [0.1, 0.9] } } };
render(<ViewingLayer {...props} />);
expect(screen.getByTestId('left-ViewingLayerInactive')).toHaveAttribute('width', '10%');
expect(screen.getByTestId('left-ViewingLayerInactive')).toHaveAttribute('x', '0');
});
it('renders a filtering box if rightBound exists', () => {
props = { ...props, viewRange: { time: { current: [0, 0.8] } } };
render(<ViewingLayer {...props} />);
expect(screen.getByTestId('right-ViewingLayerInactive')).toHaveAttribute('width', '20%');
expect(screen.getByTestId('right-ViewingLayerInactive')).toHaveAttribute('x', '80%');
});
describe('reset selection button', () => {
it('should not render the reset selection button if props.viewRange.time.current = [0,1]', () => {
render(<ViewingLayer {...props} />);
expect(screen.queryByRole('button', { hidden: true })).toBeNull();
});
it('should render the reset selection button if props.viewRange.time.current[0] !== 0', () => {
props = { ...props, viewRange: { time: { current: [0.1, 1] } } };
render(<ViewingLayer {...props} />);
expect(screen.queryByRole('button', { hidden: true })).toBeInTheDocument();
});
it('should render the reset selection button if props.viewRange.time.current[1] !== 1', () => {
props = { ...props, viewRange: { time: { current: [0, 0.9] } } };
render(<ViewingLayer {...props} />);
expect(screen.queryByRole('button', { hidden: true })).toBeInTheDocument();
});
it('should call props.updateViewRangeTime when clicked', async () => {
props = { ...props, viewRange: { time: { current: [0.1, 0.9] } } };
render(<ViewingLayer {...props} />);
const button = screen.queryByRole('button', { hidden: true })!;
await userEvent.click(button);
expect(props.updateViewRangeTime).toHaveBeenCalledWith(0, 1);
});
});
});

View File

@@ -1,423 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { withTheme2, stylesFactory, Button } from '@grafana/ui';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate, TNil } from '../..';
import { autoColor } from '../../Theme';
import DraggableManager, { DraggableBounds, DraggingUpdate, EUpdateTypes } from '../../utils/DraggableManager';
import GraphTicks from './GraphTicks';
import Scrubber from './Scrubber';
export const getStyles = stylesFactory((theme: GrafanaTheme2) => {
// Need this cause emotion will merge emotion generated classes into single className if used with cx from emotion
// package and the selector won't work
const ViewingLayerResetZoomHoverClassName = 'JaegerUiComponents__ViewingLayerResetZoomHoverClassName';
const ViewingLayerResetZoom = css`
label: ViewingLayerResetZoom;
display: none;
position: absolute;
right: 1%;
top: 10%;
z-index: 1;
`;
return {
ViewingLayer: css`
label: ViewingLayer;
cursor: vertical-text;
position: relative;
z-index: 1;
&:hover > .${ViewingLayerResetZoomHoverClassName} {
display: unset;
}
`,
ViewingLayerGraph: css`
label: ViewingLayerGraph;
border: 1px solid ${autoColor(theme, '#999')};
/* need !important here to overcome something from semantic UI */
overflow: visible !important;
position: relative;
transform-origin: 0 0;
width: 100%;
`,
ViewingLayerInactive: css`
label: ViewingLayerInactive;
fill: ${autoColor(theme, 'rgba(214, 214, 214, 0.5)')};
`,
ViewingLayerCursorGuide: css`
label: ViewingLayerCursorGuide;
stroke: ${autoColor(theme, '#f44')};
stroke-width: 1;
`,
ViewingLayerDraggedShift: css`
label: ViewingLayerDraggedShift;
fill-opacity: 0.2;
`,
ViewingLayerDrag: css`
label: ViewingLayerDrag;
fill: ${autoColor(theme, '#44f')};
`,
ViewingLayerFullOverlay: css`
label: ViewingLayerFullOverlay;
bottom: 0;
cursor: col-resize;
left: 0;
position: fixed;
right: 0;
top: 0;
user-select: none;
`,
ViewingLayerResetZoom,
ViewingLayerResetZoomHoverClassName,
};
});
export type ViewingLayerProps = {
height: number;
numTicks: number;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
viewRange: ViewRange;
theme: GrafanaTheme2;
};
type ViewingLayerState = {
/**
* Cursor line should not be drawn when the mouse is over the scrubber handle.
*/
preventCursorLine: boolean;
};
/**
* Designate the tags for the different dragging managers. Exported for tests.
*/
export const dragTypes = {
/**
* Tag for dragging the right scrubber, e.g. end of the current view range.
*/
SHIFT_END: 'SHIFT_END',
/**
* Tag for dragging the left scrubber, e.g. start of the current view range.
*/
SHIFT_START: 'SHIFT_START',
/**
* Tag for dragging a new view range.
*/
REFRAME: 'REFRAME',
};
/**
* Returns the layout information for drawing the view-range differential, e.g.
* show what will change when the mouse is released. Basically, this is the
* difference from the start of the drag to the current position.
*
* @returns {{ x: string, width: string, leadginX: string }}
*/
function getNextViewLayout(start: number, position: number) {
const [left, right] = start < position ? [start, position] : [position, start];
return {
x: `${left * 100}%`,
width: `${(right - left) * 100}%`,
leadingX: `${position * 100}%`,
};
}
/**
* `ViewingLayer` is rendered on top of the Canvas rendering of the minimap and
* handles showing the current view range and handles mouse UX for modifying it.
*/
export class UnthemedViewingLayer extends React.PureComponent<ViewingLayerProps, ViewingLayerState> {
state: ViewingLayerState;
_root: Element | TNil;
/**
* `_draggerReframe` handles clicking and dragging on the `ViewingLayer` to
* redefined the view range.
*/
_draggerReframe: DraggableManager;
/**
* `_draggerStart` handles dragging the left scrubber to adjust the start of
* the view range.
*/
_draggerStart: DraggableManager;
/**
* `_draggerEnd` handles dragging the right scrubber to adjust the end of
* the view range.
*/
_draggerEnd: DraggableManager;
constructor(props: ViewingLayerProps) {
super(props);
this._draggerReframe = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleReframeDragEnd,
onDragMove: this._handleReframeDragUpdate,
onDragStart: this._handleReframeDragUpdate,
onMouseMove: this._handleReframeMouseMove,
onMouseLeave: this._handleReframeMouseLeave,
tag: dragTypes.REFRAME,
});
this._draggerStart = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleScrubberDragEnd,
onDragMove: this._handleScrubberDragUpdate,
onDragStart: this._handleScrubberDragUpdate,
onMouseEnter: this._handleScrubberEnterLeave,
onMouseLeave: this._handleScrubberEnterLeave,
tag: dragTypes.SHIFT_START,
});
this._draggerEnd = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleScrubberDragEnd,
onDragMove: this._handleScrubberDragUpdate,
onDragStart: this._handleScrubberDragUpdate,
onMouseEnter: this._handleScrubberEnterLeave,
onMouseLeave: this._handleScrubberEnterLeave,
tag: dragTypes.SHIFT_END,
});
this._root = undefined;
this.state = {
preventCursorLine: false,
};
}
componentWillUnmount() {
this._draggerReframe.dispose();
this._draggerEnd.dispose();
this._draggerStart.dispose();
}
_setRoot = (elm: SVGElement | TNil) => {
this._root = elm;
};
_getDraggingBounds = (tag: string | TNil): DraggableBounds => {
if (!this._root) {
throw new Error('invalid state');
}
const { left: clientXLeft, width } = this._root.getBoundingClientRect();
const [viewStart, viewEnd] = this.props.viewRange.time.current;
let maxValue = 1;
let minValue = 0;
if (tag === dragTypes.SHIFT_START) {
maxValue = viewEnd;
} else if (tag === dragTypes.SHIFT_END) {
minValue = viewStart;
}
return { clientXLeft, maxValue, minValue, width };
};
_handleReframeMouseMove = ({ value }: DraggingUpdate) => {
this.props.updateNextViewRangeTime({ cursor: value });
};
_handleReframeMouseLeave = () => {
this.props.updateNextViewRangeTime({ cursor: null });
};
_handleReframeDragUpdate = ({ value }: DraggingUpdate) => {
const shift = value;
const { time } = this.props.viewRange;
const anchor = time.reframe ? time.reframe.anchor : shift;
const update = { reframe: { anchor, shift } };
this.props.updateNextViewRangeTime(update);
};
_handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => {
const { time } = this.props.viewRange;
const anchor = time.reframe ? time.reframe.anchor : value;
const [start, end] = value < anchor ? [value, anchor] : [anchor, value];
manager.resetBounds();
this.props.updateViewRangeTime(start, end, 'minimap');
};
_handleScrubberEnterLeave = ({ type }: DraggingUpdate) => {
const preventCursorLine = type === EUpdateTypes.MouseEnter;
this.setState({ preventCursorLine });
};
_handleScrubberDragUpdate = ({ event, tag, type, value }: DraggingUpdate) => {
if (type === EUpdateTypes.DragStart) {
event.stopPropagation();
}
if (tag === dragTypes.SHIFT_START) {
this.props.updateNextViewRangeTime({ shiftStart: value });
} else if (tag === dragTypes.SHIFT_END) {
this.props.updateNextViewRangeTime({ shiftEnd: value });
}
};
_handleScrubberDragEnd = ({ manager, tag, value }: DraggingUpdate) => {
const [viewStart, viewEnd] = this.props.viewRange.time.current;
let update: [number, number];
if (tag === dragTypes.SHIFT_START) {
update = [value, viewEnd];
} else if (tag === dragTypes.SHIFT_END) {
update = [viewStart, value];
} else {
// to satisfy flow
throw new Error('bad state');
}
manager.resetBounds();
this.setState({ preventCursorLine: false });
this.props.updateViewRangeTime(update[0], update[1], 'minimap');
};
/**
* Resets the zoom to fully zoomed out.
*/
_resetTimeZoomClickHandler = () => {
this.props.updateViewRangeTime(0, 1);
};
/**
* Renders the difference between where the drag started and the current
* position, e.g. the red or blue highlight.
*
* @returns React.Node[]
*/
_getMarkers(from: number, to: number) {
const styles = getStyles(this.props.theme);
const layout = getNextViewLayout(from, to);
return [
<rect
key="fill"
className={cx(styles.ViewingLayerDraggedShift, styles.ViewingLayerDrag)}
x={layout.x}
y="0"
width={layout.width}
height={this.props.height - 2}
/>,
<rect
key="edge"
className={cx(styles.ViewingLayerDrag)}
x={layout.leadingX}
y="0"
width="1"
height={this.props.height - 2}
/>,
];
}
render() {
const { height, viewRange, numTicks, theme } = this.props;
const { preventCursorLine } = this.state;
const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time;
const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null;
const [viewStart, viewEnd] = current;
let leftInactive = 0;
if (viewStart) {
leftInactive = viewStart * 100;
}
let rightInactive = 100;
if (viewEnd) {
rightInactive = 100 - viewEnd * 100;
}
let cursorPosition: string | undefined;
if (!haveNextTimeRange && cursor != null && !preventCursorLine) {
cursorPosition = `${cursor * 100}%`;
}
const styles = getStyles(theme);
return (
<div aria-hidden className={styles.ViewingLayer} style={{ height }}>
{(viewStart !== 0 || viewEnd !== 1) && (
<Button
onClick={this._resetTimeZoomClickHandler}
className={cx(styles.ViewingLayerResetZoom, styles.ViewingLayerResetZoomHoverClassName)}
type="button"
variant="secondary"
>
Reset Selection
</Button>
)}
<svg
height={height}
className={styles.ViewingLayerGraph}
ref={this._setRoot}
onMouseDown={this._draggerReframe.handleMouseDown}
onMouseLeave={this._draggerReframe.handleMouseLeave}
onMouseMove={this._draggerReframe.handleMouseMove}
>
{leftInactive > 0 && (
<rect
x={0}
y={0}
height="100%"
width={`${leftInactive}%`}
className={styles.ViewingLayerInactive}
data-testid="left-ViewingLayerInactive"
/>
)}
{rightInactive > 0 && (
<rect
x={`${100 - rightInactive}%`}
y={0}
height="100%"
width={`${rightInactive}%`}
className={styles.ViewingLayerInactive}
data-testid="right-ViewingLayerInactive"
/>
)}
<GraphTicks numTicks={numTicks} />
{cursorPosition && (
<line
className={styles.ViewingLayerCursorGuide}
x1={cursorPosition}
y1="0"
x2={cursorPosition}
y2={height - 2}
strokeWidth="1"
data-testid="ViewingLayerCursorGuide"
/>
)}
{shiftStart != null && this._getMarkers(viewStart, shiftStart)}
{shiftEnd != null && this._getMarkers(viewEnd, shiftEnd)}
<Scrubber
isDragging={shiftStart != null}
onMouseDown={this._draggerStart.handleMouseDown}
onMouseEnter={this._draggerStart.handleMouseEnter}
onMouseLeave={this._draggerStart.handleMouseLeave}
position={viewStart || 0}
/>
<Scrubber
isDragging={shiftEnd != null}
position={viewEnd || 1}
onMouseDown={this._draggerEnd.handleMouseDown}
onMouseEnter={this._draggerEnd.handleMouseEnter}
onMouseLeave={this._draggerEnd.handleMouseLeave}
/>
{reframe != null && this._getMarkers(reframe.anchor, reframe.shift)}
</svg>
{/* fullOverlay updates the mouse cursor blocks mouse events */}
{haveNextTimeRange && <div className={styles.ViewingLayerFullOverlay} />}
</div>
);
}
}
export default withTheme2(UnthemedViewingLayer);

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2017 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 { getAllByTestId, render, screen } from '@testing-library/react';
import React from 'react';
import traceGenerator from '../../demo/trace-generators';
import transformTraceData from '../../model/transform-trace-data';
import { polyfill as polyfillAnimationFrame } from '../../utils/test/requestAnimationFrame';
import SpanGraph, { SpanGraphProps, TIMELINE_TICK_INTERVAL } from './index';
describe('<SpanGraph>', () => {
polyfillAnimationFrame(window);
const trace = transformTraceData(traceGenerator.trace({}))!;
const props = {
trace,
updateViewRangeTime: () => {},
viewRange: {
time: {
current: [0, 1],
},
},
};
beforeEach(() => {
render(<SpanGraph {...(props as unknown as SpanGraphProps)} />);
});
it('renders <CanvasSpanGraph />', () => {
const canvasSpanGraphComponent = screen.getByTestId('CanvasSpanGraph');
expect(canvasSpanGraphComponent).toBeTruthy();
});
it('renders <TickLabels />', () => {
const tickLabelsComponent = screen.getByTestId('TickLabels');
expect(tickLabelsComponent).toBeTruthy();
});
it('returns an empty div if a trace is not provided', () => {
const { container } = render(<SpanGraph {...({ ...props, trace: null } as unknown as SpanGraphProps)} />);
expect(container.firstChild).toBeEmptyDOMElement();
});
it('renders <TickLabels /> with the correct numnber of ticks', async () => {
const tickLabelsDiv = screen.getByTestId('TickLabels');
expect(getAllByTestId(tickLabelsDiv, 'tick').length).toBe(TIMELINE_TICK_INTERVAL + 1);
});
});

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2017 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 cx from 'classnames';
import memoizeOne from 'memoize-one';
import * as React from 'react';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '../..';
import { TraceSpan, Trace } from '../../types/trace';
import { ubPb2, ubPx2, ubRelative } from '../../uberUtilityStyles';
import CanvasSpanGraph from './CanvasSpanGraph';
import TickLabels from './TickLabels';
import ViewingLayer from './ViewingLayer';
const DEFAULT_HEIGHT = 60;
export const TIMELINE_TICK_INTERVAL = 4;
export type SpanGraphProps = {
height?: number;
trace: Trace;
viewRange: ViewRange;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
updateNextViewRangeTime: (nextUpdate: ViewRangeTimeUpdate) => void;
};
type SpanItem = {
valueOffset: number;
valueWidth: number;
serviceName: string;
};
function getItem(span: TraceSpan): SpanItem {
return {
valueOffset: span.relativeStartTime,
valueWidth: span.duration,
serviceName: span.process.serviceName,
};
}
function getItems(trace: Trace): SpanItem[] {
return trace.spans.map(getItem);
}
const memoizedGetitems = memoizeOne(getItems);
export default class SpanGraph extends React.PureComponent<SpanGraphProps> {
static defaultProps = {
height: DEFAULT_HEIGHT,
};
render() {
const { height, trace, viewRange, updateNextViewRangeTime, updateViewRangeTime } = this.props;
if (!trace) {
return <div />;
}
const items = memoizedGetitems(trace);
return (
<div className={cx(ubPb2, ubPx2)}>
<TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} />
<div className={ubRelative}>
<CanvasSpanGraph valueWidth={trace.duration} items={items} />
<ViewingLayer
viewRange={viewRange}
numTicks={TIMELINE_TICK_INTERVAL}
height={height || DEFAULT_HEIGHT}
updateViewRangeTime={updateViewRangeTime}
updateNextViewRangeTime={updateNextViewRangeTime}
/>
</div>
</div>
);
}
}

View File

@@ -1,206 +0,0 @@
// Copyright (c) 2017 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 { range as _range } from 'lodash';
import renderIntoCanvas, {
ITEM_ALPHA,
MIN_ITEM_HEIGHT,
MAX_TOTAL_HEIGHT,
MIN_ITEM_WIDTH,
MIN_TOTAL_HEIGHT,
MAX_ITEM_HEIGHT,
} from './render-into-canvas';
const BG_COLOR = '#FFFFFF';
const getCanvasWidth = () => window.innerWidth * 2;
const getBgFillRect = (items?: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>) => ({
fillStyle: BG_COLOR,
height: !items || items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(MAX_TOTAL_HEIGHT, items.length),
width: getCanvasWidth(),
x: 0,
y: 0,
});
describe('renderIntoCanvas()', () => {
const basicItem = { valueWidth: 100, valueOffset: 50, serviceName: 'some-name' };
class CanvasContext {
fillStyle: undefined;
fillRectAccumulator: Array<{ fillStyle: undefined; height: number; width: number; x: number; y: number }> = [];
constructor() {
this.fillStyle = undefined;
this.fillRectAccumulator = [];
}
fillRect(x: number, y: number, width: number, height: number) {
const fillStyle = this.fillStyle;
this.fillRectAccumulator.push({
fillStyle,
height,
width,
x,
y,
});
}
}
class Canvas {
height: number;
width: number;
contexts: CanvasContext[];
getContext: jest.Mock;
constructor() {
this.contexts = [];
this.height = NaN;
this.width = NaN;
this.getContext = jest.fn(this._getContext.bind(this));
}
_getContext() {
const ctx = new CanvasContext();
this.contexts.push(ctx);
return ctx;
}
}
function getColorFactory() {
let i = 0;
const inputOutput: Array<{ input: string; output: [number, number, number] }> = [];
function getFakeColor(str: string) {
const rv: [number, number, number] = [i, i, i];
i++;
inputOutput.push({
input: str,
output: rv.slice() as [number, number, number],
});
return rv;
}
getFakeColor.inputOutput = inputOutput;
return getFakeColor;
}
it('sets the width', () => {
const canvas = new Canvas();
expect(canvas.width !== canvas.width).toBe(true);
renderIntoCanvas(canvas as unknown as HTMLCanvasElement, [basicItem], 150, getColorFactory(), BG_COLOR);
expect(canvas.width).toBe(getCanvasWidth());
});
describe('when there are limited number of items', () => {
it('sets the height', () => {
const canvas = new Canvas();
expect(canvas.height !== canvas.height).toBe(true);
renderIntoCanvas(canvas as unknown as HTMLCanvasElement, [basicItem], 150, getColorFactory(), BG_COLOR);
expect(canvas.height).toBe(MIN_TOTAL_HEIGHT);
});
it('draws the background', () => {
const expectedDrawing = [getBgFillRect()];
const canvas = new Canvas();
const items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }> = [];
const totalValueWidth = 4000;
const getFillColor = getColorFactory();
renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, totalValueWidth, getFillColor, BG_COLOR);
expect((canvas.getContext as jest.Mock).mock.calls).toEqual([['2d', { alpha: false }]]);
expect(canvas.contexts.length).toBe(1);
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawing);
});
it('draws the map', () => {
const totalValueWidth = 4000;
const items = [
{ valueWidth: 50, valueOffset: 50, serviceName: 'service-name-0' },
{ valueWidth: 100, valueOffset: 100, serviceName: 'service-name-1' },
{ valueWidth: 150, valueOffset: 150, serviceName: 'service-name-2' },
];
const expectedColors = [
{ input: items[0].serviceName, output: [0, 0, 0] },
{ input: items[1].serviceName, output: [1, 1, 1] },
{ input: items[2].serviceName, output: [2, 2, 2] },
];
const cHeight = items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(items.length, MAX_TOTAL_HEIGHT);
const expectedDrawings = [
getBgFillRect(),
...items.map((item, i) => {
const { valueWidth, valueOffset } = item;
const color = expectedColors[i].output;
const fillStyle = `rgba(${color.concat(ITEM_ALPHA).join()})`;
const height = Math.min(MAX_ITEM_HEIGHT, Math.max(MIN_ITEM_HEIGHT, cHeight / items.length));
const width = (valueWidth / totalValueWidth) * getCanvasWidth();
const x = (valueOffset / totalValueWidth) * getCanvasWidth();
const y = (cHeight / items.length) * i;
return { fillStyle, height, width, x, y };
}),
];
const canvas = new Canvas();
const getFillColor = getColorFactory();
renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, totalValueWidth, getFillColor, BG_COLOR);
expect(getFillColor.inputOutput).toEqual(expectedColors);
expect(canvas.getContext.mock.calls).toEqual([['2d', { alpha: false }]]);
expect(canvas.contexts.length).toBe(1);
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings);
});
});
describe('when there are many items', () => {
it('sets the height when there are many items', () => {
const canvas = new Canvas();
const items = [];
for (let i = 0; i < MIN_TOTAL_HEIGHT + 1; i++) {
items.push(basicItem);
}
expect(canvas.height !== canvas.height).toBe(true);
renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, 150, getColorFactory(), BG_COLOR);
expect(canvas.height).toBe(items.length);
});
it('draws the map', () => {
const totalValueWidth = 4000;
const items = _range(MIN_TOTAL_HEIGHT * 10).map((i) => ({
valueWidth: i,
valueOffset: i,
serviceName: `service-name-${i}`,
}));
const expectedColors = items.map((item, i) => ({
input: item.serviceName,
output: [i, i, i],
}));
const expectedDrawings = [
getBgFillRect(items),
...items.map((item, i) => {
const { valueWidth, valueOffset } = item;
const color = expectedColors[i].output;
const fillStyle = `rgba(${color.concat(ITEM_ALPHA).join()})`;
const height = MIN_ITEM_HEIGHT;
const width = Math.max(MIN_ITEM_WIDTH, (valueWidth / totalValueWidth) * getCanvasWidth());
const x = (valueOffset / totalValueWidth) * getCanvasWidth();
const y = (MAX_TOTAL_HEIGHT / items.length) * i;
return { fillStyle, height, width, x, y };
}),
];
const canvas = new Canvas();
const getFillColor = getColorFactory();
renderIntoCanvas(canvas as unknown as HTMLCanvasElement, items, totalValueWidth, getFillColor, BG_COLOR);
expect(getFillColor.inputOutput).toEqual(expectedColors);
expect((canvas.getContext as jest.Mock).mock.calls).toEqual([['2d', { alpha: false }]]);
expect(canvas.contexts.length).toBe(1);
expect(canvas.contexts[0].fillRectAccumulator).toEqual(expectedDrawings);
});
});
});

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2017 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 { TNil } from '../..';
// exported for tests
export const ITEM_ALPHA = 0.8;
export const MIN_ITEM_HEIGHT = 2;
export const MAX_TOTAL_HEIGHT = 200;
export const MIN_ITEM_WIDTH = 10;
export const MIN_TOTAL_HEIGHT = 60;
export const MAX_ITEM_HEIGHT = 6;
export default function renderIntoCanvas(
canvas: HTMLCanvasElement,
items: Array<{ valueWidth: number; valueOffset: number; serviceName: string }>,
totalValueWidth: number,
getFillColor: (serviceName: string) => [number, number, number],
bgColor: string
) {
const fillCache: Map<string, string | TNil> = new Map();
const cHeight = items.length < MIN_TOTAL_HEIGHT ? MIN_TOTAL_HEIGHT : Math.min(items.length, MAX_TOTAL_HEIGHT);
const cWidth = window.innerWidth * 2;
// eslint-disable-next-line no-param-reassign
canvas.width = cWidth;
// eslint-disable-next-line no-param-reassign
canvas.height = cHeight;
const itemHeight = Math.min(MAX_ITEM_HEIGHT, Math.max(MIN_ITEM_HEIGHT, cHeight / items.length));
const itemYChange = cHeight / items.length;
const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, cWidth, cHeight);
for (let i = 0; i < items.length; i++) {
const { valueWidth, valueOffset, serviceName } = items[i];
const x = (valueOffset / totalValueWidth) * cWidth;
let width = (valueWidth / totalValueWidth) * cWidth;
if (width < MIN_ITEM_WIDTH) {
width = MIN_ITEM_WIDTH;
}
let fillStyle = fillCache.get(serviceName);
if (!fillStyle) {
fillStyle = `rgba(${getFillColor(serviceName).concat(ITEM_ALPHA).join()})`;
fillCache.set(serviceName, fillStyle);
}
ctx.fillStyle = fillStyle;
ctx.fillRect(x, i * itemYChange, width, itemHeight);
}
}

View File

@@ -1,123 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import traceGenerator from '../demo/trace-generators';
import { getTraceName } from '../model/trace-viewer';
import transformTraceData from '../model/transform-trace-data';
import TracePageHeader, { TracePageHeaderEmbedProps } from './TracePageHeader';
const trace = transformTraceData(traceGenerator.trace({}));
const setup = (propOverrides?: TracePageHeaderEmbedProps) => {
const defaultProps = {
canCollapse: false,
hideSummary: false,
onSlimViewClicked: () => {},
onTraceGraphViewClicked: () => {},
slimView: false,
trace,
hideMap: false,
timeZone: '',
viewRange: { time: { current: [10, 20] as [number, number] } },
updateNextViewRangeTime: () => {},
updateViewRangeTime: () => {},
...propOverrides,
};
return render(<TracePageHeader {...defaultProps} />);
};
describe('TracePageHeader test', () => {
it('should render a header ', () => {
setup();
expect(screen.getByRole('banner')).toBeInTheDocument();
});
it('should render nothing if a trace is not present', () => {
setup({ trace: null } as TracePageHeaderEmbedProps);
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument();
});
it('should render the trace title', () => {
setup();
expect(
screen.getByRole('heading', {
name: (content) => content.replace(/ /g, '').startsWith(getTraceName(trace!.spans).replace(/ /g, '')),
})
).toBeInTheDocument();
});
it('should render the header items', () => {
setup();
const headerItems = screen.queryAllByRole('listitem');
expect(headerItems).toHaveLength(5);
// Year-month-day hour-minute-second
expect(headerItems[0].textContent?.match(/Trace Start:\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}/g)).toBeTruthy();
expect(headerItems[1].textContent?.match(/Duration:[\d|\.][\.|\d|s][\.|\d|s]?[\d]?/)).toBeTruthy();
expect(headerItems[2].textContent?.match(/Services:\d\d?/g)).toBeTruthy();
expect(headerItems[3].textContent?.match(/Depth:\d\d?/)).toBeTruthy();
expect(headerItems[4].textContent?.match(/Total Spans:\d\d?\d?\d?/)).toBeTruthy();
});
it('should render a <SpanGraph>', () => {
setup();
expect(screen.getByText(/Reset Selection/)).toBeInTheDocument();
});
describe('observes the visibility toggles for various UX elements', () => {
it('hides the minimap when hideMap === true', () => {
setup({ hideMap: true } as TracePageHeaderEmbedProps);
expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument();
});
it('hides the summary when hideSummary === true', () => {
const { rerender } = setup({ hideSummary: false } as TracePageHeaderEmbedProps);
expect(screen.queryAllByRole('listitem')).toHaveLength(5);
rerender(<TracePageHeader {...({ hideSummary: false, trace: null } as TracePageHeaderEmbedProps)} />);
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
rerender(
<TracePageHeader
{...({
trace: trace,
hideSummary: true,
hideMap: false,
viewRange: { time: { current: [10, 20] } },
} as TracePageHeaderEmbedProps)}
/>
);
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
rerender(
<TracePageHeader
{...({
trace: trace,
hideSummary: false,
hideMap: false,
viewRange: { time: { current: [10, 20] } },
} as TracePageHeaderEmbedProps)}
/>
);
expect(screen.queryAllByRole('listitem')).toHaveLength(5);
});
});
});

View File

@@ -1,273 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import { get as _get, maxBy as _maxBy, values as _values } from 'lodash';
import * as React from 'react';
import MdKeyboardArrowRight from 'react-icons/lib/md/keyboard-arrow-right';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor, TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from '..';
import ExternalLinks from '../common/ExternalLinks';
import LabeledList from '../common/LabeledList';
import TraceName from '../common/TraceName';
import { getTraceLinks } from '../model/link-patterns';
import { getTraceName } from '../model/trace-viewer';
import { Trace } from '../types/trace';
import { uTxMuted } from '../uberUtilityStyles';
import { formatDuration } from '../utils/date';
import SpanGraph from './SpanGraph';
const getStyles = (theme: GrafanaTheme2) => {
return {
TracePageHeader: css`
label: TracePageHeader;
& > :first-child {
border-bottom: 1px solid ${autoColor(theme, '#e8e8e8')};
}
& > :nth-child(2) {
background-color: ${autoColor(theme, '#eee')};
border-bottom: 1px solid ${autoColor(theme, '#e4e4e4')};
}
& > :last-child {
border-bottom: 1px solid ${autoColor(theme, '#ccc')};
}
`,
TracePageHeaderTitleRow: css`
label: TracePageHeaderTitleRow;
align-items: center;
display: flex;
`,
TracePageHeaderBack: css`
label: TracePageHeaderBack;
align-items: center;
align-self: stretch;
background-color: #fafafa;
border-bottom: 1px solid #ddd;
border-right: 1px solid #ddd;
color: inherit;
display: flex;
font-size: 1.4rem;
padding: 0 1rem;
margin-bottom: -1px;
&:hover {
background-color: #f0f0f0;
border-color: #ccc;
}
`,
TracePageHeaderTitleLink: css`
label: TracePageHeaderTitleLink;
align-items: center;
display: flex;
flex: 1;
&:hover * {
text-decoration: underline;
}
&:hover > *,
&:hover small {
text-decoration: none;
}
/* Adapt styles when changing from a element into button */
background: transparent;
text-align: left;
border: none;
`,
TracePageHeaderDetailToggle: css`
label: TracePageHeaderDetailToggle;
font-size: 2.5rem;
transition: transform 0.07s ease-out;
`,
TracePageHeaderDetailToggleExpanded: css`
label: TracePageHeaderDetailToggleExpanded;
transform: rotate(90deg);
`,
TracePageHeaderTitle: css`
label: TracePageHeaderTitle;
color: inherit;
flex: 1;
font-size: 1.7em;
line-height: 1em;
margin: 0 0 0 0.5em;
padding-bottom: 0.5em;
`,
TracePageHeaderTitleCollapsible: css`
label: TracePageHeaderTitleCollapsible;
margin-left: 0;
`,
TracePageHeaderOverviewItems: css`
label: TracePageHeaderOverviewItems;
border-bottom: 1px solid #e4e4e4;
padding: 0.25rem 0.5rem !important;
`,
TracePageHeaderOverviewItemValueDetail: cx(
css`
label: TracePageHeaderOverviewItemValueDetail;
color: #aaa;
`,
'trace-item-value-detail'
),
TracePageHeaderOverviewItemValue: css`
label: TracePageHeaderOverviewItemValue;
&:hover > .trace-item-value-detail {
color: unset;
}
`,
TracePageHeaderArchiveIcon: css`
label: TracePageHeaderArchiveIcon;
font-size: 1.78em;
margin-right: 0.15em;
`,
TracePageHeaderTraceId: css`
label: TracePageHeaderTraceId;
white-space: nowrap;
`,
};
};
export type TracePageHeaderEmbedProps = {
canCollapse: boolean;
hideMap: boolean;
hideSummary: boolean;
onSlimViewClicked: () => void;
onTraceGraphViewClicked: () => void;
slimView: boolean;
trace: Trace | null;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRange: ViewRange;
timeZone: TimeZone;
};
export const HEADER_ITEMS = [
{
key: 'timestamp',
label: 'Trace Start:',
renderer(trace: Trace, timeZone: TimeZone, styles: ReturnType<typeof getStyles>) {
// Convert date from micro to milli seconds
const dateStr = dateTimeFormat(trace.startTime / 1000, { timeZone, defaultWithMS: true });
const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/);
return match ? (
<span className={styles.TracePageHeaderOverviewItemValue}>
{match[1]}
<span className={styles.TracePageHeaderOverviewItemValueDetail}>{match[2]}</span>
</span>
) : (
dateStr
);
},
},
{
key: 'duration',
label: 'Duration:',
renderer: (trace: Trace) => formatDuration(trace.duration),
},
{
key: 'service-count',
label: 'Services:',
renderer: (trace: Trace) => new Set(_values(trace.processes).map((p) => p.serviceName)).size,
},
{
key: 'depth',
label: 'Depth:',
renderer: (trace: Trace) => _get(_maxBy(trace.spans, 'depth'), 'depth', 0) + 1,
},
{
key: 'span-count',
label: 'Total Spans:',
renderer: (trace: Trace) => trace.spans.length,
},
];
export default function TracePageHeader(props: TracePageHeaderEmbedProps) {
const {
canCollapse,
hideMap,
hideSummary,
onSlimViewClicked,
slimView,
trace,
updateNextViewRangeTime,
updateViewRangeTime,
viewRange,
timeZone,
} = props;
const styles = useStyles2(getStyles);
const links = React.useMemo(() => {
if (!trace) {
return [];
}
return getTraceLinks(trace);
}, [trace]);
if (!trace) {
return null;
}
const summaryItems =
!hideSummary &&
!slimView &&
HEADER_ITEMS.map((item) => {
const { renderer, ...rest } = item;
return { ...rest, value: renderer(trace, timeZone, styles) };
});
const title = (
<h1 className={cx(styles.TracePageHeaderTitle, canCollapse && styles.TracePageHeaderTitleCollapsible)}>
<TraceName traceName={getTraceName(trace.spans)} />{' '}
<small className={cx(styles.TracePageHeaderTraceId, uTxMuted)}>{trace.traceID}</small>
</h1>
);
return (
<header className={styles.TracePageHeader}>
<div className={styles.TracePageHeaderTitleRow}>
{links && links.length > 0 && <ExternalLinks links={links} className={styles.TracePageHeaderBack} />}
{canCollapse ? (
<button
type="button"
className={styles.TracePageHeaderTitleLink}
onClick={onSlimViewClicked}
role="switch"
aria-checked={!slimView}
>
<MdKeyboardArrowRight
className={cx(
styles.TracePageHeaderDetailToggle,
!slimView && styles.TracePageHeaderDetailToggleExpanded
)}
/>
{title}
</button>
) : (
title
)}
</div>
{summaryItems && <LabeledList className={styles.TracePageHeaderOverviewItems} items={summaryItems} />}
{!hideMap && !slimView && (
<SpanGraph
trace={trace}
viewRange={viewRange}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
/>
)}
</header>
);
}

View File

@@ -1,79 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { createTheme } from '@grafana/data';
import TracePageSearchBar, { getStyles, TracePageSearchBarProps } from './TracePageSearchBar';
const defaultProps = {
forwardedRef: React.createRef(),
navigable: true,
searchBarSuffix: 'suffix',
searchValue: 'value',
};
describe('<TracePageSearchBar>', () => {
describe('truthy textFilter', () => {
it('renders UiFindInput with correct props', () => {
render(<TracePageSearchBar {...(defaultProps as unknown as TracePageSearchBarProps)} />);
expect((screen.getByPlaceholderText('Find...') as HTMLInputElement)['value']).toEqual('value');
const suffix = screen.getByLabelText('Search bar suffix');
const theme = createTheme();
expect(suffix['className']).toBe(getStyles(theme).TracePageSearchBarSuffix);
expect(suffix.textContent).toBe('suffix');
});
it('renders buttons', () => {
render(<TracePageSearchBar {...(defaultProps as unknown as TracePageSearchBarProps)} />);
const nextResButton = screen.queryByRole('button', { name: 'Next results button' });
const prevResButton = screen.queryByRole('button', { name: 'Prev results button' });
expect(nextResButton).toBeInTheDocument();
expect(prevResButton).toBeInTheDocument();
expect((nextResButton as HTMLButtonElement)['disabled']).toBe(false);
expect((prevResButton as HTMLButtonElement)['disabled']).toBe(false);
});
it('only shows navigable buttons when navigable is true', () => {
const props = {
...defaultProps,
navigable: false,
};
render(<TracePageSearchBar {...(props as unknown as TracePageSearchBarProps)} />);
expect(screen.queryByRole('button', { name: 'Next results button' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Prev results button' })).not.toBeInTheDocument();
});
});
describe('falsy textFilter', () => {
beforeEach(() => {
const props = {
...defaultProps,
searchValue: '',
};
render(<TracePageSearchBar {...(props as unknown as TracePageSearchBarProps)} />);
});
it('does not render suffix', () => {
expect(screen.queryByLabelText('Search bar suffix')).not.toBeInTheDocument();
});
it('renders buttons', () => {
expect(screen.getByRole('button', { name: 'Next results button' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Prev results button' })).toBeInTheDocument();
});
});
});

View File

@@ -1,208 +0,0 @@
// Copyright (c) 2018 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 { css } from '@emotion/css';
import cx from 'classnames';
import React, { memo, Dispatch, SetStateAction } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import UiFindInput from '../common/UiFindInput';
import { ubFlexAuto, ubJustifyEnd } from '../uberUtilityStyles';
// eslint-disable-next-line no-duplicate-imports
export const getStyles = (theme: GrafanaTheme2) => {
return {
TracePageSearchBar: css`
label: TracePageSearchBar;
float: right;
position: absolute;
top: 0;
right: 0;
z-index: ${theme.zIndex.navbarFixed};
background: ${theme.colors.background.primary};
margin-bottom: -48px;
padding: 8px;
margin-right: 2px;
border-radius: 4px;
box-shadow: ${theme.shadows.z2};
`,
TracePageSearchBarBar: css`
label: TracePageSearchBarBar;
max-width: 20rem;
transition: max-width 0.5s;
&:focus-within {
max-width: 100%;
}
`,
TracePageSearchBarSuffix: css`
label: TracePageSearchBarSuffix;
opacity: 0.6;
`,
TracePageSearchBarBtn: css`
label: TracePageSearchBarBtn;
transition: 0.2s;
margin-left: 8px;
`,
TracePageSearchBarBtnDisabled: css`
label: TracePageSearchBarBtnDisabled;
opacity: 0.5;
`,
TracePageSearchBarLocateBtn: css`
label: TracePageSearchBarLocateBtn;
padding: 1px 8px 4px;
`,
};
};
export type TracePageSearchBarProps = {
navigable: boolean;
searchValue: string;
setSearch: (value: string) => void;
searchBarSuffix: string;
spanFindMatches: Set<string> | undefined;
focusedSpanIdForSearch: string;
setSearchBarSuffix: Dispatch<SetStateAction<string>>;
setFocusedSpanIdForSearch: Dispatch<SetStateAction<string>>;
datasourceType: string;
};
export default memo(function TracePageSearchBar(props: TracePageSearchBarProps) {
const {
navigable,
setSearch,
searchValue,
searchBarSuffix,
spanFindMatches,
focusedSpanIdForSearch,
setSearchBarSuffix,
setFocusedSpanIdForSearch,
datasourceType,
} = props;
const styles = useStyles2(getStyles);
const suffix = searchValue ? (
<span className={styles.TracePageSearchBarSuffix} aria-label="Search bar suffix">
{searchBarSuffix}
</span>
) : null;
const btnClass = cx(styles.TracePageSearchBarBtn, { [styles.TracePageSearchBarBtnDisabled]: !searchValue });
const uiFindInputInputProps = {
className: cx(styles.TracePageSearchBarBar, ubFlexAuto),
name: 'search',
suffix,
};
const setTraceSearch = (value: string) => {
setFocusedSpanIdForSearch('');
setSearchBarSuffix('');
setSearch(value);
};
const nextResult = () => {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'next',
});
const spanMatches = Array.from(spanFindMatches!);
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch)
? spanMatches.indexOf(focusedSpanIdForSearch)
: 0;
// new query || at end, go to start
if (prevMatchedIndex === -1 || prevMatchedIndex === spanMatches.length - 1) {
setFocusedSpanIdForSearch(spanMatches[0]);
setSearchBarSuffix(getSearchBarSuffix(1));
return;
}
// get next
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex + 1]);
setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex + 2));
};
const prevResult = () => {
reportInteraction('grafana_traces_trace_view_find_next_prev_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
direction: 'prev',
});
const spanMatches = Array.from(spanFindMatches!);
const prevMatchedIndex = spanMatches.indexOf(focusedSpanIdForSearch)
? spanMatches.indexOf(focusedSpanIdForSearch)
: 0;
// new query || at start, go to end
if (prevMatchedIndex === -1 || prevMatchedIndex === 0) {
setFocusedSpanIdForSearch(spanMatches[spanMatches.length - 1]);
setSearchBarSuffix(getSearchBarSuffix(spanMatches.length));
return;
}
// get prev
setFocusedSpanIdForSearch(spanMatches[prevMatchedIndex - 1]);
setSearchBarSuffix(getSearchBarSuffix(prevMatchedIndex));
};
const getSearchBarSuffix = (index: number): string => {
if (spanFindMatches?.size && spanFindMatches?.size > 0) {
return index + ' of ' + spanFindMatches?.size;
}
return '';
};
return (
<div className={styles.TracePageSearchBar}>
<span className={ubJustifyEnd} style={{ display: 'flex' }}>
<UiFindInput
onChange={setTraceSearch}
value={searchValue}
inputProps={uiFindInputInputProps}
allowClear={true}
/>
<>
{navigable && (
<>
<Button
className={btnClass}
variant="secondary"
disabled={!searchValue}
type="button"
icon="arrow-down"
aria-label="Next results button"
onClick={nextResult}
/>
<Button
className={btnClass}
variant="secondary"
disabled={!searchValue}
type="button"
icon="arrow-up"
aria-label="Prev results button"
onClick={prevResult}
/>
</>
)}
</>
</span>
</div>
);
});

View File

@@ -1,15 +0,0 @@
// Copyright (c) 2017 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 { default } from './TracePageHeader';

View File

@@ -1,245 +0,0 @@
// Copyright (c) 2017 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 Positions from './Positions';
describe('Positions', () => {
const bufferLen = 1;
const getHeight = (i: number) => i * 2 + 2;
let ps: Positions;
beforeEach(() => {
ps = new Positions(bufferLen);
ps.profileData(10);
});
describe('constructor()', () => {
it('intializes member variables correctly', () => {
ps = new Positions(1);
expect(ps.ys).toEqual([]);
expect(ps.heights).toEqual([]);
expect(ps.bufferLen).toBe(1);
expect(ps.dataLen).toBe(-1);
expect(ps.lastI).toBe(-1);
});
});
describe('profileData(...)', () => {
it('manages increases in data length correctly', () => {
expect(ps.dataLen).toBe(10);
expect(ps.ys.length).toBe(10);
expect(ps.heights.length).toBe(10);
expect(ps.lastI).toBe(-1);
});
it('manages decreases in data length correctly', () => {
ps.lastI = 9;
ps.profileData(5);
expect(ps.dataLen).toBe(5);
expect(ps.ys.length).toBe(5);
expect(ps.heights.length).toBe(5);
expect(ps.lastI).toBe(4);
});
it('does nothing when data length is unchanged', () => {
expect(ps.dataLen).toBe(10);
expect(ps.ys.length).toBe(10);
expect(ps.heights.length).toBe(10);
expect(ps.lastI).toBe(-1);
ps.profileData(10);
expect(ps.dataLen).toBe(10);
expect(ps.ys.length).toBe(10);
expect(ps.heights.length).toBe(10);
expect(ps.lastI).toBe(-1);
});
});
describe('calcHeights()', () => {
it('updates lastI correctly', () => {
ps.calcHeights(1, getHeight);
expect(ps.lastI).toBe(bufferLen + 1);
});
it('saves the heights and y-values up to `lastI <= max + bufferLen`', () => {
const ys = [0, 2, 6, 12];
ys.length = 10;
const heights = [2, 4, 6];
heights.length = 10;
ps.calcHeights(1, getHeight);
expect(ps.ys).toEqual(ys);
expect(ps.heights).toEqual(heights);
});
it('does nothing when `max + buffer <= lastI`', () => {
ps.calcHeights(2, getHeight);
const ys = ps.ys.slice();
const heights = ps.heights.slice();
ps.calcHeights(1, getHeight);
expect(ps.ys).toEqual(ys);
expect(ps.heights).toEqual(heights);
});
describe('recalculates values up to `max + bufferLen` when `max + buffer <= lastI` and `forcedLastI = 0` is passed', () => {
beforeEach(() => {
// the initial state for the test
ps.calcHeights(2, getHeight);
});
it('test-case has a valid initial state', () => {
const initialYs = [0, 2, 6, 12, 20];
initialYs.length = 10;
const initialHeights = [2, 4, 6, 8];
initialHeights.length = 10;
expect(ps.ys).toEqual(initialYs);
expect(ps.heights).toEqual(initialHeights);
expect(ps.lastI).toBe(3);
});
it('recalcualtes the y-values correctly', () => {
// recalc a sub-set of the calcualted values using a different getHeight
ps.calcHeights(1, () => 2, 0);
const ys = [0, 2, 4, 6, 20];
ys.length = 10;
expect(ps.ys).toEqual(ys);
});
it('recalcualtes the heights correctly', () => {
// recalc a sub-set of the calcualted values using a different getHeight
ps.calcHeights(1, () => 2, 0);
const heights = [2, 2, 2, 8];
heights.length = 10;
expect(ps.heights).toEqual(heights);
});
it('saves lastI correctly', () => {
// recalc a sub-set of the calcualted values
ps.calcHeights(1, getHeight, 0);
expect(ps.lastI).toBe(2);
});
});
it('limits caclulations to the known data length', () => {
ps.calcHeights(999, getHeight);
expect(ps.lastI).toBe(ps.dataLen - 1);
});
});
describe('calcYs()', () => {
it('scans forward until `yValue` is met or exceeded', () => {
ps.calcYs(11, getHeight);
const ys = [0, 2, 6, 12, 20];
ys.length = 10;
const heights = [2, 4, 6, 8];
heights.length = 10;
expect(ps.ys).toEqual(ys);
expect(ps.heights).toEqual(heights);
});
it('exits early if the known y-values exceed `yValue`', () => {
ps.calcYs(11, getHeight);
const spy = jest.spyOn(ps, 'calcHeights');
ps.calcYs(10, getHeight);
expect(spy).not.toHaveBeenCalled();
});
it('exits when exceeds the data length even if yValue is unmet', () => {
ps.calcYs(999, getHeight);
expect(ps.ys[ps.ys.length - 1]).toBeLessThan(999);
});
});
describe('findFloorIndex()', () => {
beforeEach(() => {
ps.calcYs(11, getHeight);
// Note: ps.ys = [0, 2, 6, 12, 20, undefined x 5];
});
it('scans y-values for index that equals or precedes `yValue`', () => {
let i = ps.findFloorIndex(3, getHeight);
expect(i).toBe(1);
i = ps.findFloorIndex(21, getHeight);
expect(i).toBe(4);
ps.calcYs(999, getHeight);
i = ps.findFloorIndex(11, getHeight);
expect(i).toBe(2);
i = ps.findFloorIndex(12, getHeight);
expect(i).toBe(3);
i = ps.findFloorIndex(20, getHeight);
expect(i).toBe(4);
});
it('is robust against non-positive y-values', () => {
let i = ps.findFloorIndex(0, getHeight);
expect(i).toBe(0);
i = ps.findFloorIndex(-10, getHeight);
expect(i).toBe(0);
});
it('scans no further than dataLen even if `yValue` is unmet', () => {
const i = ps.findFloorIndex(999, getHeight);
expect(i).toBe(ps.lastI);
});
});
describe('getEstimatedHeight()', () => {
const simpleGetHeight = () => 2;
beforeEach(() => {
ps.calcYs(5, simpleGetHeight);
// Note: ps.ys = [0, 2, 4, 6, 8, undefined x 5];
});
it('returns the estimated max height, surpassing known values', () => {
const estHeight = ps.getEstimatedHeight();
expect(estHeight).toBeGreaterThan(ps.heights[ps.lastI]);
});
it('returns the known max height, if all heights have been calculated', () => {
ps.calcYs(999, simpleGetHeight);
const totalHeight = ps.getEstimatedHeight();
expect(totalHeight).toBeGreaterThan(ps.heights[ps.heights.length - 1]);
});
});
describe('confirmHeight()', () => {
const simpleGetHeight = () => 2;
beforeEach(() => {
ps.calcYs(5, simpleGetHeight);
// Note: ps.ys = [0, 2, 4, 6, 8, undefined x 5];
});
it('calculates heights up to and including `_i` if necessary', () => {
const startNumHeights = ps.heights.filter(Boolean).length;
const calcHeightsSpy = jest.spyOn(ps, 'calcHeights');
ps.confirmHeight(7, simpleGetHeight);
const endNumHeights = ps.heights.filter(Boolean).length;
expect(startNumHeights).toBeLessThan(endNumHeights);
expect(calcHeightsSpy).toHaveBeenCalled();
});
it('invokes `heightGetter` at `_i` to compare result with known height', () => {
const getHeightSpy = jest.fn(simpleGetHeight);
ps.confirmHeight(ps.lastI - 1, getHeightSpy);
expect(getHeightSpy).toHaveBeenCalled();
});
it('cascades difference in observed height vs known height to known y-values', () => {
const getLargerHeight = () => simpleGetHeight() + 2;
const knownYs = ps.ys.slice();
const expectedYValues = knownYs.map((value) => (value ? value + 2 : value));
ps.confirmHeight(0, getLargerHeight);
expect(ps.ys).toEqual(expectedYValues);
});
});
});

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2017 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.
type THeightGetter = (index: number) => number;
/**
* Keeps track of the height and y-position for anything sequenctial where
* y-positions follow one-after-another and can be derived from the height of
* the prior entries. The height is known from an accessor function parameter
* to the methods that require new knowledge the heights.
*
* @export
* @class Positions
*/
export default class Positions {
/**
* Indicates how far past the explicitly required height or y-values should
* checked.
*/
bufferLen: number;
dataLen: number;
heights: number[];
/**
* `lastI` keeps track of which values have already been visited. In many
* scenarios, values do not need to be revisited. But, revisiting is required
* when heights have changed, so `lastI` can be forced.
*/
lastI: number;
ys: number[];
constructor(bufferLen: number) {
this.ys = [];
this.heights = [];
this.bufferLen = bufferLen;
this.dataLen = -1;
this.lastI = -1;
}
/**
* Used to make sure the length of y-values and heights is consistent with
* the context; in particular `lastI` needs to remain valid.
*/
profileData(dataLength: number) {
if (dataLength !== this.dataLen) {
this.dataLen = dataLength;
this.ys.length = dataLength;
this.heights.length = dataLength;
if (this.lastI >= dataLength) {
this.lastI = dataLength - 1;
}
}
}
/**
* Calculate and save the heights and y-values, based on `heightGetter`, from
* `lastI` until the`max` index; the starting point (`lastI`) can be forced
* via the `forcedLastI` parameter.
* @param {number=} forcedLastI
*/
calcHeights(max: number, heightGetter: THeightGetter, forcedLastI?: number) {
if (forcedLastI != null) {
this.lastI = forcedLastI;
}
let _max = max + this.bufferLen;
if (_max <= this.lastI) {
return;
}
if (_max >= this.heights.length) {
_max = this.heights.length - 1;
}
let i = this.lastI;
if (this.lastI === -1) {
i = 0;
this.ys[0] = 0;
}
while (i <= _max) {
// eslint-disable-next-line no-multi-assign
const h = (this.heights[i] = heightGetter(i));
this.ys[i + 1] = this.ys[i] + h;
i++;
}
this.lastI = _max;
}
/**
* Verify the height and y-values from `lastI` up to `yValue`.
*/
calcYs(yValue: number, heightGetter: THeightGetter) {
while ((this.ys[this.lastI] == null || yValue > this.ys[this.lastI]) && this.lastI < this.dataLen - 1) {
this.calcHeights(this.lastI, heightGetter);
}
}
/**
* Get the latest height for index `_i`. If it's in new terretory
* (_i > lastI), find the heights (and y-values) leading up to it. If it's in
* known territory (_i <= lastI) and the height is different than what is
* known, recalculate subsequent y values, but don't confirm the heights of
* those items, just update based on the difference.
*/
confirmHeight(_i: number, heightGetter: THeightGetter) {
let i = _i;
if (i > this.lastI) {
this.calcHeights(i, heightGetter);
return;
}
const h = heightGetter(i);
if (h === this.heights[i]) {
return;
}
const chg = h - this.heights[i];
this.heights[i] = h;
// shift the y positions by `chg` for all known y positions
while (++i <= this.lastI) {
this.ys[i] += chg;
}
if (this.ys[this.lastI + 1] != null) {
this.ys[this.lastI + 1] += chg;
}
}
/**
* Given a target y-value (`yValue`), find the closest index (in the `.ys`
* array) that is prior to the y-value; e.g. map from y-value to index in
* `.ys`.
*/
findFloorIndex(yValue: number, heightGetter: THeightGetter): number {
this.calcYs(yValue, heightGetter);
let imin = 0;
let imax = this.lastI;
if (this.ys.length < 2 || yValue < this.ys[1]) {
return 0;
}
if (yValue > this.ys[imax]) {
return imax;
}
let i;
while (imin < imax) {
// eslint-disable-next-line no-bitwise
i = (imin + 0.5 * (imax - imin)) | 0;
if (yValue > this.ys[i]) {
if (yValue <= this.ys[i + 1]) {
return i;
}
imin = i;
} else if (yValue < this.ys[i]) {
if (yValue >= this.ys[i - 1]) {
return i - 1;
}
imax = i;
} else {
return i;
}
}
throw new Error(`unable to find floor index for y=${yValue}`);
}
/**
* Get the `y` and `height` for a given row.
*
* @returns {{ height: number, y: number }}
*/
getRowPosition(index: number, heightGetter: THeightGetter) {
this.confirmHeight(index, heightGetter);
return {
height: this.heights[index],
y: this.ys[index],
};
}
/**
* Get the estimated height of the whole shebang by extrapolating based on
* the average known height.
*/
getEstimatedHeight(): number {
const known = this.ys[this.lastI] + this.heights[this.lastI];
if (this.lastI >= this.dataLen - 1) {
// eslint-disable-next-line no-bitwise
return known | 0;
}
// eslint-disable-next-line no-bitwise
return ((known / (this.lastI + 1)) * this.heights.length) | 0;
}
}

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import ListView, { TListViewProps } from './index';
const DATA_LENGTH = 10;
function getHeight(index: number) {
return index * 2 + 2;
}
function Item(props: React.HTMLProps<HTMLDivElement>) {
const { children, ...rest } = props;
return <div {...rest}>{children}</div>;
}
const renderItem: TListViewProps['itemRenderer'] = (itemKey, styles, itemIndex, attrs) => {
return (
<Item key={itemKey} style={styles} {...attrs} data-testid="item">
{itemIndex}
</Item>
);
};
const props = {
dataLength: DATA_LENGTH,
getIndexFromKey: Number,
getKeyFromIndex: String,
initialDraw: 5,
itemHeightGetter: getHeight,
itemRenderer: renderItem,
itemsWrapperClassName: 'SomeClassName',
viewBuffer: 10,
viewBufferMin: 5,
windowScroller: true,
};
describe('<ListView />', () => {
beforeEach(() => {
render(<ListView {...props} />);
});
it('renders without exploding', () => {
expect(screen.getByTestId('ListView')).toBeInTheDocument();
});
it('renders the correct number of items', () => {
expect(screen.getAllByTestId('item').length).toBe(DATA_LENGTH);
});
});

View File

@@ -1,515 +0,0 @@
// Copyright (c) 2017 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 { TNil } from '../../types';
import Positions from './Positions';
type TWrapperProps = {
style: React.CSSProperties;
ref: (elm: HTMLDivElement) => void;
onScroll?: () => void;
};
/**
* @typedef
*/
export type TListViewProps = {
/**
* Number of elements in the list.
*/
dataLength: number;
/**
* Convert item index (number) to the key (string). ListView uses both indexes
* and keys to handle the addition of new rows.
*/
getIndexFromKey: (key: string) => number;
/**
* Convert item key (string) to the index (number). ListView uses both indexes
* and keys to handle the addition of new rows.
*/
getKeyFromIndex: (index: number) => string;
/**
* Number of items to draw and add to the DOM, initially.
*/
initialDraw?: number;
/**
* The parent provides fallback height measurements when there is not a
* rendered element to measure.
*/
itemHeightGetter: (index: number, key: string) => number;
/**
* Function that renders an item; rendered items are added directly to the
* DOM, they are not wrapped in list item wrapper HTMLElement.
*/
// itemRenderer(itemKey, style, i, attrs)
itemRenderer: (
itemKey: string,
style: Record<string, string | number>,
index: number,
attributes: Record<string, string>
) => React.ReactNode;
/**
* `className` for the HTMLElement that holds the items.
*/
itemsWrapperClassName?: string;
/**
* When adding new items to the DOM, this is the number of items to add above
* and below the current view. E.g. if list is 100 items and is scrolled
* halfway down (so items [46, 55] are in view), then when a new range of
* items is rendered, it will render items `46 - viewBuffer` to
* `55 + viewBuffer`.
*/
viewBuffer: number;
/**
* The minimum number of items offscreen in either direction; e.g. at least
* `viewBuffer` number of items must be off screen above and below the
* current view, or more items will be rendered.
*/
viewBufferMin: number;
/**
* When `true`, expect `_wrapperElm` to have `overflow: visible` and to,
* essentially, be tall to the point the entire page will will end up
* scrolling as a result of the ListView. Similar to react-virtualized
* window scroller.
*
* - Ref: https://bvaughn.github.io/react-virtualized/#/components/WindowScroller
* - Ref:https://github.com/bvaughn/react-virtualized/blob/497e2a1942529560681d65a9ef9f5e9c9c9a49ba/docs/WindowScroller.md
*/
windowScroller?: boolean;
/**
* You need to pass in scrollElement when windowScroller is set to false.
* This element is responsible for tracking scrolling for lazy loading.
*/
scrollElement?: Element;
};
const DEFAULT_INITIAL_DRAW = 100;
/**
* Virtualized list view component, for the most part, only renders the window
* of items that are in-view with some buffer before and after. Listens for
* scroll events and updates which items are rendered. See react-virtualized
* for a suite of components with similar, but generalized, functionality.
* https://github.com/bvaughn/react-virtualized
*
* Note: Presently, ListView cannot be a PureComponent. This is because ListView
* is sensitive to the underlying state that drives the list items, but it
* doesn't actually receive that state. So, a render may still be required even
* if ListView's props are unchanged.
*
* @export
* @class ListView
*/
export default class ListView extends React.Component<TListViewProps> {
/**
* Keeps track of the height and y-value of items, by item index, in the
* ListView.
*/
_yPositions: Positions;
/**
* Keep track of the known / measured heights of the rendered items; populated
* with values through observation and keyed on the item key, not the item
* index.
*/
_knownHeights: Map<string, number>;
/**
* The start index of the items currently drawn.
*/
_startIndexDrawn: number;
/**
* The end index of the items currently drawn.
*/
_endIndexDrawn: number;
/**
* The start index of the items currently in view.
*/
_startIndex: number;
/**
* The end index of the items currently in view.
*/
_endIndex: number;
/**
* Height of the visual window, e.g. height of the scroller element.
*/
_viewHeight: number;
/**
* `scrollTop` of the current scroll position.
*/
_scrollTop: number;
/**
* Used to keep track of whether or not a re-calculation of what should be
* drawn / viewable has been scheduled.
*/
_isScrolledOrResized: boolean;
/**
* If `windowScroller` is true, this notes how far down the page the scroller
* is located. (Note: repositioning and below-the-fold views are untested)
*/
_htmlTopOffset: number;
_windowScrollListenerAdded: boolean;
_htmlElm: HTMLElement;
/**
* Element holding the scroller.
*/
_wrapperElm: Element | TNil;
/**
* HTMLElement holding the rendered items.
*/
_itemHolderElm: HTMLElement | TNil;
static defaultProps = {
initialDraw: DEFAULT_INITIAL_DRAW,
itemsWrapperClassName: '',
windowScroller: false,
};
constructor(props: TListViewProps) {
super(props);
this._yPositions = new Positions(200);
// _knownHeights is (item-key -> observed height) of list items
this._knownHeights = new Map();
this._startIndexDrawn = 2 ** 20;
this._endIndexDrawn = -(2 ** 20);
this._startIndex = 0;
this._endIndex = 0;
this._viewHeight = -1;
this._scrollTop = -1;
this._isScrolledOrResized = false;
this._htmlTopOffset = -1;
this._windowScrollListenerAdded = false;
// _htmlElm is only relevant if props.windowScroller is true
this._htmlElm = document.documentElement as any;
this._wrapperElm = undefined;
this._itemHolderElm = undefined;
}
componentDidMount() {
if (this.props.windowScroller) {
if (this._wrapperElm) {
const { top } = this._wrapperElm.getBoundingClientRect();
this._htmlTopOffset = top + this._htmlElm.scrollTop;
}
window.addEventListener('scroll', this._onScroll);
this._windowScrollListenerAdded = true;
} else {
// The wrapper element should be the one that handles the scrolling. Once we are not using scroll-canvas we can remove this.
this._wrapperElm = this.props.scrollElement;
this._wrapperElm?.addEventListener('scroll', this._onScroll);
}
}
componentDidUpdate(prevProps: TListViewProps) {
if (this._itemHolderElm) {
this._scanItemHeights();
}
// When windowScroller is set to false, we can continue to handle scrollElement
if (this.props.windowScroller) {
return;
}
// check if the scrollElement changes and update its scroll listener
if (prevProps.scrollElement !== this.props.scrollElement) {
prevProps.scrollElement?.removeEventListener('scroll', this._onScroll);
this._wrapperElm = this.props.scrollElement;
this._wrapperElm?.addEventListener('scroll', this._onScroll);
}
}
componentWillUnmount() {
if (this._windowScrollListenerAdded) {
window.removeEventListener('scroll', this._onScroll);
} else {
this._wrapperElm?.removeEventListener('scroll', this._onScroll);
}
}
getViewHeight = () => this._viewHeight;
/**
* Get the index of the item at the bottom of the current view.
*/
getBottomVisibleIndex = (): number => {
const bottomY = this._scrollTop + this._viewHeight;
return this._yPositions.findFloorIndex(bottomY, this._getHeight);
};
/**
* Get the index of the item at the top of the current view.
*/
getTopVisibleIndex = (): number => this._yPositions.findFloorIndex(this._scrollTop, this._getHeight);
getRowPosition = (index: number): { height: number; y: number } =>
this._yPositions.getRowPosition(index, this._getHeight);
scrollToIndex = (index: number) => {
// calculate the position of the list view relative to the scroll parent
const { scrollElement } = this.props;
const scrollElementTop = scrollElement?.getBoundingClientRect().top || 0;
const listViewTop = (scrollElement?.scrollTop || 0) + (this._itemHolderElm?.getBoundingClientRect().top || 0);
const listViewOffset = listViewTop - scrollElementTop;
const itemOffset = this.getRowPosition(index).y;
// hard code a small offset to leave a little bit of space above the focused span, so it is visually clear
// that there is content above
this.props.scrollElement?.scrollTo({ top: itemOffset + listViewOffset - 80 });
};
/**
* Scroll event listener that schedules a remeasuring of which items should be
* rendered.
*/
_onScroll = () => {
if (!this._isScrolledOrResized) {
this._isScrolledOrResized = true;
window.requestAnimationFrame(this._positionList);
}
};
/**
* Returns true is the view height (scroll window) or scroll position have
* changed.
*/
_isViewChanged() {
if (!this._wrapperElm) {
return false;
}
const useRoot = this.props.windowScroller;
const clientHeight = useRoot ? this._htmlElm.clientHeight : this._wrapperElm.clientHeight;
const scrollTop = useRoot ? this._htmlElm.scrollTop : this._wrapperElm.scrollTop;
return clientHeight !== this._viewHeight || scrollTop !== this._scrollTop;
}
/**
* Recalculate _startIndex and _endIndex, e.g. which items are in view.
*/
_calcViewIndexes() {
const useRoot = this.props.windowScroller;
// funky if statement is to satisfy flow
if (!useRoot) {
/* istanbul ignore next */
if (!this._wrapperElm) {
this._viewHeight = -1;
this._startIndex = 0;
this._endIndex = 0;
return;
}
this._viewHeight = this._wrapperElm.clientHeight;
this._scrollTop = this._wrapperElm.scrollTop;
} else {
this._viewHeight = window.innerHeight - this._htmlTopOffset;
this._scrollTop = window.scrollY;
}
const yStart = this._scrollTop;
const yEnd = this._scrollTop + this._viewHeight;
this._startIndex = this._yPositions.findFloorIndex(yStart, this._getHeight);
this._endIndex = this._yPositions.findFloorIndex(yEnd, this._getHeight);
}
/**
* Checked to see if the currently rendered items are sufficient, if not,
* force an update to trigger more items to be rendered.
*/
_positionList = () => {
this._isScrolledOrResized = false;
if (!this._wrapperElm) {
return;
}
this._calcViewIndexes();
// indexes drawn should be padded by at least props.viewBufferMin
const maxStart = this.props.viewBufferMin > this._startIndex ? 0 : this._startIndex - this.props.viewBufferMin;
const minEnd =
this.props.viewBufferMin < this.props.dataLength - this._endIndex
? this._endIndex + this.props.viewBufferMin
: this.props.dataLength - 1;
if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) {
this.forceUpdate();
}
};
_initWrapper = (elm: HTMLElement | TNil) => {
if (!this.props.windowScroller) {
return;
}
this._wrapperElm = elm;
if (elm) {
this._viewHeight = elm.clientHeight;
}
};
_initItemHolder = (elm: HTMLElement | TNil) => {
this._itemHolderElm = elm;
this._scanItemHeights();
};
/**
* Go through all items that are rendered and save their height based on their
* item-key (which is on a data-* attribute). If any new or adjusted heights
* are found, re-measure the current known y-positions (via .yPositions).
*/
_scanItemHeights = () => {
const getIndexFromKey = this.props.getIndexFromKey;
if (!this._itemHolderElm) {
return;
}
// note the keys for the first and last altered heights, the `yPositions`
// needs to be updated
let lowDirtyKey = null;
let highDirtyKey = null;
let isDirty = false;
// iterating childNodes is faster than children
// https://jsperf.com/large-htmlcollection-vs-large-nodelist
const nodes = this._itemHolderElm.childNodes;
const max = nodes.length;
for (let i = 0; i < max; i++) {
const node: HTMLElement = nodes[i] as any;
// use `.getAttribute(...)` instead of `.dataset` for jest / JSDOM
const itemKey = node.getAttribute('data-item-key');
if (!itemKey) {
// eslint-disable-next-line no-console
console.warn('itemKey not found');
continue;
}
// measure the first child, if it's available, otherwise the node itself
// (likely not transferable to other contexts, and instead is specific to
// how we have the items rendered)
const measureSrc: Element = node.firstElementChild || node;
const observed = measureSrc.clientHeight;
const known = this._knownHeights.get(itemKey);
if (observed !== known) {
this._knownHeights.set(itemKey, observed);
if (!isDirty) {
isDirty = true;
// eslint-disable-next-line no-multi-assign
lowDirtyKey = highDirtyKey = itemKey;
} else {
highDirtyKey = itemKey;
}
}
}
if (lowDirtyKey != null && highDirtyKey != null) {
// update yPositions, then redraw
const imin = getIndexFromKey(lowDirtyKey);
const imax = highDirtyKey === lowDirtyKey ? imin : getIndexFromKey(highDirtyKey);
this._yPositions.calcHeights(imax, this._getHeight, imin);
this.forceUpdate();
}
};
/**
* Get the height of the element at index `i`; first check the known heights,
* fallback to `.props.itemHeightGetter(...)`.
*/
_getHeight = (i: number) => {
const key = this.props.getKeyFromIndex(i);
const known = this._knownHeights.get(key);
// known !== known iff known is NaN
// eslint-disable-next-line no-self-compare
if (known != null && known === known) {
return known;
}
return this.props.itemHeightGetter(i, key);
};
render() {
const {
dataLength,
getKeyFromIndex,
initialDraw = DEFAULT_INITIAL_DRAW,
itemRenderer,
viewBuffer,
viewBufferMin,
} = this.props;
const heightGetter = this._getHeight;
const items = [];
let start;
let end;
this._yPositions.profileData(dataLength);
if (!this._wrapperElm) {
start = 0;
end = (initialDraw < dataLength ? initialDraw : dataLength) - 1;
} else {
if (this._isViewChanged()) {
this._calcViewIndexes();
}
const maxStart = viewBufferMin > this._startIndex ? 0 : this._startIndex - viewBufferMin;
const minEnd = viewBufferMin < dataLength - this._endIndex ? this._endIndex + viewBufferMin : dataLength - 1;
if (maxStart < this._startIndexDrawn || minEnd > this._endIndexDrawn) {
start = viewBuffer > this._startIndex ? 0 : this._startIndex - viewBuffer;
end = this._endIndex + viewBuffer;
if (end >= dataLength) {
end = dataLength - 1;
}
} else {
start = this._startIndexDrawn;
end = this._endIndexDrawn > dataLength - 1 ? dataLength - 1 : this._endIndexDrawn;
}
}
this._yPositions.calcHeights(end, heightGetter, start || -1);
this._startIndexDrawn = start;
this._endIndexDrawn = end;
items.length = end - start + 1;
for (let i = start; i <= end; i++) {
const { y: top, height } = this._yPositions.getRowPosition(i, heightGetter);
const style = {
height,
top,
position: 'absolute',
};
const itemKey = getKeyFromIndex(i);
const attrs = { 'data-item-key': itemKey };
items.push(itemRenderer(itemKey, style, i, attrs));
}
const wrapperProps: TWrapperProps = {
style: { position: 'relative' },
ref: this._initWrapper,
};
if (!this.props.windowScroller) {
wrapperProps.onScroll = this._onScroll;
wrapperProps.style.height = '100%';
wrapperProps.style.overflowY = 'auto';
}
const scrollerStyle = {
position: 'relative' as 'relative',
height: this._yPositions.getEstimatedHeight(),
};
return (
<div {...wrapperProps} data-testid="ListView">
<div style={scrollerStyle}>
<div
style={{
position: 'absolute',
top: 0,
margin: 0,
padding: 0,
}}
className={this.props.itemsWrapperClassName}
ref={this._initItemHolder}
>
{items}
</div>
</div>
</div>
);
}
}

View File

@@ -1,97 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import SpanBar, { Props } from './SpanBar';
describe('<SpanBar>', () => {
const shortLabel = 'omg-so-awesome';
const longLabel = 'omg-awesome-long-label';
const props = {
longLabel,
shortLabel,
color: '#fff',
hintSide: 'right',
viewEnd: 1,
viewStart: 0,
theme: {},
getViewedBounds: (s: number) => {
// Log entries
if (s === 10) {
return { start: 0.1, end: 0.1 };
}
if (s === 20) {
return { start: 0.2, end: 0.2 };
}
return { error: 'error' };
},
rpc: {
viewStart: 0.25,
viewEnd: 0.75,
color: '#000',
},
traceStartTime: 0,
span: {
logs: [
{
timestamp: 10,
fields: [
{ key: 'message', value: 'oh the log message' },
{ key: 'something', value: 'else' },
],
},
{
timestamp: 10,
fields: [
{ key: 'message', value: 'oh the second log message' },
{ key: 'something', value: 'different' },
],
},
{
timestamp: 20,
fields: [
{ key: 'message', value: 'oh the next log message' },
{ key: 'more', value: 'stuff' },
],
},
],
},
};
it('renders without exploding', async () => {
render(<SpanBar {...(props as unknown as Props)} />);
expect(screen.getByText(shortLabel)).toBeInTheDocument();
expect(screen.queryByText(longLabel)).not.toBeInTheDocument();
await userEvent.hover(screen.getByTestId(selectors.components.TraceViewer.spanBar));
expect(screen.queryByText(shortLabel)).not.toBeInTheDocument();
expect(screen.getByText(longLabel)).toBeInTheDocument();
await userEvent.unhover(screen.getByTestId(selectors.components.TraceViewer.spanBar));
expect(screen.getByText(shortLabel)).toBeInTheDocument();
expect(screen.queryByText(longLabel)).not.toBeInTheDocument();
});
it('log markers count', () => {
// 3 log entries, two grouped together with the same timestamp
render(<SpanBar {...(props as unknown as Props)} />);
expect(screen.getAllByTestId('SpanBar--logMarker')).toHaveLength(2);
});
});

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import { groupBy as _groupBy } from 'lodash';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { Popover } from '../common/Popover';
import { TNil } from '../types';
import { TraceSpan } from '../types/trace';
import AccordianLogs from './SpanDetail/AccordianLogs';
import { ViewedBoundsFunctionType } from './utils';
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css`
label: wrapper;
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
overflow: hidden;
z-index: 0;
`,
bar: css`
label: bar;
border-radius: 3px;
min-width: 2px;
position: absolute;
height: 36%;
top: 32%;
`,
rpc: css`
label: rpc;
position: absolute;
top: 35%;
bottom: 35%;
z-index: 1;
`,
label: css`
label: label;
color: #aaa;
font-size: 12px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1em;
white-space: nowrap;
padding: 0 0.5em;
position: absolute;
`,
logMarker: css`
label: logMarker;
background-color: ${autoColor(theme, '#2c3235')};
cursor: pointer;
height: 60%;
min-width: 1px;
position: absolute;
top: 20%;
&:hover {
background-color: ${autoColor(theme, '#464c54')};
}
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: 0;
border: 1px solid transparent;
}
&::after {
left: 0;
}
`,
};
};
export type Props = {
color: string;
onClick?: (evt: React.MouseEvent<any>) => void;
viewEnd: number;
viewStart: number;
getViewedBounds: ViewedBoundsFunctionType;
rpc:
| {
viewStart: number;
viewEnd: number;
color: string;
}
| TNil;
traceStartTime: number;
span: TraceSpan;
className?: string;
labelClassName?: string;
longLabel: string;
shortLabel: string;
};
function toPercent(value: number) {
return `${(value * 100).toFixed(1)}%`;
}
function SpanBar({
viewEnd,
viewStart,
getViewedBounds,
color,
shortLabel,
longLabel,
onClick,
rpc,
traceStartTime,
span,
className,
labelClassName,
}: Props) {
const [label, setLabel] = useState(shortLabel);
const setShortLabel = () => setLabel(shortLabel);
const setLongLabel = () => setLabel(longLabel);
// group logs based on timestamps
const logGroups = _groupBy(span.logs, (log) => {
const posPercent = getViewedBounds(log.timestamp, log.timestamp).start;
// round to the nearest 0.2%
return toPercent(Math.round(posPercent * 500) / 500);
});
const styles = useStyles2(getStyles);
return (
<div
className={cx(styles.wrapper, className)}
onBlur={setShortLabel}
onClick={onClick}
onFocus={setLongLabel}
onMouseOut={setShortLabel}
onMouseOver={setLongLabel}
aria-hidden
data-testid={selectors.components.TraceViewer.spanBar}
>
<div
aria-label={label}
className={styles.bar}
style={{
background: color,
left: toPercent(viewStart),
width: toPercent(viewEnd - viewStart),
}}
>
<div className={cx(styles.label, labelClassName)} data-testid="SpanBar--label">
{label}
</div>
</div>
<div>
{Object.keys(logGroups).map((positionKey) => (
<Popover
key={positionKey}
content={
<AccordianLogs interactive={false} isOpen logs={logGroups[positionKey]} timestamp={traceStartTime} />
}
>
<div data-testid="SpanBar--logMarker" className={styles.logMarker} style={{ left: positionKey }} />
</Popover>
))}
</div>
{rpc && (
<div
className={styles.rpc}
style={{
background: rpc.color,
left: toPercent(rpc.viewStart),
width: toPercent(rpc.viewEnd - rpc.viewStart),
}}
/>
)}
</div>
);
}
export default React.memo(SpanBar);

View File

@@ -1,278 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SpanLinks } from 'src/types/links';
import { TraceSpan } from 'src/types/trace';
import { NONE, DURATION, TAG } from '../settings/SpanBarSettings';
import SpanBarRow, { SpanBarRowProps } from './SpanBarRow';
describe('<SpanBarRow>', () => {
const spanID = 'some-id';
const props = {
addHoverIndentGuideId: jest.fn(),
className: 'a-class-name',
color: 'color-a',
columnDivision: '0.5',
hoverIndentGuideIds: new Set(),
isChildrenExpanded: true,
isDetailExpanded: false,
isFilteredOut: false,
onDetailToggled: jest.fn(),
onChildrenToggled: jest.fn(),
operationName: 'op-name',
numTicks: 5,
rpc: {
viewStart: 0.25,
viewEnd: 0.75,
color: 'color-b',
operationName: 'rpc-op-name',
serviceName: 'rpc-service-name',
},
showErrorIcon: false,
getViewedBounds: () => ({ start: 0, end: 1 }),
span: {
duration: 9000,
hasChildren: true,
process: {
serviceName: 'service-name',
},
spanID,
logs: [],
references: [],
},
};
beforeEach(() => {
props.onDetailToggled.mockReset();
props.onChildrenToggled.mockReset();
});
it('renders without exploding', () => {
expect(() => render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />)).not.toThrow();
});
it('escalates detail toggling', async () => {
render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />);
const { onDetailToggled } = props;
expect(onDetailToggled.mock.calls.length).toBe(0);
await userEvent.click(screen.getByTestId('span-view'));
expect(onDetailToggled.mock.calls).toEqual([[spanID]]);
});
it('escalates children toggling', async () => {
render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />);
const { onChildrenToggled } = props;
expect(onChildrenToggled.mock.calls.length).toBe(0);
await userEvent.click(screen.getByTestId('icon-wrapper'));
expect(onChildrenToggled.mock.calls.length).toBe(1);
});
it('render references button', () => {
render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />);
const newSpan = Object.assign({}, props.span);
const span = Object.assign(newSpan, {
references: [
{
refType: 'FOLLOWS_FROM',
traceID: 'trace1',
spanID: 'span0',
span: {
spanID: 'span0',
},
},
{
refType: 'FOLLOWS_FROM',
traceID: 'otherTrace',
spanID: 'span1',
span: {
spanID: 'span1',
},
},
],
}) as unknown as TraceSpan;
render(
<SpanBarRow
{...(props as unknown as SpanBarRowProps)}
span={span}
createSpanLink={() =>
({
traceLinks: [{ href: 'href' }, { href: 'href' }],
} as SpanLinks)
}
/>
);
expect(screen.getAllByTestId('SpanLinksMenu')).toHaveLength(1);
});
it('render referenced to by single span', () => {
render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />);
const span = Object.assign(
{
subsidiarilyReferencedBy: [
{
refType: 'FOLLOWS_FROM',
traceID: 'trace1',
spanID: 'span0',
span: {
spanID: 'span0',
},
},
],
},
props.span
) as unknown as TraceSpan;
render(
<SpanBarRow
{...(props as unknown as SpanBarRowProps)}
span={span}
createSpanLink={() =>
({
traceLinks: [{ content: 'This span is referenced by another span', href: 'href' }],
} as SpanLinks)
}
/>
);
expect(screen.getByRole('link', { name: 'This span is referenced by another span' })).toBeInTheDocument();
});
it('render referenced to by multiple span', () => {
render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />);
const span = Object.assign(
{
subsidiarilyReferencedBy: [
{
refType: 'FOLLOWS_FROM',
traceID: 'trace1',
spanID: 'span0',
span: {
spanID: 'span0',
},
},
{
refType: 'FOLLOWS_FROM',
traceID: 'trace1',
spanID: 'span1',
span: {
spanID: 'span1',
},
},
],
},
props.span
) as unknown as TraceSpan;
render(
<SpanBarRow
{...(props as unknown as SpanBarRowProps)}
span={span}
createSpanLink={() =>
({
traceLinks: [{ href: 'href' }, { href: 'href' }],
} as SpanLinks)
}
/>
);
expect(screen.getAllByTestId('SpanLinksMenu')).toHaveLength(1);
});
describe('render span bar label', () => {
it('with default value', () => {
render(<SpanBarRow {...(props as unknown as SpanBarRowProps)} />);
expect(screen.getByText('(9ms)')).toBeInTheDocument();
});
it('with none value', () => {
const testProps = Object.assign(
{
spanBarOptions: {
type: NONE,
},
},
props
);
render(<SpanBarRow {...(testProps as unknown as SpanBarRowProps)} />);
expect(screen.queryByText('(9ms)')).not.toBeInTheDocument();
});
it('with duration value', () => {
const testProps = Object.assign(
{
spanBarOptions: {
type: DURATION,
},
},
props
);
render(<SpanBarRow {...(testProps as unknown as SpanBarRowProps)} />);
expect(screen.getByText('(9ms)')).toBeInTheDocument();
});
it('with tag value', () => {
const testProps = Object.assign(
{
spanBarOptions: {
type: TAG,
tag: 'tag',
},
},
{
...props,
span: {
process: {},
tags: [
{
key: 'tag',
value: 'tag-value',
},
],
},
}
);
render(<SpanBarRow {...(testProps as unknown as SpanBarRowProps)} />);
expect(screen.getByText('(tag-value)')).toBeInTheDocument();
});
it('with process value', () => {
let testProps = Object.assign(
{
spanBarOptions: {
type: TAG,
tag: 'tag',
},
},
{
...props,
span: {
process: {
tags: [
{
key: 'tag',
value: 'process-value',
},
],
},
tags: [],
},
}
);
render(<SpanBarRow {...(testProps as unknown as SpanBarRowProps)} />);
expect(screen.getByText('(process-value)')).toBeInTheDocument();
});
});
});

View File

@@ -1,586 +0,0 @@
// Copyright (c) 2017 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 { css, keyframes } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import IoAlert from 'react-icons/lib/io/alert';
import IoArrowRightA from 'react-icons/lib/io/arrow-right-a';
import { GrafanaTheme2, TraceKeyValuePair } from '@grafana/data';
import { stylesFactory, withTheme2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { DURATION, NONE, TAG } from '../settings/SpanBarSettings';
import { SpanBarOptions, SpanLinkFunc, TNil } from '../types';
import { SpanLinks } from '../types/links';
import { TraceSpan } from '../types/trace';
import SpanBar from './SpanBar';
import { SpanLinksMenu } from './SpanLinks';
import SpanTreeOffset from './SpanTreeOffset';
import Ticks from './Ticks';
import TimelineRow from './TimelineRow';
import { formatDuration, ViewedBoundsFunctionType } from './utils';
const spanBarClassName = 'spanBar';
const spanBarLabelClassName = 'spanBarLabel';
const nameWrapperClassName = 'nameWrapper';
const nameWrapperMatchingFilterClassName = 'nameWrapperMatchingFilter';
const viewClassName = 'jaegerView';
const nameColumnClassName = 'nameColumn';
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
const animations = {
flash: keyframes`
label: flash;
from {
background-color: ${autoColor(theme, '#68b9ff')};
}
to {
background-color: default;
}
`,
};
return {
nameWrapper: css`
label: nameWrapper;
line-height: 27px;
overflow: hidden;
display: flex;
`,
nameWrapperMatchingFilter: css`
label: nameWrapperMatchingFilter;
background-color: ${autoColor(theme, '#fffce4')};
`,
nameColumn: css`
label: nameColumn;
position: relative;
white-space: nowrap;
z-index: 1;
&:hover {
z-index: 1;
}
`,
endpointName: css`
label: endpointName;
color: ${autoColor(theme, '#808080')};
`,
view: css`
label: view;
position: relative;
`,
viewExpanded: css`
label: viewExpanded;
background: ${autoColor(theme, '#f8f8f8')};
outline: 1px solid ${autoColor(theme, '#ddd')};
`,
viewExpandedAndMatchingFilter: css`
label: viewExpandedAndMatchingFilter;
background: ${autoColor(theme, '#fff3d7')};
outline: 1px solid ${autoColor(theme, '#ddd')};
`,
row: css`
label: row;
&:hover .${spanBarClassName} {
opacity: 1;
}
&:hover .${spanBarLabelClassName} {
color: ${autoColor(theme, '#000')};
}
&:hover .${nameWrapperClassName} {
background: #f8f8f8;
background: linear-gradient(
90deg,
${autoColor(theme, '#fafafa')},
${autoColor(theme, '#f8f8f8')} 75%,
${autoColor(theme, '#eee')}
);
}
&:hover .${viewClassName} {
background-color: ${autoColor(theme, '#f5f5f5')};
outline: 1px solid ${autoColor(theme, '#ddd')};
}
`,
rowClippingLeft: css`
label: rowClippingLeft;
& .${nameColumnClassName}::before {
content: ' ';
height: 100%;
position: absolute;
width: 6px;
background-image: linear-gradient(
to right,
${autoColor(theme, 'rgba(25, 25, 25, 0.25)')},
${autoColor(theme, 'rgba(32, 32, 32, 0)')}
);
left: 100%;
z-index: -1;
}
`,
rowClippingRight: css`
label: rowClippingRight;
& .${viewClassName}::before {
content: ' ';
height: 100%;
position: absolute;
width: 6px;
background-image: linear-gradient(
to left,
${autoColor(theme, 'rgba(25, 25, 25, 0.25)')},
${autoColor(theme, 'rgba(25, 25, 25, 0.25)')}
);
right: 0%;
z-index: 1;
}
`,
rowExpanded: css`
label: rowExpanded;
& .${spanBarClassName} {
opacity: 1;
}
& .${spanBarLabelClassName} {
color: ${autoColor(theme, '#000')};
}
& .${nameWrapperClassName}, &:hover .${nameWrapperClassName} {
background: ${autoColor(theme, '#f0f0f0')};
box-shadow: 0 1px 0 ${autoColor(theme, '#ddd')};
}
& .${nameWrapperMatchingFilterClassName} {
background: ${autoColor(theme, '#fff3d7')};
}
&:hover .${viewClassName} {
background: ${autoColor(theme, '#eee')};
}
`,
rowMatchingFilter: css`
label: rowMatchingFilter;
background-color: ${autoColor(theme, '#fffbde')};
&:hover .${nameWrapperClassName} {
background: linear-gradient(
90deg,
${autoColor(theme, '#fffbde')},
${autoColor(theme, '#fffbde')} 75%,
${autoColor(theme, '#f7f1c6')}
);
}
&:hover .${viewClassName} {
background-color: ${autoColor(theme, '#f7f1c6')};
outline: 1px solid ${autoColor(theme, '#ddd')};
}
`,
rowFocused: css`
label: rowFocused;
background-color: ${autoColor(theme, '#cbe7ff')};
animation: ${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0);
& .${nameWrapperClassName}, .${viewClassName}, .${nameWrapperMatchingFilterClassName} {
background-color: ${autoColor(theme, '#cbe7ff')};
animation: ${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0);
}
& .${spanBarClassName} {
opacity: 1;
}
& .${spanBarLabelClassName} {
color: ${autoColor(theme, '#000')};
}
&:hover .${nameWrapperClassName}, :hover .${viewClassName} {
background: ${autoColor(theme, '#d5ebff')};
box-shadow: 0 1px 0 ${autoColor(theme, '#ddd')};
}
`,
rowExpandedAndMatchingFilter: css`
label: rowExpandedAndMatchingFilter;
&:hover .${viewClassName} {
background: ${autoColor(theme, '#ffeccf')};
}
`,
name: css`
label: name;
color: ${autoColor(theme, '#000')};
cursor: pointer;
flex: 1 1 auto;
outline: none;
overflow-y: hidden;
overflow-x: auto;
margin-right: 8px;
padding-left: 4px;
padding-right: 0.25em;
position: relative;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&::before {
content: ' ';
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
border-left: 4px solid;
border-left-color: inherit;
}
&:focus {
text-decoration: none;
}
&:hover > small {
color: ${autoColor(theme, '#000')};
}
text-align: left;
background: transparent;
border: none;
`,
nameDetailExpanded: css`
label: nameDetailExpanded;
&::before {
bottom: 0;
}
`,
svcName: css`
label: svcName;
padding: 0 0.25rem 0 0.5rem;
font-size: 1.05em;
`,
svcNameChildrenCollapsed: css`
label: svcNameChildrenCollapsed;
font-weight: bold;
font-style: italic;
`,
errorIcon: css`
label: errorIcon;
border-radius: 6.5px;
color: ${autoColor(theme, '#fff')};
font-size: 0.85em;
margin-right: 0.25rem;
padding: 1px;
`,
rpcColorMarker: css`
label: rpcColorMarker;
border-radius: 6.5px;
display: inline-block;
font-size: 0.85em;
height: 1em;
margin-right: 0.25rem;
padding: 1px;
width: 1em;
vertical-align: middle;
`,
labelRight: css`
label: labelRight;
left: 100%;
`,
labelLeft: css`
label: labelLeft;
right: 100%;
`,
};
});
export type SpanBarRowProps = {
className?: string;
theme: GrafanaTheme2;
color: string;
spanBarOptions: SpanBarOptions | undefined;
columnDivision: number;
isChildrenExpanded: boolean;
isDetailExpanded: boolean;
isMatchingFilter: boolean;
isFocused: boolean;
onDetailToggled: (spanID: string) => void;
onChildrenToggled: (spanID: string) => void;
numTicks: number;
rpc?:
| {
viewStart: number;
viewEnd: number;
color: string;
operationName: string;
serviceName: string;
}
| TNil;
noInstrumentedServer?:
| {
color: string;
serviceName: string;
}
| TNil;
showErrorIcon: boolean;
getViewedBounds: ViewedBoundsFunctionType;
traceStartTime: number;
span: TraceSpan;
hoverIndentGuideIds: Set<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
clippingLeft?: boolean;
clippingRight?: boolean;
createSpanLink?: SpanLinkFunc;
datasourceType: string;
};
/**
* This was originally a stateless function, but changing to a PureComponent
* reduced the render time of expanding a span row detail by ~50%. This is
* even true in the case where the stateless function has the same prop types as
* this class and arrow functions are created in the stateless function as
* handlers to the onClick props. E.g. for now, the PureComponent is more
* performance than the stateless function.
*/
export class UnthemedSpanBarRow extends React.PureComponent<SpanBarRowProps> {
static displayName = 'UnthemedSpanBarRow';
static defaultProps: Partial<SpanBarRowProps> = {
className: '',
rpc: null,
};
_detailToggle = () => {
this.props.onDetailToggled(this.props.span.spanID);
};
_childrenToggle = () => {
this.props.onChildrenToggled(this.props.span.spanID);
};
render() {
const {
className,
color,
spanBarOptions,
columnDivision,
isChildrenExpanded,
isDetailExpanded,
isMatchingFilter,
isFocused,
numTicks,
rpc,
noInstrumentedServer,
showErrorIcon,
getViewedBounds,
traceStartTime,
span,
hoverIndentGuideIds,
addHoverIndentGuideId,
removeHoverIndentGuideId,
clippingLeft,
clippingRight,
theme,
createSpanLink,
datasourceType,
} = this.props;
const {
duration,
hasChildren: isParent,
operationName,
process: { serviceName },
} = span;
const label = formatDuration(duration);
const viewBounds = getViewedBounds(span.startTime, span.startTime + span.duration);
const viewStart = viewBounds.start;
const viewEnd = viewBounds.end;
const styles = getStyles(theme);
const labelDetail = `${serviceName}::${operationName}`;
let longLabel;
let hintClassName;
if (viewStart > 1 - viewEnd) {
longLabel = `${labelDetail} | ${label}`;
hintClassName = styles.labelLeft;
} else {
longLabel = `${label} | ${labelDetail}`;
hintClassName = styles.labelRight;
}
const countLinks = (links?: SpanLinks): number => {
if (!links) {
return 0;
}
return Object.values(links).reduce((count, arr) => count + arr.length, 0);
};
return (
<TimelineRow
className={cx(
styles.row,
{
[styles.rowExpanded]: isDetailExpanded,
[styles.rowMatchingFilter]: isMatchingFilter,
[styles.rowExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
[styles.rowFocused]: isFocused,
[styles.rowClippingLeft]: clippingLeft,
[styles.rowClippingRight]: clippingRight,
},
className
)}
>
<TimelineRow.Cell className={cx(styles.nameColumn, nameColumnClassName)} width={columnDivision}>
<div
className={cx(styles.nameWrapper, nameWrapperClassName, {
[styles.nameWrapperMatchingFilter]: isMatchingFilter,
nameWrapperMatchingFilter: isMatchingFilter,
})}
>
<SpanTreeOffset
onClick={isParent ? this._childrenToggle : undefined}
childrenVisible={isChildrenExpanded}
span={span}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
/>
<button
type="button"
className={cx(styles.name, { [styles.nameDetailExpanded]: isDetailExpanded })}
aria-checked={isDetailExpanded}
title={labelDetail}
onClick={this._detailToggle}
role="switch"
style={{ borderColor: color }}
tabIndex={0}
>
<span
className={cx(styles.svcName, {
[styles.svcNameChildrenCollapsed]: isParent && !isChildrenExpanded,
})}
>
{showErrorIcon && (
<IoAlert
style={{
backgroundColor: span.errorIconColor
? autoColor(theme, span.errorIconColor)
: autoColor(theme, '#db2828'),
}}
className={styles.errorIcon}
/>
)}
{serviceName}{' '}
{rpc && (
<span>
<IoArrowRightA /> <i className={styles.rpcColorMarker} style={{ background: rpc.color }} />
{rpc.serviceName}
</span>
)}
{noInstrumentedServer && (
<span>
<IoArrowRightA />{' '}
<i className={styles.rpcColorMarker} style={{ background: noInstrumentedServer.color }} />
{noInstrumentedServer.serviceName}
</span>
)}
</span>
<small className={styles.endpointName}>{rpc ? rpc.operationName : operationName}</small>
<small className={styles.endpointName}> {this.getSpanBarLabel(span, spanBarOptions, label)}</small>
</button>
{createSpanLink &&
(() => {
const links = createSpanLink(span);
const count = countLinks(links);
if (links && count === 1) {
const link = links.logLinks?.[0] ?? links.metricLinks?.[0] ?? links.traceLinks?.[0] ?? undefined;
if (!link) {
return null;
}
return (
<a
href={link.href}
// Needs to have target otherwise preventDefault would not work due to angularRouter.
target={'_blank'}
style={{ marginRight: '5px' }}
rel="noopener noreferrer"
onClick={
link.onClick
? (event) => {
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link.onClick) {
event.preventDefault();
link.onClick(event);
}
}
: undefined
}
>
{link.content}
</a>
);
} else if (links && count > 1) {
return <SpanLinksMenu links={links} datasourceType={datasourceType} />;
} else {
return null;
}
})()}
</div>
</TimelineRow.Cell>
<TimelineRow.Cell
className={cx(styles.view, viewClassName, {
[styles.viewExpanded]: isDetailExpanded,
[styles.viewExpandedAndMatchingFilter]: isMatchingFilter && isDetailExpanded,
})}
data-testid="span-view"
style={{ cursor: 'pointer' }}
width={1 - columnDivision}
onClick={this._detailToggle}
>
<Ticks numTicks={numTicks} />
<SpanBar
rpc={rpc}
viewStart={viewStart}
viewEnd={viewEnd}
getViewedBounds={getViewedBounds}
color={color}
shortLabel={label}
longLabel={longLabel}
traceStartTime={traceStartTime}
span={span}
labelClassName={`${spanBarLabelClassName} ${hintClassName}`}
className={spanBarClassName}
/>
</TimelineRow.Cell>
</TimelineRow>
);
}
getSpanBarLabel = (span: TraceSpan, spanBarOptions: SpanBarOptions | undefined, duration: string) => {
const type = spanBarOptions?.type ?? '';
if (type === NONE) {
return '';
} else if (type === '' || type === DURATION) {
return `(${duration})`;
} else if (type === TAG) {
const tagKey = spanBarOptions?.tag?.trim() ?? '';
if (tagKey !== '' && span.tags) {
const tag = span.tags?.find((tag: TraceKeyValuePair) => {
return tag.key === tagKey;
});
const process = span.process?.tags?.find((process: TraceKeyValuePair) => {
return process.key === tagKey;
});
if (tag) {
return `(${tag.value})`;
}
if (process) {
return `(${process.value})`;
}
}
}
return '';
};
}
export default withTheme2(UnthemedSpanBarRow);

View File

@@ -1,15 +0,0 @@
// Copyright (c) 2017 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 const LABEL = 'label';

View File

@@ -1,106 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import AccordianKeyValues, { KeyValuesSummary, AccordianKeyValuesProps } from './AccordianKeyValues';
const tags = [
{ key: 'span.kind', value: 'client' },
{ key: 'omg', value: 'mos-def' },
];
const setupAccordian = (propOverrides?: AccordianKeyValuesProps) => {
const props = {
compact: false,
data: tags,
isOpen: true,
label: 'test accordian',
onToggle: jest.fn(),
...propOverrides,
};
return render(<AccordianKeyValues {...(props as AccordianKeyValuesProps)} />);
};
const setupKeyValues = (propOverrides?: AccordianKeyValuesProps) => {
const props = {
data: tags,
...propOverrides,
};
return render(<KeyValuesSummary {...props} />);
};
describe('KeyValuesSummary tests', () => {
it('renders without exploding', () => {
expect(() => setupKeyValues()).not.toThrow();
});
it('returns `null` when props.data is empty', () => {
setupKeyValues({ data: null } as unknown as AccordianKeyValuesProps);
expect(screen.queryAllByRole('table')).toHaveLength(0);
expect(screen.queryAllByRole('row')).toHaveLength(0);
expect(screen.queryAllByRole('cell')).toHaveLength(0);
});
it('generates a list from `data` with the correct content', () => {
setupKeyValues();
expect(screen.queryAllByRole('listitem')).toHaveLength(2);
});
it('renders the data as text', () => {
setupKeyValues();
expect(screen.getByText(/^span.kind$/)).toBeInTheDocument();
expect(screen.getByText(/^client$/)).toBeInTheDocument();
expect(screen.getByText(/^omg$/)).toBeInTheDocument();
expect(screen.getByText(/^mos-def$/)).toBeInTheDocument();
});
});
describe('AccordianKeyValues test', () => {
it('renders without exploding', () => {
expect(() => setupAccordian()).not.toThrow();
});
it('renders the label', () => {
setupAccordian();
expect(screen.getByTestId('AccordianKeyValues--header')).toBeInTheDocument();
});
it('renders table correctly when passed data & is open ', () => {
setupAccordian();
expect(screen.getByRole('switch', { name: 'test accordian' })).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('row', { name: 'span.kind "client"' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'span.kind' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: '"client"' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: 'omg' })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: '"mos-def"' })).toBeInTheDocument();
});
it('renders the summary instead of the table when it is not expanded', () => {
setupAccordian({ isOpen: false } as AccordianKeyValuesProps);
expect(
screen.getByRole('switch', { name: 'test accordian: span.kind = client omg = mos-def' })
).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.queryAllByRole('cell')).toHaveLength(0);
});
});

View File

@@ -1,170 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { TNil } from '../../types';
import { TraceKeyValuePair, TraceLink } from '../../types/trace';
import { uAlignIcon, uTxEllipsis } from '../../uberUtilityStyles';
import * as markers from './AccordianKeyValues.markers';
import KeyValuesTable from './KeyValuesTable';
export const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
label: header;
cursor: pointer;
overflow: hidden;
padding: 0.25em 0.1em;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: ${autoColor(theme, '#e8e8e8')};
}
`,
headerEmpty: css`
label: headerEmpty;
background: none;
cursor: initial;
`,
headerHighContrast: css`
label: headerHighContrast;
&:hover {
background: ${autoColor(theme, '#ddd')};
}
`,
emptyIcon: css`
label: emptyIcon;
color: ${autoColor(theme, '#aaa')};
`,
summary: css`
label: summary;
display: inline;
list-style: none;
padding: 0;
`,
summaryItem: css`
label: summaryItem;
display: inline;
margin-left: 0.7em;
padding-right: 0.5rem;
border-right: 1px solid ${autoColor(theme, '#ddd')};
&:last-child {
padding-right: 0;
border-right: none;
}
`,
summaryLabel: css`
label: summaryLabel;
color: ${autoColor(theme, '#777')};
`,
summaryDelim: css`
label: summaryDelim;
color: ${autoColor(theme, '#bbb')};
padding: 0 0.2em;
`,
};
};
export type AccordianKeyValuesProps = {
className?: string | TNil;
data: TraceKeyValuePair[];
highContrast?: boolean;
interactive?: boolean;
isOpen: boolean;
label: string;
linksGetter: ((pairs: TraceKeyValuePair[], index: number) => TraceLink[]) | TNil;
onToggle?: null | (() => void);
};
// export for tests
export function KeyValuesSummary(props: { data?: TraceKeyValuePair[] }) {
const { data } = props;
const styles = useStyles2(getStyles);
if (!Array.isArray(data) || !data.length) {
return null;
}
return (
<ul className={styles.summary}>
{data.map((item, i) => (
// `i` is necessary in the key because item.key can repeat
<li className={styles.summaryItem} key={`${item.key}-${i}`}>
<span className={styles.summaryLabel}>{item.key}</span>
<span className={styles.summaryDelim}>=</span>
{String(item.value)}
</li>
))}
</ul>
);
}
KeyValuesSummary.defaultProps = {
data: null,
};
export default function AccordianKeyValues(props: AccordianKeyValuesProps) {
const { className, data, highContrast, interactive, isOpen, label, linksGetter, onToggle } = props;
const isEmpty = !Array.isArray(data) || !data.length;
const styles = useStyles2(getStyles);
const iconCls = cx(uAlignIcon, { [styles.emptyIcon]: isEmpty });
let arrow: React.ReactNode | null = null;
let headerProps: {} | null = null;
if (interactive) {
arrow = isOpen ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
headerProps = {
'aria-checked': isOpen,
onClick: isEmpty ? null : onToggle,
role: 'switch',
};
}
return (
<div className={cx(className, uTxEllipsis)}>
<div
className={cx(styles.header, {
[styles.headerEmpty]: isEmpty,
[styles.headerHighContrast]: highContrast && !isEmpty,
})}
{...headerProps}
data-testid="AccordianKeyValues--header"
>
{arrow}
<strong data-test={markers.LABEL}>
{label}
{isOpen || ':'}
</strong>
{!isOpen && <KeyValuesSummary data={data} />}
</div>
{isOpen && <KeyValuesTable data={data} linksGetter={linksGetter} />}
</div>
);
}
AccordianKeyValues.defaultProps = {
className: null,
highContrast: false,
interactive: true,
onToggle: null,
};

View File

@@ -1,76 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import AccordianLogs, { AccordianLogsProps } from './AccordianLogs';
const logs = [
{
timestamp: 10,
fields: [
{ key: 'message', value: 'oh the log message' },
{ key: 'something', value: 'else' },
],
},
{
timestamp: 20,
fields: [
{ key: 'message', value: 'oh the next log message' },
{ key: 'more', value: 'stuff' },
],
},
];
const setup = (propOverrides?: AccordianLogsProps) => {
const props = {
logs,
isOpen: false,
onItemToggle: jest.fn(),
onToggle: () => {},
openedItems: new Set([logs[1]]),
timestamp: 5,
...propOverrides,
};
return render(<AccordianLogs {...props} />);
};
describe('AccordianLogs tests', () => {
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('shows the number of log entries', () => {
setup();
expect(screen.getByRole('switch', { name: 'Events (2)' })).toBeInTheDocument();
});
it('hides log entries when not expanded', () => {
setup({ isOpen: false } as AccordianLogsProps);
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('shows log entries when expanded', () => {
setup({ isOpen: true } as AccordianLogsProps);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.queryAllByRole('cell')).toHaveLength(6);
expect(screen.getByText(/^something$/)).toBeInTheDocument();
expect(screen.getByText(/^else$/)).toBeInTheDocument();
});
});

View File

@@ -1,126 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import { sortBy as _sortBy } from 'lodash';
import * as React from 'react';
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { TNil } from '../../types';
import { TraceLog, TraceKeyValuePair, TraceLink } from '../../types/trace';
import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles';
import { formatDuration } from '../utils';
import AccordianKeyValues from './AccordianKeyValues';
const getStyles = (theme: GrafanaTheme2) => {
return {
AccordianLogs: css`
label: AccordianLogs;
border: 1px solid ${autoColor(theme, '#d8d8d8')};
position: relative;
margin-bottom: 0.25rem;
`,
AccordianLogsHeader: css`
label: AccordianLogsHeader;
background: ${autoColor(theme, '#e4e4e4')};
color: inherit;
display: block;
padding: 0.25rem 0.5rem;
&:hover {
background: ${autoColor(theme, '#dadada')};
}
`,
AccordianLogsContent: css`
label: AccordianLogsContent;
background: ${autoColor(theme, '#f0f0f0')};
border-top: 1px solid ${autoColor(theme, '#d8d8d8')};
padding: 0.5rem 0.5rem 0.25rem 0.5rem;
`,
AccordianLogsFooter: css`
label: AccordianLogsFooter;
color: ${autoColor(theme, '#999')};
`,
};
};
export type AccordianLogsProps = {
interactive?: boolean;
isOpen: boolean;
linksGetter: ((pairs: TraceKeyValuePair[], index: number) => TraceLink[]) | TNil;
logs: TraceLog[];
onItemToggle?: (log: TraceLog) => void;
onToggle?: () => void;
openedItems?: Set<TraceLog>;
timestamp: number;
};
export default function AccordianLogs(props: AccordianLogsProps) {
const { interactive, isOpen, linksGetter, logs, openedItems, onItemToggle, onToggle, timestamp } = props;
let arrow: React.ReactNode | null = null;
let HeaderComponent: 'span' | 'a' = 'span';
let headerProps: {} | null = null;
if (interactive) {
arrow = isOpen ? <IoIosArrowDown className={uAlignIcon} /> : <IoIosArrowRight className="u-align-icon" />;
HeaderComponent = 'a';
headerProps = {
'aria-checked': isOpen,
onClick: onToggle,
role: 'switch',
};
}
const styles = useStyles2(getStyles);
return (
<div className={styles.AccordianLogs}>
<HeaderComponent className={styles.AccordianLogsHeader} {...headerProps}>
{arrow} <strong>Events</strong> ({logs.length})
</HeaderComponent>
{isOpen && (
<div className={styles.AccordianLogsContent}>
{_sortBy(logs, 'timestamp').map((log, i) => (
<AccordianKeyValues
// `i` is necessary in the key because timestamps can repeat
key={`${log.timestamp}-${i}`}
className={i < logs.length - 1 ? ubMb1 : null}
data={log.fields || []}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(log) : false}
label={`${formatDuration(log.timestamp - timestamp)}`}
linksGetter={linksGetter}
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
/>
))}
<small className={styles.AccordianLogsFooter}>
Log timestamps are relative to the start time of the full trace.
</small>
</div>
)}
</div>
);
}
AccordianLogs.defaultProps = {
interactive: true,
linksGetter: undefined,
onItemToggle: undefined,
onToggle: undefined,
openedItems: undefined,
};

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2019 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 { render, screen } from '@testing-library/react';
import React from 'react';
import AccordianReferences, { AccordianReferencesProps } from './AccordianReferences';
const traceID = 'trace1';
const references = [
{
refType: 'CHILD_OF',
span: {
spanID: 'span1',
traceID,
operationName: 'op1',
process: {
serviceName: 'service1',
},
},
spanID: 'span1',
traceID,
},
{
refType: 'CHILD_OF',
span: {
spanID: 'span3',
traceID,
operationName: 'op2',
process: {
serviceName: 'service2',
},
},
spanID: 'span3',
traceID,
},
{
refType: 'CHILD_OF',
spanID: 'span5',
traceID: 'trace2',
},
];
const link = { href: 'link' };
const setup = (propOverrides?: AccordianReferencesProps) => {
const props = {
compact: false,
data: references,
highContrast: false,
isOpen: false,
onToggle: jest.fn(),
createFocusSpanLink: () => link,
...propOverrides,
};
return render(<AccordianReferences {...(props as AccordianReferencesProps)} />);
};
describe('AccordianReferences tests', () => {
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('renders the correct number of references', () => {
setup();
expect(screen.getByRole('switch', { name: 'References (3)' })).toBeInTheDocument();
});
it('content doesnt show when not expanded', () => {
setup({ isOpen: false } as AccordianReferencesProps);
expect(screen.queryByRole('link', { name: /^View\sLinked/ })).not.toBeInTheDocument();
expect(screen.queryAllByRole('link', { name: /^service\d\sop\d/ })).toHaveLength(0);
});
it('renders the content when it is expanded', () => {
setup({ isOpen: true } as AccordianReferencesProps);
expect(screen.getByRole('switch', { name: 'References (3)' })).toBeInTheDocument();
expect(screen.getAllByRole('link', { name: /^service\d\sop\d/ })).toHaveLength(2);
expect(screen.getByRole('link', { name: /^View\sLinked/ })).toBeInTheDocument();
});
});

View File

@@ -1,234 +0,0 @@
// Copyright (c) 2019 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 { css, cx } from '@emotion/css';
import * as React from 'react';
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
import { Field, GrafanaTheme2, LinkModel } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { TraceSpanReference } from '../../types/trace';
import { uAlignIcon, ubMb1 } from '../../uberUtilityStyles';
import ReferenceLink from '../../url/ReferenceLink';
import AccordianKeyValues from './AccordianKeyValues';
const getStyles = (theme: GrafanaTheme2) => {
return {
AccordianReferenceItem: css`
border-bottom: 1px solid ${autoColor(theme, '#d8d8d8')};
`,
AccordianKeyValues: css`
margin-left: 10px;
`,
AccordianReferences: css`
label: AccordianReferences;
border: 1px solid ${autoColor(theme, '#d8d8d8')};
position: relative;
margin-bottom: 0.25rem;
`,
AccordianReferencesHeader: css`
label: AccordianReferencesHeader;
background: ${autoColor(theme, '#e4e4e4')};
color: inherit;
display: block;
padding: 0.25rem 0.5rem;
&:hover {
background: ${autoColor(theme, '#dadada')};
}
`,
AccordianReferencesContent: css`
label: AccordianReferencesContent;
background: ${autoColor(theme, '#f0f0f0')};
border-top: 1px solid ${autoColor(theme, '#d8d8d8')};
padding: 0.5rem 0.5rem 0.25rem 0.5rem;
`,
AccordianReferencesFooter: css`
label: AccordianReferencesFooter;
color: ${autoColor(theme, '#999')};
`,
ReferencesList: css`
background: #fff;
border: 1px solid #ddd;
margin-bottom: 0.7em;
max-height: 450px;
overflow: auto;
`,
list: css`
width: 100%;
list-style: none;
padding: 0;
margin: 0;
background: #fff;
`,
itemContent: css`
padding: 0.25rem 0.5rem;
display: flex;
width: 100%;
justify-content: space-between;
`,
item: css`
&:nth-child(2n) {
background: #f5f5f5;
}
`,
debugInfo: css`
letter-spacing: 0.25px;
margin: 0.5em 0 0;
flex-wrap: wrap;
display: flex;
justify-content: flex-end;
`,
debugLabel: css`
margin: 0 5px 0 5px;
&::before {
color: #bbb;
content: attr(data-label);
}
`,
serviceName: css`
margin-right: 8px;
`,
title: css`
display: flex;
align-items: center;
gap: 4px;
`,
};
};
export type AccordianReferencesProps = {
data: TraceSpanReference[];
highContrast?: boolean;
interactive?: boolean;
isOpen: boolean;
openedItems?: Set<TraceSpanReference>;
onItemToggle?: (reference: TraceSpanReference) => void;
onToggle?: null | (() => void);
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel<Field>;
};
type ReferenceItemProps = {
data: TraceSpanReference[];
interactive?: boolean;
openedItems?: Set<TraceSpanReference>;
onItemToggle?: (reference: TraceSpanReference) => void;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel<Field>;
};
// export for test
export function References(props: ReferenceItemProps) {
const { data, createFocusSpanLink, openedItems, onItemToggle, interactive } = props;
const styles = useStyles2(getStyles);
return (
<div className={styles.AccordianReferencesContent}>
{data.map((reference, i) => (
<div className={i < data.length - 1 ? styles.AccordianReferenceItem : undefined} key={i}>
<div className={styles.item} key={`${reference.spanID}`}>
<ReferenceLink reference={reference} createFocusSpanLink={createFocusSpanLink}>
<span className={styles.itemContent}>
{reference.span ? (
<span>
<span className={cx('span-svc-name', styles.serviceName)}>
{reference.span.process.serviceName}
</span>
<small className="endpoint-name">{reference.span.operationName}</small>
</span>
) : (
<span className={cx('span-svc-name', styles.title)}>
View Linked Span <Icon name="external-link-alt" />
</span>
)}
<small className={styles.debugInfo}>
<span className={styles.debugLabel} data-label="TraceID:">
{reference.traceID}
</span>
<span className={styles.debugLabel} data-label="SpanID:">
{reference.spanID}
</span>
</small>
</span>
</ReferenceLink>
</div>
{!!reference.tags?.length && (
<div className={styles.AccordianKeyValues}>
<AccordianKeyValues
className={i < data.length - 1 ? ubMb1 : null}
data={reference.tags || []}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(reference) : false}
label={'attributes'}
linksGetter={null}
onToggle={interactive && onItemToggle ? () => onItemToggle(reference) : null}
/>
</div>
)}
</div>
))}
</div>
);
}
const AccordianReferences: React.FC<AccordianReferencesProps> = ({
data,
interactive = true,
isOpen,
onToggle,
onItemToggle,
openedItems,
createFocusSpanLink,
}) => {
const isEmpty = !Array.isArray(data) || !data.length;
let arrow: React.ReactNode | null = null;
let HeaderComponent: 'span' | 'a' = 'span';
let headerProps: {} | null = null;
if (interactive) {
arrow = isOpen ? <IoIosArrowDown className={uAlignIcon} /> : <IoIosArrowRight className={uAlignIcon} />;
HeaderComponent = 'a';
headerProps = {
'aria-checked': isOpen,
onClick: isEmpty ? null : onToggle,
role: 'switch',
};
}
const styles = useStyles2(getStyles);
return (
<div className={styles.AccordianReferences}>
<HeaderComponent className={styles.AccordianReferencesHeader} {...headerProps}>
{arrow}
<strong>
<span>References</span>
</strong>{' '}
({data.length})
</HeaderComponent>
{isOpen && (
<References
data={data}
openedItems={openedItems}
createFocusSpanLink={createFocusSpanLink}
onItemToggle={onItemToggle}
interactive={interactive}
/>
)}
</div>
);
};
export default React.memo(AccordianReferences);

View File

@@ -1,50 +0,0 @@
// 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 { render, screen, within } from '@testing-library/react';
import React from 'react';
import AccordianText from './AccordianText';
const warnings = ['Duplicated tag', 'Duplicated spanId'];
describe('<AccordianText>', () => {
const props = {
compact: false,
data: warnings,
highContrast: false,
isOpen: false,
label: 'le-label',
onToggle: jest.fn(),
};
it('renders without exploding', () => {
render(<AccordianText {...props} />);
expect(() => render(<AccordianText {...props} />)).not.toThrow();
});
it('renders the label', () => {
render(<AccordianText {...props} />);
const { getByText } = within(screen.getByTestId('AccordianText--header'));
expect(getByText(props.label)).toBeInTheDocument();
});
it('renders the content when it is expanded', () => {
props.isOpen = true;
render(<AccordianText {...props} />);
warnings.forEach((warning) => {
expect(screen.getByText(warning)).toBeInTheDocument();
});
});
});

View File

@@ -1,103 +0,0 @@
// 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
import IoIosArrowRight from 'react-icons/lib/io/ios-arrow-right';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { TNil } from '../../types';
import { uAlignIcon } from '../../uberUtilityStyles';
import { getStyles as getAccordianKeyValuesStyles } from './AccordianKeyValues';
import TextList from './TextList';
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
cursor: pointer;
overflow: hidden;
padding: 0.25em 0.1em;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: ${autoColor(theme, '#e8e8e8')};
}
`,
};
};
type AccordianTextProps = {
className?: string | TNil;
headerClassName?: string | TNil;
data: string[];
highContrast?: boolean;
interactive?: boolean;
isOpen: boolean;
label: React.ReactNode | string;
onToggle?: null | (() => void);
TextComponent?: React.ElementType<{ data: string[] }>;
};
function DefaultTextComponent({ data }: { data: string[] }) {
return <TextList data={data} />;
}
export default function AccordianText(props: AccordianTextProps) {
const {
className,
data,
headerClassName,
interactive,
isOpen,
label,
onToggle,
TextComponent = DefaultTextComponent,
} = props;
const isEmpty = !Array.isArray(data) || !data.length;
const accordianKeyValuesStyles = useStyles2(getAccordianKeyValuesStyles);
const iconCls = cx(uAlignIcon, { [accordianKeyValuesStyles.emptyIcon]: isEmpty });
let arrow: React.ReactNode | null = null;
let headerProps: {} | null = null;
if (interactive) {
arrow = isOpen ? <IoIosArrowDown className={iconCls} /> : <IoIosArrowRight className={iconCls} />;
headerProps = {
'aria-checked': isOpen,
onClick: isEmpty ? null : onToggle,
role: 'switch',
};
}
const styles = useStyles2(getStyles);
return (
<div className={className || ''}>
<div className={cx(styles.header, headerClassName)} {...headerProps} data-testid="AccordianText--header">
{arrow}
<strong>{label}</strong> ({data.length})
</div>
{isOpen && <TextComponent data={data} />}
</div>
);
}
AccordianText.defaultProps = {
className: null,
highContrast: false,
interactive: true,
onToggle: null,
};

View File

@@ -1,109 +0,0 @@
// Copyright (c) 2017 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 { TraceLog, TraceSpanReference } from '../../types/trace';
/**
* Which items of a {@link SpanDetail} component are expanded.
*/
export default class DetailState {
isTagsOpen: boolean;
isProcessOpen: boolean;
logs: { isOpen: boolean; openedItems: Set<TraceLog> };
references: { isOpen: boolean; openedItems: Set<TraceSpanReference> };
isWarningsOpen: boolean;
isStackTracesOpen: boolean;
isReferencesOpen: boolean;
constructor(oldState?: DetailState) {
const {
isTagsOpen,
isProcessOpen,
isReferencesOpen,
isWarningsOpen,
isStackTracesOpen,
logs,
references,
}: DetailState | Record<string, undefined> = oldState || {};
this.isTagsOpen = Boolean(isTagsOpen);
this.isProcessOpen = Boolean(isProcessOpen);
this.isReferencesOpen = Boolean(isReferencesOpen);
this.isWarningsOpen = Boolean(isWarningsOpen);
this.isStackTracesOpen = Boolean(isStackTracesOpen);
this.logs = {
isOpen: Boolean(logs && logs.isOpen),
openedItems: logs && logs.openedItems ? new Set(logs.openedItems) : new Set(),
};
this.references = {
isOpen: Boolean(references && references.isOpen),
openedItems: references && references.openedItems ? new Set(references.openedItems) : new Set(),
};
}
toggleTags() {
const next = new DetailState(this);
next.isTagsOpen = !this.isTagsOpen;
return next;
}
toggleProcess() {
const next = new DetailState(this);
next.isProcessOpen = !this.isProcessOpen;
return next;
}
toggleReferences() {
const next = new DetailState(this);
next.references.isOpen = !this.references.isOpen;
return next;
}
toggleReferenceItem(reference: TraceSpanReference) {
const next = new DetailState(this);
if (next.references.openedItems.has(reference)) {
next.references.openedItems.delete(reference);
} else {
next.references.openedItems.add(reference);
}
return next;
}
toggleWarnings() {
const next = new DetailState(this);
next.isWarningsOpen = !this.isWarningsOpen;
return next;
}
toggleStackTraces() {
const next = new DetailState(this);
next.isStackTracesOpen = !this.isStackTracesOpen;
return next;
}
toggleLogs() {
const next = new DetailState(this);
next.logs.isOpen = !this.logs.isOpen;
return next;
}
toggleLogItem(logItem: TraceLog) {
const next = new DetailState(this);
if (next.logs.openedItems.has(logItem)) {
next.logs.openedItems.delete(logItem);
} else {
next.logs.openedItems.add(logItem);
}
return next;
}
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import KeyValuesTable, { LinkValue, KeyValuesTableProps } from './KeyValuesTable';
const data = [
{ key: 'span.kind', value: 'client' },
{ key: 'omg', value: 'mos-def' },
{ key: 'numericString', value: '12345678901234567890' },
{ key: 'jsonkey', value: JSON.stringify({ hello: 'world' }) },
];
const setup = (propOverrides?: KeyValuesTableProps) => {
const props = {
data: data,
...propOverrides,
};
return render(<KeyValuesTable {...(props as KeyValuesTableProps)} />);
};
describe('LinkValue', () => {
it('renders as expected', () => {
const title = 'titleValue';
const href = 'hrefValue';
const childrenText = 'childrenTextValue';
render(
<LinkValue href={href} title={title}>
{childrenText}
</LinkValue>
);
expect(screen.getByRole('link', { name: 'titleValue' })).toBeInTheDocument();
expect(screen.getByText(/^childrenTextValue$/)).toBeInTheDocument();
});
});
describe('KeyValuesTable tests', () => {
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('renders a table', () => {
setup();
expect(screen.getByTestId('KeyValueTable')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders a table row for each data element', () => {
setup();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getAllByRole('cell')).toHaveLength(12);
expect(screen.getAllByTestId('KeyValueTable--keyColumn')).toHaveLength(4);
expect(screen.getByRole('row', { name: 'span.kind "client"' })).toBeInTheDocument();
expect(screen.getByRole('row', { name: 'jsonkey { "hello": "world" }' })).toBeInTheDocument();
});
it('renders a single link correctly', () => {
setup({
linksGetter: (array, i) =>
array[i].key === 'span.kind'
? [
{
url: `http://example.com/?kind=${encodeURIComponent(array[i].value)}`,
text: `More info about ${array[i].value}`,
},
]
: [],
} as KeyValuesTableProps);
expect(screen.getByRole('row', { name: 'span.kind More info about client' })).toBeInTheDocument();
});
it('renders a <CopyIcon /> for each data element', () => {
setup();
expect(screen.getAllByRole('button')).toHaveLength(4);
});
});

View File

@@ -1,154 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import jsonMarkup from 'json-markup';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import CopyIcon from '../../common/CopyIcon';
import { TNil } from '../../types';
import { TraceKeyValuePair, TraceLink } from '../../types/trace';
import { ubInlineBlock, uWidth100 } from '../../uberUtilityStyles';
const copyIconClassName = 'copyIcon';
export const getStyles = (theme: GrafanaTheme2) => {
return {
KeyValueTable: css`
label: KeyValueTable;
background: ${autoColor(theme, '#fff')};
border: 1px solid ${autoColor(theme, '#ddd')};
margin-bottom: 0.5rem;
max-height: 450px;
overflow: auto;
`,
body: css`
label: body;
vertical-align: baseline;
`,
row: css`
label: row;
& > td {
padding: 0rem 0.5rem;
height: 30px;
}
&:nth-child(2n) > td {
background: ${autoColor(theme, '#f5f5f5')};
}
&:not(:hover) .${copyIconClassName} {
visibility: hidden;
}
`,
keyColumn: css`
label: keyColumn;
color: ${autoColor(theme, '#888')};
white-space: pre;
width: 125px;
`,
copyColumn: css`
label: copyColumn;
text-align: right;
`,
linkIcon: css`
label: linkIcon;
vertical-align: middle;
font-weight: bold;
`,
};
};
const jsonObjectOrArrayStartRegex = /^(\[|\{)/;
function parseIfComplexJson(value: unknown) {
// if the value is a string representing actual json object or array, then use json-markup
if (typeof value === 'string' && jsonObjectOrArrayStartRegex.test(value)) {
// otherwise just return as is
try {
return JSON.parse(value);
// eslint-disable-next-line no-empty
} catch (_) {}
}
return value;
}
export const LinkValue = (props: { href: string; title?: string; children: React.ReactNode }) => {
return (
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer">
{props.children} <Icon name="external-link-alt" />
</a>
);
};
LinkValue.defaultProps = {
title: '',
};
export type KeyValuesTableProps = {
data: TraceKeyValuePair[];
linksGetter: ((pairs: TraceKeyValuePair[], index: number) => TraceLink[]) | TNil;
};
export default function KeyValuesTable(props: KeyValuesTableProps) {
const { data, linksGetter } = props;
const styles = useStyles2(getStyles);
return (
<div className={cx(styles.KeyValueTable)} data-testid="KeyValueTable">
<table className={uWidth100}>
<tbody className={styles.body}>
{data.map((row, i) => {
const markup = {
__html: jsonMarkup(parseIfComplexJson(row.value)),
};
const jsonTable = <div className={ubInlineBlock} dangerouslySetInnerHTML={markup} />;
const links = linksGetter ? linksGetter(data, i) : null;
let valueMarkup;
if (links && links.length) {
// TODO: handle multiple items
valueMarkup = (
<div>
<LinkValue href={links[0].url} title={links[0].text}>
{jsonTable}
</LinkValue>
</div>
);
} else {
valueMarkup = jsonTable;
}
return (
// `i` is necessary in the key because row.key can repeat
<tr className={styles.row} key={`${row.key}-${i}`}>
<td className={styles.keyColumn} data-testid="KeyValueTable--keyColumn">
{row.key}
</td>
<td>{valueMarkup}</td>
<td className={styles.copyColumn}>
<CopyIcon
className={copyIconClassName}
copyText={JSON.stringify(row, null, 2)}
tooltipTitle="Copy JSON"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,31 +0,0 @@
// 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 { render, screen } from '@testing-library/react';
import React from 'react';
import TextList from './TextList';
describe('<TextList>', () => {
const data = ['client', 'mos-def'];
it('renders without exploding', () => {
expect(() => render(<TextList data={data} />)).not.toThrow();
});
it('renders a table row for each data element', () => {
render(<TextList data={data} />);
expect(screen.getAllByRole('listitem')).toHaveLength(data.length);
});
});

View File

@@ -1,64 +0,0 @@
// 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { useStyles2 } from '@grafana/ui';
const getStyles = () => {
return {
TextList: css`
max-height: 450px;
overflow: auto;
`,
List: css`
width: 100%;
list-style: none;
padding: 0;
margin: 0;
`,
item: css`
padding: 0.25rem 0.5rem;
vertical-align: top;
&:nth-child(2n) {
background: #f5f5f5;
}
`,
};
};
type TextListProps = {
data: string[];
};
export default function TextList(props: TextListProps) {
const { data } = props;
const styles = useStyles2(getStyles);
return (
<div className={cx(styles.TextList)} data-testid="TextList">
<ul className={styles.List}>
{data.map((row, i) => {
return (
// `i` is necessary in the key because row.key can repeat
<li className={styles.item} key={`${i}`}>
{row}
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,181 +0,0 @@
// Copyright (c) 2017 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.
jest.mock('../utils');
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TraceSpanReference } from 'src/types/trace';
import traceGenerator from '../../demo/trace-generators';
import transformTraceData from '../../model/transform-trace-data';
import { formatDuration } from '../utils';
import DetailState from './DetailState';
import SpanDetail, { getAbsoluteTime, SpanDetailProps } from './index';
describe('<SpanDetail>', () => {
// use `transformTraceData` on a fake trace to get a fully processed span
const span = transformTraceData(traceGenerator.trace({ numberOfSpans: 1 }))!.spans[0];
const detailState = new DetailState().toggleLogs().toggleProcess().toggleReferences().toggleTags();
const traceStartTime = 5;
const topOfExploreViewRef = jest.fn();
const props = {
detailState,
span,
traceStartTime,
topOfExploreViewRef,
logItemToggle: jest.fn(),
logsToggle: jest.fn(),
processToggle: jest.fn(),
tagsToggle: jest.fn(),
warningsToggle: jest.fn(),
referencesToggle: jest.fn(),
createFocusSpanLink: jest.fn().mockReturnValue({}),
topOfViewRefType: 'Explore',
};
span.logs = [
{
timestamp: 10,
fields: [
{ key: 'message', value: 'oh the log message' },
{ key: 'something', value: 'else' },
],
},
{
timestamp: 20,
fields: [
{ key: 'message', value: 'oh the next log message' },
{ key: 'more', value: 'stuff' },
],
},
];
span.warnings = ['Warning 1', 'Warning 2'];
span.references = [
{
refType: 'CHILD_OF',
span: {
spanID: 'span2',
traceID: 'trace1',
operationName: 'op1',
process: {
serviceName: 'service1',
},
},
spanID: 'span1',
traceID: 'trace1',
} as TraceSpanReference,
{
refType: 'CHILD_OF',
span: {
spanID: 'span3',
traceID: 'trace1',
operationName: 'op2',
process: {
serviceName: 'service2',
},
},
spanID: 'span4',
traceID: 'trace1',
} as TraceSpanReference,
{
refType: 'CHILD_OF',
span: {
spanID: 'span6',
traceID: 'trace2',
operationName: 'op2',
process: {
serviceName: 'service2',
},
},
spanID: 'span5',
traceID: 'trace2',
} as TraceSpanReference,
];
beforeEach(() => {
jest.mocked(formatDuration).mockReset();
props.tagsToggle.mockReset();
props.processToggle.mockReset();
props.logsToggle.mockReset();
props.logItemToggle.mockReset();
});
it('renders without exploding', () => {
expect(() => render(<SpanDetail {...(props as unknown as SpanDetailProps)} />)).not.toThrow();
});
it('shows the operation name', () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
expect(screen.getByRole('heading', { name: span.operationName })).toBeInTheDocument();
});
it('lists the service name, duration and start time', () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('Service:')).toBeInTheDocument();
expect(screen.getByText('Start Time:')).toBeInTheDocument();
});
it('start time shows the absolute time', () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
const absoluteTime = getAbsoluteTime(span.startTime, 'browser');
expect(
screen.getByText((text) => {
return text.includes(absoluteTime);
})
).toBeInTheDocument();
});
it('renders the span tags', async () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
await userEvent.click(screen.getByRole('switch', { name: /Attributes/ }));
expect(props.tagsToggle).toHaveBeenLastCalledWith(span.spanID);
});
it('renders the process tags', async () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
await userEvent.click(screen.getByRole('switch', { name: /Resource/ }));
expect(props.processToggle).toHaveBeenLastCalledWith(span.spanID);
});
it('renders the logs', async () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
await userEvent.click(screen.getByRole('switch', { name: /Events/ }));
expect(props.logsToggle).toHaveBeenLastCalledWith(span.spanID);
await userEvent.click(screen.getByRole('switch', { name: /oh the log/ }));
expect(props.logItemToggle).toHaveBeenLastCalledWith(span.spanID, props.span.logs[0]);
});
it('renders the warnings', async () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
await userEvent.click(screen.getByRole('switch', { name: /Warnings/ }));
expect(props.warningsToggle).toHaveBeenLastCalledWith(span.spanID);
});
it('renders the references', async () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
await userEvent.click(screen.getByRole('switch', { name: /References/ }));
expect(props.referencesToggle).toHaveBeenLastCalledWith(span.spanID);
});
it('renders deep link URL', () => {
render(<SpanDetail {...(props as unknown as SpanDetailProps)} />);
expect(document.getElementsByTagName('a').length).toBeGreaterThan(1);
});
});

View File

@@ -1,362 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import React from 'react';
import IoLink from 'react-icons/lib/io/link';
import { dateTimeFormat, GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, DataLinkButton, TextArea, useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { Divider } from '../../common/Divider';
import LabeledList from '../../common/LabeledList';
import { SpanLinkFunc, TNil } from '../../types';
import { TraceKeyValuePair, TraceLink, TraceLog, TraceSpan, TraceSpanReference } from '../../types/trace';
import { uAlignIcon, ubM0, ubMb1, ubMy1, ubTxRightAlign } from '../../uberUtilityStyles';
import { TopOfViewRefType } from '../VirtualizedTraceView';
import { formatDuration } from '../utils';
import AccordianKeyValues from './AccordianKeyValues';
import AccordianLogs from './AccordianLogs';
import AccordianReferences from './AccordianReferences';
import AccordianText from './AccordianText';
import DetailState from './DetailState';
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0 1rem;
margin-bottom: 0.25rem;
`,
listWrapper: css`
overflow: hidden;
`,
debugInfo: css`
label: debugInfo;
display: block;
letter-spacing: 0.25px;
margin: 0.5em 0 -0.75em;
text-align: right;
`,
debugLabel: css`
label: debugLabel;
&::before {
color: ${autoColor(theme, '#bbb')};
content: attr(data-label);
}
`,
debugValue: css`
label: debugValue;
background-color: inherit;
border: none;
color: ${autoColor(theme, '#888')};
cursor: pointer;
&:hover {
color: ${autoColor(theme, '#333')};
}
`,
AccordianWarnings: css`
label: AccordianWarnings;
background: ${autoColor(theme, '#fafafa')};
border: 1px solid ${autoColor(theme, '#e4e4e4')};
margin-bottom: 0.25rem;
`,
AccordianWarningsHeader: css`
label: AccordianWarningsHeader;
background: ${autoColor(theme, '#fff7e6')};
padding: 0.25rem 0.5rem;
&:hover {
background: ${autoColor(theme, '#ffe7ba')};
}
`,
AccordianWarningsHeaderOpen: css`
label: AccordianWarningsHeaderOpen;
border-bottom: 1px solid ${autoColor(theme, '#e8e8e8')};
`,
AccordianWarningsLabel: css`
label: AccordianWarningsLabel;
color: ${autoColor(theme, '#d36c08')};
`,
Textarea: css`
word-break: break-all;
white-space: pre;
`,
LinkIcon: css`
font-size: 1.5em;
`,
};
};
export type SpanDetailProps = {
detailState: DetailState;
linksGetter: ((links: TraceKeyValuePair[], index: number) => TraceLink[]) | TNil;
logItemToggle: (spanID: string, log: TraceLog) => void;
logsToggle: (spanID: string) => void;
processToggle: (spanID: string) => void;
span: TraceSpan;
timeZone: TimeZone;
tagsToggle: (spanID: string) => void;
traceStartTime: number;
warningsToggle: (spanID: string) => void;
stackTracesToggle: (spanID: string) => void;
referenceItemToggle: (spanID: string, reference: TraceSpanReference) => void;
referencesToggle: (spanID: string) => void;
createSpanLink?: SpanLinkFunc;
focusedSpanId?: string;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRefType?: TopOfViewRefType;
datasourceType: string;
};
export default function SpanDetail(props: SpanDetailProps) {
const {
detailState,
linksGetter,
logItemToggle,
logsToggle,
processToggle,
span,
tagsToggle,
traceStartTime,
warningsToggle,
stackTracesToggle,
referencesToggle,
referenceItemToggle,
createSpanLink,
createFocusSpanLink,
topOfViewRefType,
datasourceType,
} = props;
const {
isTagsOpen,
isProcessOpen,
logs: logsState,
isWarningsOpen,
references: referencesState,
isStackTracesOpen,
} = detailState;
const {
operationName,
process,
duration,
relativeStartTime,
startTime,
traceID,
spanID,
logs,
tags,
warnings,
references,
stackTraces,
} = span;
const { timeZone } = props;
const overviewItems = [
{
key: 'svc',
label: 'Service:',
value: process.serviceName,
},
{
key: 'duration',
label: 'Duration:',
value: formatDuration(duration),
},
{
key: 'start',
label: 'Start Time:',
value: formatDuration(relativeStartTime) + getAbsoluteTime(startTime, timeZone),
},
...(span.childSpanCount > 0
? [
{
key: 'child_count',
label: 'Child Count:',
value: span.childSpanCount,
},
]
: []),
];
const styles = useStyles2(getStyles);
let logLinkButton: JSX.Element | undefined = undefined;
if (createSpanLink) {
const links = createSpanLink(span);
if (links?.logLinks) {
logLinkButton = (
<DataLinkButton
link={{
...links.logLinks[0],
title: 'Logs for this span',
target: '_blank',
origin: links.logLinks[0].field,
onClick: (event: React.MouseEvent) => {
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
type: 'log',
location: 'spanDetails',
});
links?.logLinks?.[0].onClick?.(event);
},
}}
buttonProps={{ icon: 'gf-logs' }}
/>
);
} else {
logLinkButton = (
<Button
variant="primary"
size="sm"
icon={'gf-logs'}
disabled
tooltip={
'We did not match any variables between the link and this span. Check your configuration or this span attributes.'
}
>
Logs for this span
</Button>
);
}
}
const focusSpanLink = createFocusSpanLink(traceID, spanID);
return (
<div data-testid="span-detail-component">
<div className={styles.header}>
<h2 className={cx(ubM0)}>{operationName}</h2>
<div className={styles.listWrapper}>
<LabeledList className={ubTxRightAlign} divider={true} items={overviewItems} />
</div>
</div>
{logLinkButton}
<Divider className={ubMy1} type={'horizontal'} />
<div>
<div>
<AccordianKeyValues
data={tags}
label="Attributes"
linksGetter={linksGetter}
isOpen={isTagsOpen}
onToggle={() => tagsToggle(spanID)}
/>
{process.tags && (
<AccordianKeyValues
className={ubMb1}
data={process.tags}
label="Resource"
linksGetter={linksGetter}
isOpen={isProcessOpen}
onToggle={() => processToggle(spanID)}
/>
)}
</div>
{logs && logs.length > 0 && (
<AccordianLogs
linksGetter={linksGetter}
logs={logs}
isOpen={logsState.isOpen}
openedItems={logsState.openedItems}
onToggle={() => logsToggle(spanID)}
onItemToggle={(logItem) => logItemToggle(spanID, logItem)}
timestamp={traceStartTime}
/>
)}
{warnings && warnings.length > 0 && (
<AccordianText
className={styles.AccordianWarnings}
headerClassName={styles.AccordianWarningsHeader}
label={<span className={styles.AccordianWarningsLabel}>Warnings</span>}
data={warnings}
isOpen={isWarningsOpen}
onToggle={() => warningsToggle(spanID)}
/>
)}
{stackTraces && stackTraces.length && (
<AccordianText
label="Stack trace"
data={stackTraces}
isOpen={isStackTracesOpen}
TextComponent={(textComponentProps) => {
let text;
if (textComponentProps.data?.length > 1) {
text = textComponentProps.data
.map((stackTrace, index) => `StackTrace ${index + 1}:\n${stackTrace}`)
.join('\n');
} else {
text = textComponentProps.data?.[0];
}
return (
<TextArea
className={styles.Textarea}
style={{ cursor: 'unset' }}
readOnly
cols={10}
rows={10}
value={text}
/>
);
}}
onToggle={() => stackTracesToggle(spanID)}
/>
)}
{references && references.length > 0 && (references.length > 1 || references[0].refType !== 'CHILD_OF') && (
<AccordianReferences
data={references}
isOpen={referencesState.isOpen}
openedItems={referencesState.openedItems}
onToggle={() => referencesToggle(spanID)}
onItemToggle={(reference) => referenceItemToggle(spanID, reference)}
createFocusSpanLink={createFocusSpanLink}
/>
)}
{topOfViewRefType === TopOfViewRefType.Explore && (
<small className={styles.debugInfo}>
<a
{...focusSpanLink}
onClick={(e) => {
// click handling logic copied from react router:
// https://github.com/remix-run/react-router/blob/997b4d67e506d39ac6571cb369d6d2d6b3dda557/packages/react-router-dom/index.tsx#L392-L394s
if (
focusSpanLink.onClick &&
e.button === 0 && // Ignore everything but left clicks
(!e.currentTarget.target || e.currentTarget.target === '_self') && // Let browser handle "target=_blank" etc.
!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) // Ignore clicks with modifier keys
) {
e.preventDefault();
focusSpanLink.onClick(e);
}
}}
>
<IoLink className={cx(uAlignIcon, styles.LinkIcon)}></IoLink>
</a>
<span className={styles.debugLabel} data-label="SpanID:" /> {spanID}
</small>
)}
</div>
</div>
);
}
export const getAbsoluteTime = (startTime: number, timeZone: TimeZone) => {
const dateStr = dateTimeFormat(startTime / 1000, { timeZone, defaultWithMS: true });
const match = dateStr.split(' ');
const absoluteTime = match[1] ? match[1] : dateStr;
return ` (${absoluteTime})`;
};

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { createTheme } from '@grafana/data';
import DetailState from './SpanDetail/DetailState';
import { UnthemedSpanDetailRow, SpanDetailRowProps } from './SpanDetailRow';
const testSpan = {
spanID: 'testSpanID',
traceID: 'testTraceID',
depth: 3,
process: {
serviceName: 'some-service',
tags: [{ key: 'tag-key', value: 'tag-value' }],
},
};
const setup = (propOverrides?: SpanDetailRowProps) => {
const props = {
color: 'some-color',
columnDivision: 0.5,
detailState: new DetailState(),
onDetailToggled: jest.fn(),
isFilteredOut: false,
logItemToggle: jest.fn(),
logsToggle: jest.fn(),
processToggle: jest.fn(),
createFocusSpanLink: jest.fn(),
hoverIndentGuideIds: new Map(),
span: testSpan,
tagsToggle: jest.fn(),
traceStartTime: 1000,
theme: createTheme(),
...propOverrides,
};
return render(<UnthemedSpanDetailRow {...(props as SpanDetailRowProps)} />);
};
describe('SpanDetailRow tests', () => {
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('calls toggle on click', async () => {
const mockToggle = jest.fn();
setup({ onDetailToggled: mockToggle } as unknown as SpanDetailRowProps);
expect(mockToggle).not.toHaveBeenCalled();
const detailRow = screen.getByTestId('detail-row-expanded-accent');
await userEvent.click(detailRow);
expect(mockToggle).toHaveBeenCalled();
});
it('renders the span tree offset', () => {
setup();
expect(screen.getByTestId('SpanTreeOffset--indentGuide')).toBeInTheDocument();
});
it('renders the SpanDetail', () => {
setup();
expect(screen.getByTestId('span-detail-component')).toBeInTheDocument();
});
});

View File

@@ -1,186 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import classNames from 'classnames';
import React from 'react';
import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data';
import { Button, clearButtonStyles, stylesFactory, withTheme2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { SpanLinkFunc } from '../types';
import { TraceLog, TraceSpan, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace';
import SpanDetail from './SpanDetail';
import DetailState from './SpanDetail/DetailState';
import SpanTreeOffset from './SpanTreeOffset';
import TimelineRow from './TimelineRow';
import { TopOfViewRefType } from './VirtualizedTraceView';
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
expandedAccent: css`
cursor: pointer;
height: 100%;
overflow: hidden;
position: absolute;
width: 100%;
&::before {
border-left: 4px solid;
pointer-events: none;
width: 1000px;
}
&::after {
border-right: 1000px solid;
border-color: inherit;
cursor: pointer;
opacity: 0.2;
}
/* border-color inherit must come AFTER other border declarations for accent */
&::before,
&::after {
border-color: inherit;
content: ' ';
position: absolute;
height: 100%;
}
&:hover::after {
opacity: 0.35;
}
`,
infoWrapper: css`
label: infoWrapper;
border: 1px solid ${autoColor(theme, '#d3d3d3')};
border-top: 3px solid;
padding: 0.75rem;
`,
};
});
export type SpanDetailRowProps = {
color: string;
columnDivision: number;
detailState: DetailState;
onDetailToggled: (spanID: string) => void;
linksGetter: (span: TraceSpan, links: TraceKeyValuePair[], index: number) => TraceLink[];
logItemToggle: (spanID: string, log: TraceLog) => void;
logsToggle: (spanID: string) => void;
processToggle: (spanID: string) => void;
referenceItemToggle: (spanID: string, reference: TraceSpanReference) => void;
referencesToggle: (spanID: string) => void;
warningsToggle: (spanID: string) => void;
stackTracesToggle: (spanID: string) => void;
span: TraceSpan;
timeZone: TimeZone;
tagsToggle: (spanID: string) => void;
traceStartTime: number;
hoverIndentGuideIds: Set<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: GrafanaTheme2;
createSpanLink?: SpanLinkFunc;
focusedSpanId?: string;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRefType?: TopOfViewRefType;
datasourceType: string;
};
export class UnthemedSpanDetailRow extends React.PureComponent<SpanDetailRowProps> {
_detailToggle = () => {
this.props.onDetailToggled(this.props.span.spanID);
};
_linksGetter = (items: TraceKeyValuePair[], itemIndex: number) => {
const { linksGetter, span } = this.props;
return linksGetter(span, items, itemIndex);
};
render() {
const {
color,
columnDivision,
detailState,
logItemToggle,
logsToggle,
processToggle,
referenceItemToggle,
referencesToggle,
warningsToggle,
stackTracesToggle,
span,
timeZone,
tagsToggle,
traceStartTime,
hoverIndentGuideIds,
addHoverIndentGuideId,
removeHoverIndentGuideId,
theme,
createSpanLink,
focusedSpanId,
createFocusSpanLink,
topOfViewRefType,
datasourceType,
} = this.props;
const styles = getStyles(theme);
return (
<TimelineRow>
<TimelineRow.Cell width={columnDivision} style={{ overflow: 'hidden' }}>
<SpanTreeOffset
span={span}
showChildrenIcon={false}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
/>
<Button
fill="text"
onClick={this._detailToggle}
className={classNames(styles.expandedAccent, clearButtonStyles(theme))}
style={{ borderColor: color }}
data-testid="detail-row-expanded-accent"
></Button>
</TimelineRow.Cell>
<TimelineRow.Cell width={1 - columnDivision}>
<div className={styles.infoWrapper} style={{ borderTopColor: color }}>
<SpanDetail
detailState={detailState}
linksGetter={this._linksGetter}
logItemToggle={logItemToggle}
logsToggle={logsToggle}
processToggle={processToggle}
referenceItemToggle={referenceItemToggle}
referencesToggle={referencesToggle}
warningsToggle={warningsToggle}
stackTracesToggle={stackTracesToggle}
span={span}
timeZone={timeZone}
tagsToggle={tagsToggle}
traceStartTime={traceStartTime}
createSpanLink={createSpanLink}
focusedSpanId={focusedSpanId}
createFocusSpanLink={createFocusSpanLink}
topOfViewRefType={topOfViewRefType}
datasourceType={datasourceType}
/>
</div>
</TimelineRow.Cell>
</TimelineRow>
);
}
}
export default withTheme2(UnthemedSpanDetailRow);

View File

@@ -1,155 +0,0 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { config, reportInteraction } from '@grafana/runtime';
import { useStyles2, MenuGroup, MenuItem, Icon, ContextMenu } from '@grafana/ui';
import { SpanLinks } from '../types/links';
interface SpanLinksProps {
links: SpanLinks;
datasourceType: string;
}
const renderMenuItems = (
links: SpanLinks,
styles: ReturnType<typeof getStyles>,
closeMenu: () => void,
datasourceType: string
) => {
return (
<>
{!!links.logLinks?.length ? (
<MenuGroup label="Logs">
{links.logLinks.map((link, i) => (
<MenuItem
key={i}
label="Logs for this span"
onClick={
link.onClick
? (event) => {
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
type: 'log',
location: 'menu',
});
event?.preventDefault();
link.onClick!(event);
closeMenu();
}
: undefined
}
url={link.href}
className={styles.menuItem}
/>
))}
</MenuGroup>
) : null}
{!!links.metricLinks?.length ? (
<MenuGroup label="Metrics">
{links.metricLinks.map((link, i) => (
<MenuItem
key={i}
label={link.title ?? 'Metrics for this span'}
onClick={
link.onClick
? (event) => {
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
type: 'metric',
location: 'menu',
});
event?.preventDefault();
link.onClick!(event);
closeMenu();
}
: undefined
}
url={link.href}
className={styles.menuItem}
/>
))}
</MenuGroup>
) : null}
{!!links.traceLinks?.length ? (
<MenuGroup label="Traces">
{links.traceLinks.map((link, i) => (
<MenuItem
key={i}
label={link.title ?? 'View linked span'}
onClick={
link.onClick
? (event) => {
reportInteraction('grafana_traces_trace_view_span_link_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
type: 'trace',
location: 'menu',
});
event?.preventDefault();
link.onClick!(event);
closeMenu();
}
: undefined
}
url={link.href}
className={styles.menuItem}
/>
))}
</MenuGroup>
) : null}
</>
);
};
export const SpanLinksMenu = ({ links, datasourceType }: SpanLinksProps) => {
const styles = useStyles2(getStyles);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const closeMenu = () => setIsMenuOpen(false);
return (
<div data-testid="SpanLinksMenu">
<button
onClick={(e) => {
setIsMenuOpen(true);
setMenuPosition({
x: e.pageX,
y: e.pageY,
});
}}
className={styles.button}
>
<Icon name="link" className={styles.button} />
</button>
{isMenuOpen ? (
<ContextMenu
onClose={() => setIsMenuOpen(false)}
renderMenuItems={() => renderMenuItems(links, styles, closeMenu, datasourceType)}
focusOnOpen={true}
x={menuPosition.x}
y={menuPosition.y}
/>
) : null}
</div>
);
};
const getStyles = () => {
return {
button: css`
background: transparent;
border: none;
padding: 0;
margin: 0 3px 0 0;
`,
menuItem: css`
max-width: 60ch;
overflow: hidden;
`,
};
};

View File

@@ -1,137 +0,0 @@
// Copyright (c) 2018 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TraceSpan } from 'src/types/trace';
import { createTheme } from '@grafana/data';
import spanAncestorIdsSpy from '../utils/span-ancestor-ids';
import SpanTreeOffset, { getStyles, TProps } from './SpanTreeOffset';
jest.mock('../utils/span-ancestor-ids');
describe('SpanTreeOffset', () => {
const ownSpanID = 'ownSpanID';
const parentSpanID = 'parentSpanID';
const rootSpanID = 'rootSpanID';
const specialRootID = 'root';
let props: TProps;
beforeEach(() => {
// Mock implementation instead of Mock return value so that each call returns a new array (like normal)
jest.mocked(spanAncestorIdsSpy).mockImplementation(() => [parentSpanID, rootSpanID]);
props = {
addHoverIndentGuideId: jest.fn(),
hoverIndentGuideIds: new Set(),
removeHoverIndentGuideId: jest.fn(),
span: {
hasChildren: false,
spanID: ownSpanID,
} as TraceSpan,
} as unknown as TProps;
});
describe('.SpanTreeOffset--indentGuide', () => {
it('renders only one SpanTreeOffset--indentGuide for entire trace if span has no ancestors', () => {
jest.mocked(spanAncestorIdsSpy).mockReturnValue([]);
render(<SpanTreeOffset {...props} />);
const indentGuide = screen.getByTestId('SpanTreeOffset--indentGuide');
expect(indentGuide).toBeInTheDocument();
expect(indentGuide).toHaveAttribute('data-ancestor-id', specialRootID);
});
it('renders one SpanTreeOffset--indentGuide per ancestor span, plus one for entire trace', () => {
render(<SpanTreeOffset {...props} />);
const indentGuides = screen.getAllByTestId('SpanTreeOffset--indentGuide');
expect(indentGuides.length).toBe(3);
expect(indentGuides[0]).toHaveAttribute('data-ancestor-id', specialRootID);
expect(indentGuides[1]).toHaveAttribute('data-ancestor-id', rootSpanID);
expect(indentGuides[2]).toHaveAttribute('data-ancestor-id', parentSpanID);
});
it('adds .is-active to correct indentGuide', () => {
props.hoverIndentGuideIds = new Set([parentSpanID]);
render(<SpanTreeOffset {...props} />);
const styles = getStyles(createTheme());
const activeIndentGuide = document.querySelector(`.${styles.indentGuideActive}`);
expect(activeIndentGuide).toBeInTheDocument();
expect(activeIndentGuide).toHaveAttribute('data-ancestor-id', parentSpanID);
});
it('calls props.addHoverIndentGuideId on mouse enter', async () => {
render(<SpanTreeOffset {...props} />);
const span = document.querySelector(`[data-ancestor-id=${parentSpanID}]`);
await userEvent.hover(span!);
expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1);
expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID);
});
it('calls props.removeHoverIndentGuideId on mouse leave', async () => {
render(<SpanTreeOffset {...props} />);
const span = document.querySelector(`[data-ancestor-id=${parentSpanID}]`);
await userEvent.unhover(span!);
expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1);
expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(parentSpanID);
});
});
describe('icon', () => {
beforeEach(() => {
props = { ...props, span: { ...props.span, hasChildren: true } };
});
it('does not render icon if props.span.hasChildren is false', () => {
props.span.hasChildren = false;
render(<SpanTreeOffset {...props} />);
expect(screen.queryByTestId('icon-wrapper')).not.toBeInTheDocument();
});
it('does not render icon if props.span.hasChildren is true and showChildrenIcon is false', () => {
props.showChildrenIcon = false;
render(<SpanTreeOffset {...props} />);
expect(screen.queryByTestId('icon-wrapper')).not.toBeInTheDocument();
});
it('renders arrow-right if props.span.hasChildren is true and props.childrenVisible is false', () => {
render(<SpanTreeOffset {...props} />);
expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument();
});
it('renders arrow-down if props.span.hasChildren is true and props.childrenVisible is true', () => {
props.childrenVisible = true;
render(<SpanTreeOffset {...props} />);
expect(screen.getByTestId('icon-arrow-down')).toBeInTheDocument();
});
it('calls props.addHoverIndentGuideId on mouse enter', async () => {
render(<SpanTreeOffset {...props} />);
const icon = screen.getByTestId('icon-wrapper');
await userEvent.hover(icon);
expect(props.addHoverIndentGuideId).toHaveBeenCalledTimes(1);
expect(props.addHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID);
});
it('calls props.removeHoverIndentGuideId on mouse leave', async () => {
render(<SpanTreeOffset {...props} />);
const icon = screen.getByTestId('icon-wrapper');
await userEvent.unhover(icon);
expect(props.removeHoverIndentGuideId).toHaveBeenCalledTimes(1);
expect(props.removeHoverIndentGuideId).toHaveBeenCalledWith(ownSpanID);
});
});
});

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import { get as _get } from 'lodash';
import React from 'react';
import IoChevronRight from 'react-icons/lib/io/chevron-right';
import IoIosArrowDown from 'react-icons/lib/io/ios-arrow-down';
import { GrafanaTheme2 } from '@grafana/data';
import { stylesFactory, withTheme2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { TraceSpan } from '../types/trace';
import spanAncestorIds from '../utils/span-ancestor-ids';
export const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
SpanTreeOffset: css`
label: SpanTreeOffset;
color: ${autoColor(theme, '#000')};
position: relative;
`,
SpanTreeOffsetParent: css`
label: SpanTreeOffsetParent;
&:hover {
cursor: pointer;
}
`,
indentGuide: css`
label: indentGuide;
/* The size of the indentGuide is based off of the iconWrapper */
padding-right: calc(0.5rem + 12px);
height: 100%;
border-left: 3px solid transparent;
display: inline-flex;
&::before {
content: '';
padding-left: 1px;
background-color: ${autoColor(theme, 'lightgrey')};
}
`,
indentGuideActive: css`
label: indentGuideActive;
border-color: ${autoColor(theme, 'darkgrey')};
&::before {
background-color: transparent;
}
`,
iconWrapper: css`
label: iconWrapper;
position: absolute;
right: 0.25rem;
`,
};
});
export type TProps = {
childrenVisible?: boolean;
onClick?: () => void;
span: TraceSpan;
showChildrenIcon?: boolean;
hoverIndentGuideIds: Set<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: GrafanaTheme2;
};
export class UnthemedSpanTreeOffset extends React.PureComponent<TProps> {
static displayName = 'UnthemedSpanTreeOffset';
ancestorIds: string[];
static defaultProps = {
childrenVisible: false,
showChildrenIcon: true,
};
constructor(props: TProps) {
super(props);
this.ancestorIds = spanAncestorIds(props.span);
// Some traces have multiple root-level spans, this connects them all under one guideline and adds the
// necessary padding for the collapse icon on root-level spans.
this.ancestorIds.push('root');
this.ancestorIds.reverse();
}
/**
* If the mouse leaves to anywhere except another span with the same ancestor id, this span's ancestor id is
* removed from the set of hoverIndentGuideIds.
*
* @param {Object} event - React Synthetic event tied to mouseleave. Includes the related target which is
* the element the user is now hovering.
* @param {string} ancestorId - The span id that the user was hovering over.
*/
handleMouseLeave = (event: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
if (
!(event.relatedTarget instanceof HTMLSpanElement) ||
_get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
) {
this.props.removeHoverIndentGuideId(ancestorId);
}
};
/**
* If the mouse entered this span from anywhere except another span with the same ancestor id, this span's
* ancestorId is added to the set of hoverIndentGuideIds.
*
* @param {Object} event - React Synthetic event tied to mouseenter. Includes the related target which is
* the last element the user was hovering.
* @param {string} ancestorId - The span id that the user is now hovering over.
*/
handleMouseEnter = (event: React.MouseEvent<HTMLSpanElement>, ancestorId: string) => {
if (
!(event.relatedTarget instanceof HTMLSpanElement) ||
_get(event, 'relatedTarget.dataset.ancestorId') !== ancestorId
) {
this.props.addHoverIndentGuideId(ancestorId);
}
};
render() {
const { childrenVisible, onClick, showChildrenIcon, span, theme } = this.props;
const { hasChildren, spanID } = span;
const wrapperProps = hasChildren ? { onClick, role: 'switch', 'aria-checked': childrenVisible } : null;
const icon =
showChildrenIcon &&
hasChildren &&
(childrenVisible ? (
<IoIosArrowDown data-testid="icon-arrow-down" />
) : (
<IoChevronRight data-testid="icon-arrow-right" />
));
const styles = getStyles(theme);
return (
<span className={cx(styles.SpanTreeOffset, { [styles.SpanTreeOffsetParent]: hasChildren })} {...wrapperProps}>
{this.ancestorIds.map((ancestorId) => (
<span
key={ancestorId}
className={cx(styles.indentGuide, {
[styles.indentGuideActive]: this.props.hoverIndentGuideIds.has(ancestorId),
})}
data-ancestor-id={ancestorId}
data-testid="SpanTreeOffset--indentGuide"
onMouseEnter={(event) => this.handleMouseEnter(event, ancestorId)}
onMouseLeave={(event) => this.handleMouseLeave(event, ancestorId)}
/>
))}
{icon && (
<span
className={styles.iconWrapper}
onMouseEnter={(event) => this.handleMouseEnter(event, spanID)}
onMouseLeave={(event) => this.handleMouseLeave(event, spanID)}
data-testid="icon-wrapper"
>
{icon}
</span>
)}
</span>
);
}
}
export default withTheme2(UnthemedSpanTreeOffset);

View File

@@ -1,24 +0,0 @@
// Copyright (c) 2017 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 { render } from '@testing-library/react';
import React from 'react';
import Ticks from './Ticks';
describe('<Ticks>', () => {
it('renders without exploding', () => {
expect(() => render(<Ticks endTime={200} numTicks={5} showLabels startTime={100} />)).not.toThrow();
});
});

View File

@@ -1,103 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../Theme';
import { TNil } from '../types';
import { formatDuration } from './utils';
const getStyles = (theme: GrafanaTheme2) => {
return {
Ticks: css`
label: Ticks;
pointer-events: none;
`,
TicksTick: css`
label: TicksTick;
position: absolute;
height: 100%;
width: 1px;
background: ${autoColor(theme, '#d8d8d8')};
&:last-child {
width: 0;
}
`,
TicksTickLabel: css`
label: TicksTickLabel;
left: 0.25rem;
position: absolute;
`,
TicksTickLabelEndAnchor: css`
label: TicksTickLabelEndAnchor;
left: initial;
right: 0.25rem;
`,
};
};
type TicksProps = {
endTime?: number | TNil;
numTicks: number;
showLabels?: boolean | TNil;
startTime?: number | TNil;
};
export default function Ticks(props: TicksProps) {
const { endTime, numTicks, showLabels, startTime } = props;
let labels: undefined | string[];
if (showLabels) {
labels = [];
const viewingDuration = (endTime || 0) - (startTime || 0);
for (let i = 0; i < numTicks; i++) {
const durationAtTick = (startTime || 0) + (i / (numTicks - 1)) * viewingDuration;
labels.push(formatDuration(durationAtTick));
}
}
const styles = useStyles2(getStyles);
const ticks: React.ReactNode[] = [];
for (let i = 0; i < numTicks; i++) {
const portion = i / (numTicks - 1);
ticks.push(
<div
data-testid="TicksID"
key={portion}
className={styles.TicksTick}
style={{
left: `${portion * 100}%`,
}}
>
{labels && (
<span className={cx(styles.TicksTickLabel, { [styles.TicksTickLabelEndAnchor]: portion >= 1 })}>
{labels[i]}
</span>
)}
</div>
);
}
return <div className={styles.Ticks}>{ticks}</div>;
}
Ticks.defaultProps = {
endTime: null,
showLabels: null,
startTime: null,
};

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TimelineCollapser } from './TimelineCollapser';
const setup = () => {
const props = {
onCollapseAll: () => {},
onCollapseOne: () => {},
onExpandAll: () => {},
onExpandOne: () => {},
};
return render(<TimelineCollapser {...props} />);
};
describe('TimelineCollapser test', () => {
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('renders correctly', () => {
setup();
expect(screen.getByTestId('TimelineCollapser')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Expand All' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse All' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Expand +1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse +1' })).toBeInTheDocument();
});
});

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import React from 'react';
import { IconButton, useStyles2 } from '@grafana/ui';
const getStyles = () => {
return {
TimelineCollapser: css`
align-items: center;
display: flex;
flex: none;
justify-content: center;
margin-right: 0.5rem;
`,
};
};
type CollapserProps = {
onCollapseAll: () => void;
onCollapseOne: () => void;
onExpandOne: () => void;
onExpandAll: () => void;
};
export function TimelineCollapser(props: CollapserProps) {
const { onExpandAll, onExpandOne, onCollapseAll, onCollapseOne } = props;
const styles = useStyles2(getStyles);
return (
<div className={styles.TimelineCollapser} data-testid="TimelineCollapser">
<IconButton tooltip="Expand +1" size="xl" tooltipPlacement="top" name="angle-down" onClick={onExpandOne} />
<IconButton tooltip="Collapse +1" size="xl" tooltipPlacement="top" name="angle-right" onClick={onCollapseOne} />
<IconButton
tooltip="Expand All"
size="xl"
tooltipPlacement="top"
name="angle-double-down"
onClick={onExpandAll}
/>
<IconButton
tooltip="Collapse All"
size="xl"
tooltipPlacement="top"
name="angle-double-right"
onClick={onCollapseAll}
/>
</div>
);
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2017 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 { fireEvent, render, screen } from '@testing-library/react';
import cx from 'classnames';
import React from 'react';
import TimelineColumnResizer, { getStyles, TimelineColumnResizerProps } from './TimelineColumnResizer';
const mockOnChange = jest.fn();
describe('<TimelineColumnResizer>', () => {
const props: TimelineColumnResizerProps = {
min: 0.1,
max: 0.9,
onChange: mockOnChange,
position: 0.5,
columnResizeHandleHeight: 10,
};
beforeEach(() => {
mockOnChange.mockReset();
render(<TimelineColumnResizer {...props} />);
});
it('renders without exploding', () => {
expect(screen.getByTestId('TimelineColumnResizer')).toBeTruthy();
expect(screen.getByTestId('TimelineColumnResizer--gripIcon')).toBeTruthy();
expect(screen.getByTestId('TimelineColumnResizer--dragger')).toBeTruthy();
});
it('does not render a dragging indicator when not dragging', () => {
const styles = getStyles();
expect(screen.getByTestId('TimelineColumnResizer--dragger')).toHaveStyle(`right: ${undefined}`);
expect(screen.getByTestId('TimelineColumnResizer--dragger')).toHaveClass(styles.dragger);
});
it('renders a dragging indicator when dragging', () => {
const styles = getStyles();
fireEvent.mouseDown(screen.getByTestId('TimelineColumnResizer--dragger'), { clientX: 0 });
fireEvent.mouseMove(screen.getByTestId('TimelineColumnResizer--dragger'), { clientX: -5 });
expect(screen.getByTestId('TimelineColumnResizer--dragger')).toHaveClass(
cx(styles.dragger, styles.draggerDragging, styles.draggerDraggingLeft)
);
});
});

View File

@@ -1,218 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { stylesFactory } from '@grafana/ui';
import { TNil } from '../../types';
import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager';
export const getStyles = stylesFactory(() => {
return {
TimelineColumnResizer: css`
left: 0;
position: absolute;
right: 0;
top: 0;
`,
wrapper: css`
bottom: 0;
position: absolute;
top: 0;
`,
dragger: css`
border-left: 2px solid transparent;
cursor: col-resize;
height: 5000px;
margin-left: -1px;
position: absolute;
top: 0;
width: 1px;
z-index: 10;
&:hover {
border-left: 2px solid rgba(0, 0, 0, 0.3);
}
&::before {
position: absolute;
top: 0;
bottom: 0;
left: -8px;
right: 0;
content: ' ';
}
`,
draggerDragging: css`
background: rgba(136, 0, 136, 0.05);
width: unset;
&::before {
left: -2000px;
right: -2000px;
}
`,
draggerDraggingLeft: css`
border-left: 2px solid #808;
border-right: 1px solid #999;
`,
draggerDraggingRight: css`
border-left: 1px solid #999;
border-right: 2px solid #808;
`,
gripIcon: css`
position: absolute;
top: 0;
bottom: 0;
&::before,
&::after {
border-right: 1px solid #ccc;
content: ' ';
height: 9px;
position: absolute;
right: 9px;
top: 25px;
}
&::after {
right: 5px;
}
`,
gripIconDragging: css`
&::before,
&::after {
border-right: 1px solid rgba(136, 0, 136, 0.5);
}
`,
};
});
export type TimelineColumnResizerProps = {
min: number;
max: number;
onChange: (newSize: number) => void;
position: number;
columnResizeHandleHeight: number;
};
type TimelineColumnResizerState = {
dragPosition: number | TNil;
};
export default class TimelineColumnResizer extends React.PureComponent<
TimelineColumnResizerProps,
TimelineColumnResizerState
> {
state: TimelineColumnResizerState;
_dragManager: DraggableManager;
_rootElm: Element | TNil;
constructor(props: TimelineColumnResizerProps) {
super(props);
this._dragManager = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleDragEnd,
onDragMove: this._handleDragUpdate,
onDragStart: this._handleDragUpdate,
});
this._rootElm = undefined;
this.state = {
dragPosition: null,
};
}
componentWillUnmount() {
this._dragManager.dispose();
}
_setRootElm = (elm: Element | TNil) => {
this._rootElm = elm;
};
_getDraggingBounds = (): DraggableBounds => {
if (!this._rootElm) {
throw new Error('invalid state');
}
const { left: clientXLeft, width } = this._rootElm.getBoundingClientRect();
const { min, max } = this.props;
return {
clientXLeft,
width,
maxValue: max,
minValue: min,
};
};
_handleDragUpdate = ({ value }: DraggingUpdate) => {
this.setState({ dragPosition: value });
};
_handleDragEnd = ({ manager, value }: DraggingUpdate) => {
manager.resetBounds();
this.setState({ dragPosition: null });
this.props.onChange(value);
};
render() {
let left;
let draggerStyle: React.CSSProperties;
const { position, columnResizeHandleHeight } = this.props;
const { dragPosition } = this.state;
left = `${position * 100}%`;
const gripStyle = { left };
let isDraggingLeft = false;
let isDraggingRight = false;
const styles = getStyles();
if (this._dragManager.isDragging() && this._rootElm && dragPosition != null) {
isDraggingLeft = dragPosition < position;
isDraggingRight = dragPosition > position;
left = `${dragPosition * 100}%`;
// Draw a highlight from the current dragged position back to the original
// position, e.g. highlight the change. Draw the highlight via `left` and
// `right` css styles (simpler than using `width`).
const draggerLeft = `${Math.min(position, dragPosition) * 100}%`;
// subtract 1px for draggerRight to deal with the right border being off
// by 1px when dragging left
const draggerRight = `calc(${(1 - Math.max(position, dragPosition)) * 100}% - 1px)`;
draggerStyle = { left: draggerLeft, right: draggerRight };
} else {
draggerStyle = gripStyle;
}
draggerStyle.height = columnResizeHandleHeight;
const isDragging = isDraggingLeft || isDraggingRight;
return (
<div className={styles.TimelineColumnResizer} ref={this._setRootElm} data-testid="TimelineColumnResizer">
<div
className={cx(styles.gripIcon, isDragging && styles.gripIconDragging)}
style={gripStyle}
data-testid="TimelineColumnResizer--gripIcon"
/>
<div
aria-hidden
className={cx(
styles.dragger,
isDragging && styles.draggerDragging,
isDraggingRight && styles.draggerDraggingRight,
isDraggingLeft && styles.draggerDraggingLeft
)}
onMouseDown={this._dragManager.handleMouseDown}
style={draggerStyle}
data-testid="TimelineColumnResizer--dragger"
/>
</div>
);
}
}

View File

@@ -1,89 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import TimelineHeaderRow, { TimelineHeaderRowProps } from './TimelineHeaderRow';
const nameColumnWidth = 0.25;
const setup = () => {
const props = {
nameColumnWidth,
duration: 1234,
numTicks: 5,
onCollapseAll: () => {},
onCollapseOne: () => {},
onColummWidthChange: () => {},
onExpandAll: () => {},
onExpandOne: () => {},
updateNextViewRangeTime: () => {},
updateViewRangeTime: () => {},
viewRangeTime: {
current: [0.1, 0.9],
},
};
return render(<TimelineHeaderRow {...(props as unknown as TimelineHeaderRowProps)} />);
};
describe('TimelineHeaderRow', () => {
it('renders without exploding', () => {
expect(() => setup()).not.toThrow();
});
it('renders the title', () => {
setup();
expect(screen.getByRole('heading', { name: 'Service & Operation' }));
});
it('renders the collapser controls', () => {
setup();
expect(screen.getByRole('button', { name: 'Expand All' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse All' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Expand +1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse +1' })).toBeInTheDocument();
});
it('renders the resizer controls', () => {
setup();
expect(screen.getByTestId('TimelineColumnResizer')).toBeInTheDocument();
expect(screen.getByTestId('TimelineColumnResizer--dragger')).toBeInTheDocument();
expect(screen.getByTestId('TimelineColumnResizer--gripIcon')).toBeInTheDocument();
});
it('propagates the name column width', () => {
setup();
const timelineCells = screen.queryAllByTestId('TimelineRowCell');
expect(timelineCells).toHaveLength(2);
expect(getComputedStyle(timelineCells[0]).maxWidth).toBe(`${nameColumnWidth * 100}%`);
expect(getComputedStyle(timelineCells[1]).maxWidth).toBe(`${(1 - nameColumnWidth) * 100}%`);
});
it('renders the TimelineViewingLayer', () => {
setup();
expect(screen.getByTestId('TimelineViewingLayer')).toBeInTheDocument();
});
it('renders the Ticks', () => {
setup();
expect(screen.getAllByTestId('TicksID')).toHaveLength(5);
});
});

View File

@@ -1,120 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../../Theme';
import { ubFlex, ubPx2 } from '../../uberUtilityStyles';
import Ticks from '../Ticks';
import TimelineRow from '../TimelineRow';
import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types';
import { TimelineCollapser } from './TimelineCollapser';
import TimelineColumnResizer from './TimelineColumnResizer';
import TimelineViewingLayer from './TimelineViewingLayer';
const getStyles = (theme: GrafanaTheme2) => {
return {
TimelineHeaderRow: css`
label: TimelineHeaderRow;
background: ${autoColor(theme, '#ececec')};
border-bottom: 1px solid ${autoColor(theme, '#ccc')};
height: 38px;
line-height: 38px;
width: 100%;
z-index: 4;
position: relative;
`,
TimelineHeaderRowTitle: css`
label: TimelineHeaderRowTitle;
flex: 1;
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
`,
TimelineHeaderWrapper: css`
label: TimelineHeaderWrapper;
align-items: center;
`,
};
};
export type TimelineHeaderRowProps = {
duration: number;
nameColumnWidth: number;
numTicks: number;
onCollapseAll: () => void;
onCollapseOne: () => void;
onColummWidthChange: (width: number) => void;
onExpandAll: () => void;
onExpandOne: () => void;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRangeTime: ViewRangeTime;
columnResizeHandleHeight: number;
};
export default function TimelineHeaderRow(props: TimelineHeaderRowProps) {
const {
duration,
nameColumnWidth,
numTicks,
onCollapseAll,
onCollapseOne,
onColummWidthChange,
onExpandAll,
onExpandOne,
updateViewRangeTime,
updateNextViewRangeTime,
viewRangeTime,
columnResizeHandleHeight,
} = props;
const [viewStart, viewEnd] = viewRangeTime.current;
const styles = useStyles2(getStyles);
return (
<TimelineRow className={styles.TimelineHeaderRow} data-testid="TimelineHeaderRow">
<TimelineRow.Cell className={cx(ubFlex, ubPx2, styles.TimelineHeaderWrapper)} width={nameColumnWidth}>
<h4 className={styles.TimelineHeaderRowTitle}>Service &amp; Operation</h4>
<TimelineCollapser
onCollapseAll={onCollapseAll}
onExpandAll={onExpandAll}
onCollapseOne={onCollapseOne}
onExpandOne={onExpandOne}
/>
</TimelineRow.Cell>
<TimelineRow.Cell width={1 - nameColumnWidth}>
<TimelineViewingLayer
boundsInvalidator={nameColumnWidth}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
viewRangeTime={viewRangeTime}
/>
<Ticks numTicks={numTicks} startTime={viewStart * duration} endTime={viewEnd * duration} showLabels />
</TimelineRow.Cell>
<TimelineColumnResizer
columnResizeHandleHeight={columnResizeHandleHeight}
position={nameColumnWidth}
onChange={onColummWidthChange}
min={0.2}
max={0.85}
/>
</TimelineRow>
);
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { ViewRangeTime } from '../types';
import TimelineViewingLayer, { TimelineViewingLayerProps } from './TimelineViewingLayer';
describe('<TimelineViewingLayer>', () => {
const viewStart = 0.25;
const viewEnd = 0.9;
let props: TimelineViewingLayerProps = {
boundsInvalidator: Math.random(),
updateNextViewRangeTime: jest.fn(),
updateViewRangeTime: jest.fn(),
viewRangeTime: {
current: [viewStart, viewEnd] as [number, number],
},
};
it('renders without exploding', () => {
render(<TimelineViewingLayer {...props} />);
expect(screen.getByTestId('TimelineViewingLayer')).toBeTruthy();
});
describe('render()', () => {
it('renders nothing without a nextViewRangeTime', () => {
render(<TimelineViewingLayer {...props} />);
expect(screen.queryByTestId('TimelineViewingLayer--cursorGuide')).not.toBeInTheDocument();
});
});
it('renders the cursor when it is the only non-current value set', () => {
const cursor = viewStart + 0.5 * (viewEnd - viewStart);
const baseViewRangeTime = { ...props.viewRangeTime, cursor };
props = { ...props, viewRangeTime: baseViewRangeTime };
render(<TimelineViewingLayer {...props} />);
expect(screen.queryByTestId('TimelineViewingLayer--cursorGuide')).toBeInTheDocument();
});
it('does not render the cursor when shiftStart, shiftEnd, or reframe are present', () => {
const cursor = viewStart + 0.5 * (viewEnd - viewStart);
const baseViewRangeTime = { ...props.viewRangeTime, cursor };
let viewRangeTime: ViewRangeTime = {
...baseViewRangeTime,
shiftStart: cursor,
shiftEnd: cursor,
reframe: { anchor: cursor, shift: cursor },
};
props = { ...props, viewRangeTime };
render(<TimelineViewingLayer {...props} />);
expect(screen.queryByTestId('TimelineViewingLayer--cursorGuide')).not.toBeInTheDocument();
viewRangeTime = { ...baseViewRangeTime, shiftEnd: cursor };
props = { ...props, viewRangeTime };
render(<TimelineViewingLayer {...props} />);
expect(screen.queryByTestId('TimelineViewingLayer--cursorGuide')).not.toBeInTheDocument();
viewRangeTime = { ...baseViewRangeTime, reframe: { anchor: cursor, shift: cursor } };
props = { ...props, viewRangeTime };
render(<TimelineViewingLayer {...props} />);
expect(screen.queryByTestId('TimelineViewingLayer--cursorGuide')).not.toBeInTheDocument();
});
});

View File

@@ -1,283 +0,0 @@
// Copyright (c) 2017 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 { css, cx } from '@emotion/css';
import * as React from 'react';
import { stylesFactory } from '@grafana/ui';
import { TNil } from '../../types';
import DraggableManager, { DraggableBounds, DraggingUpdate } from '../../utils/DraggableManager';
import { TUpdateViewRangeTimeFunction, ViewRangeTime, ViewRangeTimeUpdate } from '../types';
// exported for testing
export const getStyles = stylesFactory(() => {
return {
TimelineViewingLayer: css`
label: TimelineViewingLayer;
bottom: 0;
cursor: vertical-text;
left: 0;
position: absolute;
right: 0;
top: 0;
`,
TimelineViewingLayerCursorGuide: css`
label: TimelineViewingLayerCursorGuide;
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 1px;
background-color: red;
`,
TimelineViewingLayerDragged: css`
label: TimelineViewingLayerDragged;
position: absolute;
top: 0;
bottom: 0;
`,
TimelineViewingLayerDraggedDraggingLeft: css`
label: TimelineViewingLayerDraggedDraggingLeft;
border-left: 1px solid;
`,
TimelineViewingLayerDraggedDraggingRight: css`
label: TimelineViewingLayerDraggedDraggingRight;
border-right: 1px solid;
`,
TimelineViewingLayerDraggedShiftDrag: css`
label: TimelineViewingLayerDraggedShiftDrag;
background-color: rgba(68, 68, 255, 0.2);
border-color: #44f;
`,
TimelineViewingLayerDraggedReframeDrag: css`
label: TimelineViewingLayerDraggedReframeDrag;
background-color: rgba(255, 68, 68, 0.2);
border-color: #f44;
`,
TimelineViewingLayerFullOverlay: css`
label: TimelineViewingLayerFullOverlay;
bottom: 0;
cursor: col-resize;
left: 0;
position: fixed;
right: 0;
top: 0;
user-select: none;
`,
};
});
export type TimelineViewingLayerProps = {
/**
* `boundsInvalidator` is an arbitrary prop that lets the component know the
* bounds for dragging need to be recalculated. In practice, the name column
* width serves fine for this.
*/
boundsInvalidator: any | null | undefined;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRangeTime: ViewRangeTime;
};
type TDraggingLeftLayout = {
isDraggingLeft: boolean;
left: string;
width: string;
};
type TOutOfViewLayout = {
isOutOfView: true;
};
function isOutOfView(layout: TDraggingLeftLayout | TOutOfViewLayout): layout is TOutOfViewLayout {
return Reflect.has(layout, 'isOutOfView');
}
/**
* Map from a sub range to the greater view range, e.g, when the view range is
* the middle half ([0.25, 0.75]), a value of 0.25 befomes 3/8.
* @returns {number}
*/
function mapFromViewSubRange(viewStart: number, viewEnd: number, value: number) {
return viewStart + value * (viewEnd - viewStart);
}
/**
* Map a value from the view ([0, 1]) to a sub-range, e.g, when the view range is
* the middle half ([0.25, 0.75]), a value of 3/8 becomes 1/4.
* @returns {number}
*/
function mapToViewSubRange(viewStart: number, viewEnd: number, value: number) {
return (value - viewStart) / (viewEnd - viewStart);
}
/**
* Get the layout for the "next" view range time, e.g. the difference from the
* drag start and the drag end. This is driven by `shiftStart`, `shiftEnd` or
* `reframe` on `props.viewRangeTime`, not by the current state of the
* component. So, it reflects in-progress dragging from the span minimap.
*/
function getNextViewLayout(start: number, position: number): TDraggingLeftLayout | TOutOfViewLayout {
let [left, right] = start < position ? [start, position] : [position, start];
if (left >= 1 || right <= 0) {
return { isOutOfView: true };
}
if (left < 0) {
left = 0;
}
if (right > 1) {
right = 1;
}
return {
isDraggingLeft: start > position,
left: `${left * 100}%`,
width: `${(right - left) * 100}%`,
};
}
/**
* Render the visual indication of the "next" view range.
*/
function getMarkers(viewStart: number, viewEnd: number, from: number, to: number, isShift: boolean): React.ReactNode {
const mappedFrom = mapToViewSubRange(viewStart, viewEnd, from);
const mappedTo = mapToViewSubRange(viewStart, viewEnd, to);
const layout = getNextViewLayout(mappedFrom, mappedTo);
if (isOutOfView(layout)) {
return null;
}
const { isDraggingLeft, left, width } = layout;
const styles = getStyles();
const cls = cx({
[styles.TimelineViewingLayerDraggedDraggingRight]: !isDraggingLeft,
[styles.TimelineViewingLayerDraggedReframeDrag]: !isShift,
[styles.TimelineViewingLayerDraggedShiftDrag]: isShift,
});
return (
<div
className={cx(styles.TimelineViewingLayerDragged, styles.TimelineViewingLayerDraggedDraggingLeft, cls)}
style={{ left, width }}
data-testid="Dragged"
/>
);
}
/**
* `TimelineViewingLayer` is rendered on top of the TimelineHeaderRow time
* labels; it handles showing the current view range and handles mouse UX for
* modifying it.
*/
export default class TimelineViewingLayer extends React.PureComponent<TimelineViewingLayerProps> {
_draggerReframe: DraggableManager;
_root: Element | TNil;
constructor(props: TimelineViewingLayerProps) {
super(props);
this._draggerReframe = new DraggableManager({
getBounds: this._getDraggingBounds,
onDragEnd: this._handleReframeDragEnd,
onDragMove: this._handleReframeDragUpdate,
onDragStart: this._handleReframeDragUpdate,
onMouseLeave: this._handleReframeMouseLeave,
onMouseMove: this._handleReframeMouseMove,
});
this._root = undefined;
}
UNSAFE_componentWillReceiveProps(nextProps: TimelineViewingLayerProps) {
const { boundsInvalidator } = this.props;
if (boundsInvalidator !== nextProps.boundsInvalidator) {
this._draggerReframe.resetBounds();
}
}
componentWillUnmount() {
this._draggerReframe.dispose();
}
_setRoot = (elm: Element | TNil) => {
this._root = elm;
};
_getDraggingBounds = (): DraggableBounds => {
if (!this._root) {
throw new Error('invalid state');
}
const { left: clientXLeft, width } = this._root.getBoundingClientRect();
return { clientXLeft, width };
};
_handleReframeMouseMove = ({ value }: DraggingUpdate) => {
const [viewStart, viewEnd] = this.props.viewRangeTime.current;
const cursor = mapFromViewSubRange(viewStart, viewEnd, value);
this.props.updateNextViewRangeTime({ cursor });
};
_handleReframeMouseLeave = () => {
this.props.updateNextViewRangeTime({ cursor: undefined });
};
_handleReframeDragUpdate = ({ value }: DraggingUpdate) => {
const { current, reframe } = this.props.viewRangeTime;
const [viewStart, viewEnd] = current;
const shift = mapFromViewSubRange(viewStart, viewEnd, value);
const anchor = reframe ? reframe.anchor : shift;
const update = { reframe: { anchor, shift } };
this.props.updateNextViewRangeTime(update);
};
_handleReframeDragEnd = ({ manager, value }: DraggingUpdate) => {
const { current, reframe } = this.props.viewRangeTime;
const [viewStart, viewEnd] = current;
const shift = mapFromViewSubRange(viewStart, viewEnd, value);
const anchor = reframe ? reframe.anchor : shift;
const [start, end] = shift < anchor ? [shift, anchor] : [anchor, shift];
manager.resetBounds();
this.props.updateViewRangeTime(start, end, 'timeline-header');
};
render() {
const { viewRangeTime } = this.props;
const { current, cursor, reframe, shiftEnd, shiftStart } = viewRangeTime;
const [viewStart, viewEnd] = current;
const haveNextTimeRange = reframe != null || shiftEnd != null || shiftStart != null;
let cusrorPosition: string | TNil;
if (!haveNextTimeRange && cursor != null && cursor >= viewStart && cursor <= viewEnd) {
cusrorPosition = `${mapToViewSubRange(viewStart, viewEnd, cursor) * 100}%`;
}
const styles = getStyles();
return (
<div
aria-hidden
className={styles.TimelineViewingLayer}
ref={this._setRoot}
onMouseDown={this._draggerReframe.handleMouseDown}
onMouseLeave={this._draggerReframe.handleMouseLeave}
onMouseMove={this._draggerReframe.handleMouseMove}
data-testid="TimelineViewingLayer"
>
{cusrorPosition != null && (
<div
className={styles.TimelineViewingLayerCursorGuide}
style={{ left: cusrorPosition }}
data-testid="TimelineViewingLayer--cursorGuide"
/>
)}
{reframe != null && getMarkers(viewStart, viewEnd, reframe.anchor, reframe.shift, false)}
{shiftEnd != null && getMarkers(viewStart, viewEnd, viewEnd, shiftEnd, true)}
{shiftStart != null && getMarkers(viewStart, viewEnd, viewStart, shiftStart, true)}
</div>
);
}
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) 2017 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 { default } from './TimelineHeaderRow';

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { useStyles2 } from '@grafana/ui';
import { ubRelative } from '../uberUtilityStyles';
const getStyles = () => {
return {
flexRow: css`
display: flex;
flex: 0 1 auto;
flex-direction: row;
`,
};
};
type TTimelineRowProps = {
children: React.ReactNode;
className?: string;
};
interface TimelineRowCellProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
width: number;
style?: {};
}
export default function TimelineRow(props: TTimelineRowProps) {
const { children, className = '', ...rest } = props;
const styles = useStyles2(getStyles);
return (
<div className={cx(styles.flexRow, className)} {...rest}>
{children}
</div>
);
}
TimelineRow.defaultProps = {
className: '',
};
export function TimelineRowCell(props: TimelineRowCellProps) {
const { children, className = '', width, style, ...rest } = props;
const widthPercent = `${width * 100}%`;
const mergedStyle = { ...style, flexBasis: widthPercent, maxWidth: widthPercent };
return (
<div className={cx(ubRelative, className)} style={mergedStyle} data-testid="TimelineRowCell" {...rest}>
{children}
</div>
);
}
TimelineRowCell.defaultProps = { className: '', style: {} };
TimelineRow.Cell = TimelineRowCell;

View File

@@ -1,126 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { Trace } from 'src/types/trace';
import traceGenerator from '../demo/trace-generators';
import transformTraceData from '../model/transform-trace-data';
import SpanTreeOffset from './SpanTreeOffset';
import VirtualizedTraceView, { VirtualizedTraceViewProps } from './VirtualizedTraceView';
jest.mock('./SpanTreeOffset');
const trace = transformTraceData(traceGenerator.trace({ numberOfSpans: 2 }))!;
const topOfExploreViewRef = jest.fn();
let props = {
childrenHiddenIDs: new Set(),
childrenToggle: jest.fn(),
clearShouldScrollToFirstUiFindMatch: jest.fn(),
currentViewRangeTime: [0.25, 0.75],
detailLogItemToggle: jest.fn(),
detailLogsToggle: jest.fn(),
detailProcessToggle: jest.fn(),
detailStates: new Map(),
detailTagsToggle: jest.fn(),
detailToggle: jest.fn(),
findMatchesIDs: null,
registerAccessors: jest.fn(),
scrollToFirstVisibleSpan: jest.fn(),
setSpanNameColumnWidth: jest.fn(),
setTrace: jest.fn(),
shouldScrollToFirstUiFindMatch: false,
spanNameColumnWidth: 0.5,
trace,
uiFind: 'uiFind',
topOfExploreViewRef,
} as unknown as VirtualizedTraceViewProps;
describe('<VirtualizedTraceViewImpl>', () => {
beforeEach(() => {
jest.mocked(SpanTreeOffset).mockReturnValue(<div />);
Object.keys(props).forEach((key) => {
if (typeof props[key as keyof VirtualizedTraceViewProps] === 'function') {
(props[key as keyof VirtualizedTraceViewProps] as jest.Mock).mockReset();
}
});
});
it('renders service name, operation name and duration for each span', () => {
render(<VirtualizedTraceView {...props} />);
expect(screen.getAllByText(trace.services[0].name)).toBeTruthy();
if (trace.services.length > 1) {
expect(screen.getAllByText(trace.services[1].name)).toBeTruthy();
}
expect(screen.getAllByText(trace.spans[0].operationName)).toBeTruthy();
expect(screen.getAllByText(trace.spans[1].operationName)).toBeTruthy();
let durationSpan0 = trace.spans[0].duration;
if (trace.spans[0].duration >= 1_000_000) {
durationSpan0 = Math.floor(trace.spans[0].duration / 1000000);
} else if (trace.spans[0].duration >= 1000) {
durationSpan0 = Math.floor(trace.spans[0].duration / 1000);
}
let durationSpan1 = trace.spans[1].duration;
if (trace.spans[1].duration >= 1_000_000) {
durationSpan1 = Math.floor(trace.spans[1].duration / 1000000);
} else if (trace.spans[1].duration >= 1000) {
durationSpan1 = Math.floor(trace.spans[1].duration / 1000);
}
expect(screen.getAllByText(durationSpan0, { exact: false })).toBeTruthy();
expect(screen.getAllByText(durationSpan1, { exact: false })).toBeTruthy();
});
it('renders without exploding', () => {
render(<VirtualizedTraceView {...props} />);
expect(screen.getByTestId('ListView')).toBeInTheDocument();
expect(screen.getByTitle('Scroll to top')).toBeInTheDocument();
});
it('renders when a trace is not set', () => {
props = { ...props, trace: null as unknown as Trace };
render(<VirtualizedTraceView {...props} />);
expect(screen.getByTestId('ListView')).toBeInTheDocument();
expect(screen.getByTitle('Scroll to top')).toBeInTheDocument();
});
it('renders ListView', () => {
render(<VirtualizedTraceView {...props} />);
expect(screen.getByTestId('ListView')).toBeInTheDocument();
});
it('renders scrollToTopButton', () => {
render(<VirtualizedTraceView {...props} />);
expect(
screen.getByRole('button', {
name: /Scroll to top/i,
})
).toBeInTheDocument();
});
it('sets the trace for global state.traceTimeline', () => {
const traceID = 'some-other-id';
const _trace = { ...trace, traceID };
props = { ...props, trace: _trace };
render(<VirtualizedTraceView {...props} />);
expect(jest.mocked(props.setTrace).mock.calls).toEqual([[_trace, props.uiFind]]);
});
});

View File

@@ -1,579 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import { isEqual } from 'lodash';
import memoizeOne from 'memoize-one';
import * as React from 'react';
import { createRef, RefObject } from 'react';
import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { stylesFactory, withTheme2, ToolbarButton } from '@grafana/ui';
import { Accessors } from '../ScrollManager';
import { PEER_SERVICE } from '../constants/tag-keys';
import { SpanBarOptions, SpanLinkFunc, TNil } from '../types';
import TTraceTimeline from '../types/TTraceTimeline';
import { TraceLog, TraceSpan, Trace, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace';
import { getColorByKey } from '../utils/color-generator';
import ListView from './ListView';
import SpanBarRow from './SpanBarRow';
import DetailState from './SpanDetail/DetailState';
import SpanDetailRow from './SpanDetailRow';
import {
createViewedBoundsFunc,
findServerChildSpan,
isErrorSpan,
isKindClient,
spanContainsErredSpan,
ViewedBoundsFunctionType,
} from './utils';
type TExtractUiFindFromStateReturn = {
uiFind: string | undefined;
};
const getStyles = stylesFactory((props: TVirtualizedTraceViewOwnProps) => {
const { topOfViewRefType } = props;
const position = topOfViewRefType === TopOfViewRefType.Explore ? 'fixed' : 'absolute';
return {
rowsWrapper: css`
width: 100%;
`,
row: css`
width: 100%;
`,
scrollToTopButton: css`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
position: ${position};
bottom: 30px;
right: 30px;
z-index: 1;
`,
};
});
type RowState = {
isDetail: boolean;
span: TraceSpan;
spanIndex: number;
};
export enum TopOfViewRefType {
Explore = 'Explore',
Panel = 'Panel',
}
type TVirtualizedTraceViewOwnProps = {
currentViewRangeTime: [number, number];
timeZone: TimeZone;
findMatchesIDs: Set<string> | TNil;
scrollToFirstVisibleSpan: () => void;
registerAccessors: (accesors: Accessors) => void;
trace: Trace;
spanBarOptions: SpanBarOptions | undefined;
linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[];
childrenToggle: (spanID: string) => void;
clearShouldScrollToFirstUiFindMatch: () => void;
detailLogItemToggle: (spanID: string, log: TraceLog) => void;
detailLogsToggle: (spanID: string) => void;
detailWarningsToggle: (spanID: string) => void;
detailStackTracesToggle: (spanID: string) => void;
detailReferencesToggle: (spanID: string) => void;
detailReferenceItemToggle: (spanID: string, reference: TraceSpanReference) => void;
detailProcessToggle: (spanID: string) => void;
detailTagsToggle: (spanID: string) => void;
detailToggle: (spanID: string) => void;
setSpanNameColumnWidth: (width: number) => void;
setTrace: (trace: Trace | TNil, uiFind: string | TNil) => void;
hoverIndentGuideIds: Set<string>;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
theme: GrafanaTheme2;
createSpanLink?: SpanLinkFunc;
scrollElement?: Element;
focusedSpanId?: string;
focusedSpanIdForSearch: string;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRef?: RefObject<HTMLDivElement>;
topOfViewRefType?: TopOfViewRefType;
datasourceType: string;
};
export type VirtualizedTraceViewProps = TVirtualizedTraceViewOwnProps & TExtractUiFindFromStateReturn & TTraceTimeline;
// export for tests
export const DEFAULT_HEIGHTS = {
bar: 28,
detail: 161,
detailWithLogs: 197,
};
const NUM_TICKS = 5;
function generateRowStates(
spans: TraceSpan[] | TNil,
childrenHiddenIDs: Set<string>,
detailStates: Map<string, DetailState | TNil>
): RowState[] {
if (!spans) {
return [];
}
let collapseDepth = null;
const rowStates = [];
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
const { spanID, depth } = span;
let hidden = false;
if (collapseDepth != null) {
if (depth >= collapseDepth) {
hidden = true;
} else {
collapseDepth = null;
}
}
if (hidden) {
continue;
}
if (childrenHiddenIDs.has(spanID)) {
collapseDepth = depth + 1;
}
rowStates.push({
span,
isDetail: false,
spanIndex: i,
});
if (detailStates.has(spanID)) {
rowStates.push({
span,
isDetail: true,
spanIndex: i,
});
}
}
return rowStates;
}
function getClipping(currentViewRange: [number, number]) {
const [zoomStart, zoomEnd] = currentViewRange;
return {
left: zoomStart > 0,
right: zoomEnd < 1,
};
}
function generateRowStatesFromTrace(
trace: Trace | TNil,
childrenHiddenIDs: Set<string>,
detailStates: Map<string, DetailState | TNil>
): RowState[] {
return trace ? generateRowStates(trace.spans, childrenHiddenIDs, detailStates) : [];
}
const memoizedGenerateRowStates = memoizeOne(generateRowStatesFromTrace);
const memoizedViewBoundsFunc = memoizeOne(createViewedBoundsFunc, isEqual);
const memoizedGetClipping = memoizeOne(getClipping, isEqual);
// export from tests
export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTraceViewProps> {
listView: ListView | TNil;
topTraceViewRef = createRef<HTMLDivElement>();
constructor(props: VirtualizedTraceViewProps) {
super(props);
const { setTrace, trace, uiFind } = props;
setTrace(trace, uiFind);
}
componentDidMount() {
this.scrollToSpan(this.props.focusedSpanId);
}
shouldComponentUpdate(nextProps: VirtualizedTraceViewProps) {
// If any prop updates, VirtualizedTraceViewImpl should update.
const nextPropKeys = Object.keys(nextProps) as Array<keyof VirtualizedTraceViewProps>;
for (let i = 0; i < nextPropKeys.length; i += 1) {
if (nextProps[nextPropKeys[i]] !== this.props[nextPropKeys[i]]) {
// Unless the only change was props.shouldScrollToFirstUiFindMatch changing to false.
if (nextPropKeys[i] === 'shouldScrollToFirstUiFindMatch') {
if (nextProps[nextPropKeys[i]]) {
return true;
}
} else {
return true;
}
}
}
return false;
}
componentDidUpdate(prevProps: Readonly<VirtualizedTraceViewProps>) {
const { registerAccessors, trace } = prevProps;
const {
shouldScrollToFirstUiFindMatch,
clearShouldScrollToFirstUiFindMatch,
scrollToFirstVisibleSpan,
registerAccessors: nextRegisterAccessors,
setTrace,
trace: nextTrace,
uiFind,
focusedSpanId,
focusedSpanIdForSearch,
} = this.props;
if (trace !== nextTrace) {
setTrace(nextTrace, uiFind);
}
if (this.listView && registerAccessors !== nextRegisterAccessors) {
nextRegisterAccessors(this.getAccessors());
}
if (shouldScrollToFirstUiFindMatch) {
scrollToFirstVisibleSpan();
clearShouldScrollToFirstUiFindMatch();
}
if (focusedSpanId !== prevProps.focusedSpanId) {
this.scrollToSpan(focusedSpanId);
}
if (focusedSpanIdForSearch !== prevProps.focusedSpanIdForSearch) {
this.scrollToSpan(focusedSpanIdForSearch);
}
}
getRowStates(): RowState[] {
const { childrenHiddenIDs, detailStates, trace } = this.props;
return memoizedGenerateRowStates(trace, childrenHiddenIDs, detailStates);
}
getClipping(): { left: boolean; right: boolean } {
const { currentViewRangeTime } = this.props;
return memoizedGetClipping(currentViewRangeTime);
}
getViewedBounds(): ViewedBoundsFunctionType {
const { currentViewRangeTime, trace } = this.props;
const [zoomStart, zoomEnd] = currentViewRangeTime;
return memoizedViewBoundsFunc({
min: trace.startTime,
max: trace.endTime,
viewStart: zoomStart,
viewEnd: zoomEnd,
});
}
getAccessors() {
const lv = this.listView;
if (!lv) {
throw new Error('ListView unavailable');
}
return {
getViewRange: this.getViewRange,
getSearchedSpanIDs: this.getSearchedSpanIDs,
getCollapsedChildren: this.getCollapsedChildren,
getViewHeight: lv.getViewHeight,
getBottomRowIndexVisible: lv.getBottomVisibleIndex,
getTopRowIndexVisible: lv.getTopVisibleIndex,
getRowPosition: lv.getRowPosition,
mapRowIndexToSpanIndex: this.mapRowIndexToSpanIndex,
mapSpanIndexToRowIndex: this.mapSpanIndexToRowIndex,
};
}
getViewRange = () => this.props.currentViewRangeTime;
getSearchedSpanIDs = () => this.props.findMatchesIDs;
getCollapsedChildren = () => this.props.childrenHiddenIDs;
mapRowIndexToSpanIndex = (index: number) => this.getRowStates()[index].spanIndex;
mapSpanIndexToRowIndex = (index: number) => {
const max = this.getRowStates().length;
for (let i = 0; i < max; i++) {
const { spanIndex } = this.getRowStates()[i];
if (spanIndex === index) {
return i;
}
}
throw new Error(`unable to find row for span index: ${index}`);
};
setListView = (listView: ListView | TNil) => {
const isChanged = this.listView !== listView;
this.listView = listView;
if (listView && isChanged) {
this.props.registerAccessors(this.getAccessors());
}
};
// use long form syntax to avert flow error
// https://github.com/facebook/flow/issues/3076#issuecomment-290944051
getKeyFromIndex = (index: number) => {
const { isDetail, span } = this.getRowStates()[index];
return `${span.traceID}--${span.spanID}--${isDetail ? 'detail' : 'bar'}`;
};
getIndexFromKey = (key: string) => {
const parts = key.split('--');
const _traceID = parts[0];
const _spanID = parts[1];
const _isDetail = parts[2] === 'detail';
const max = this.getRowStates().length;
for (let i = 0; i < max; i++) {
const { span, isDetail } = this.getRowStates()[i];
if (span.spanID === _spanID && span.traceID === _traceID && isDetail === _isDetail) {
return i;
}
}
return -1;
};
getRowHeight = (index: number) => {
const { span, isDetail } = this.getRowStates()[index];
if (!isDetail) {
return DEFAULT_HEIGHTS.bar;
}
if (Array.isArray(span.logs) && span.logs.length) {
return DEFAULT_HEIGHTS.detailWithLogs;
}
return DEFAULT_HEIGHTS.detail;
};
renderRow = (key: string, style: React.CSSProperties, index: number, attrs: {}) => {
const { isDetail, span, spanIndex } = this.getRowStates()[index];
return isDetail
? this.renderSpanDetailRow(span, key, style, attrs)
: this.renderSpanBarRow(span, spanIndex, key, style, attrs);
};
scrollToSpan = (spanID?: string) => {
if (spanID == null) {
return;
}
const i = this.getRowStates().findIndex((row) => row.span.spanID === spanID);
if (i >= 0) {
this.listView?.scrollToIndex(i);
}
};
renderSpanBarRow(span: TraceSpan, spanIndex: number, key: string, style: React.CSSProperties, attrs: {}) {
const { spanID } = span;
const { serviceName } = span.process;
const {
childrenHiddenIDs,
childrenToggle,
detailStates,
detailToggle,
findMatchesIDs,
spanNameColumnWidth,
trace,
spanBarOptions,
hoverIndentGuideIds,
addHoverIndentGuideId,
removeHoverIndentGuideId,
createSpanLink,
focusedSpanId,
focusedSpanIdForSearch,
theme,
datasourceType,
} = this.props;
// to avert flow error
if (!trace) {
return null;
}
const color = getColorByKey(serviceName, theme);
const isCollapsed = childrenHiddenIDs.has(spanID);
const isDetailExpanded = detailStates.has(spanID);
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
const isFocused = spanID === focusedSpanId || spanID === focusedSpanIdForSearch;
const showErrorIcon = isErrorSpan(span) || (isCollapsed && spanContainsErredSpan(trace.spans, spanIndex));
// Check for direct child "server" span if the span is a "client" span.
let rpc = null;
if (isCollapsed) {
const rpcSpan = findServerChildSpan(trace.spans.slice(spanIndex));
if (rpcSpan) {
const rpcViewBounds = this.getViewedBounds()(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration);
rpc = {
color: getColorByKey(rpcSpan.process.serviceName, theme),
operationName: rpcSpan.operationName,
serviceName: rpcSpan.process.serviceName,
viewEnd: rpcViewBounds.end,
viewStart: rpcViewBounds.start,
};
}
}
const peerServiceKV = span.tags.find((kv) => kv.key === PEER_SERVICE);
// Leaf, kind == client and has peer.service.tag, is likely a client span that does a request
// to an uninstrumented/external service
let noInstrumentedServer = null;
if (!span.hasChildren && peerServiceKV && isKindClient(span)) {
noInstrumentedServer = {
serviceName: peerServiceKV.value,
color: getColorByKey(peerServiceKV.value, theme),
};
}
const styles = getStyles(this.props);
return (
<div className={styles.row} key={key} style={style} {...attrs}>
<SpanBarRow
clippingLeft={this.getClipping().left}
clippingRight={this.getClipping().right}
color={color}
spanBarOptions={spanBarOptions}
columnDivision={spanNameColumnWidth}
isChildrenExpanded={!isCollapsed}
isDetailExpanded={isDetailExpanded}
isMatchingFilter={isMatchingFilter}
isFocused={isFocused}
numTicks={NUM_TICKS}
onDetailToggled={detailToggle}
onChildrenToggled={childrenToggle}
rpc={rpc}
noInstrumentedServer={noInstrumentedServer}
showErrorIcon={showErrorIcon}
getViewedBounds={this.getViewedBounds()}
traceStartTime={trace.startTime}
span={span}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
createSpanLink={createSpanLink}
datasourceType={datasourceType}
/>
</div>
);
}
renderSpanDetailRow(span: TraceSpan, key: string, style: React.CSSProperties, attrs: {}) {
const { spanID } = span;
const { serviceName } = span.process;
const {
detailLogItemToggle,
detailLogsToggle,
detailProcessToggle,
detailReferencesToggle,
detailReferenceItemToggle,
detailWarningsToggle,
detailStackTracesToggle,
detailStates,
detailTagsToggle,
detailToggle,
spanNameColumnWidth,
trace,
timeZone,
hoverIndentGuideIds,
addHoverIndentGuideId,
removeHoverIndentGuideId,
linksGetter,
createSpanLink,
focusedSpanId,
createFocusSpanLink,
topOfViewRefType,
theme,
datasourceType,
} = this.props;
const detailState = detailStates.get(spanID);
if (!trace || !detailState) {
return null;
}
const color = getColorByKey(serviceName, theme);
const styles = getStyles(this.props);
return (
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
<SpanDetailRow
color={color}
columnDivision={spanNameColumnWidth}
onDetailToggled={detailToggle}
detailState={detailState}
linksGetter={linksGetter}
logItemToggle={detailLogItemToggle}
logsToggle={detailLogsToggle}
processToggle={detailProcessToggle}
referenceItemToggle={detailReferenceItemToggle}
referencesToggle={detailReferencesToggle}
warningsToggle={detailWarningsToggle}
stackTracesToggle={detailStackTracesToggle}
span={span}
timeZone={timeZone}
tagsToggle={detailTagsToggle}
traceStartTime={trace.startTime}
hoverIndentGuideIds={hoverIndentGuideIds}
addHoverIndentGuideId={addHoverIndentGuideId}
removeHoverIndentGuideId={removeHoverIndentGuideId}
createSpanLink={createSpanLink}
focusedSpanId={focusedSpanId}
createFocusSpanLink={createFocusSpanLink}
topOfViewRefType={topOfViewRefType}
datasourceType={datasourceType}
/>
</div>
);
}
scrollToTop = () => {
const { topOfViewRef, datasourceType, trace } = this.props;
topOfViewRef?.current?.scrollIntoView({ behavior: 'smooth' });
reportInteraction('grafana_traces_trace_view_scroll_to_top_clicked', {
datasourceType: datasourceType,
grafana_version: config.buildInfo.version,
numServices: trace.services.length,
numSpans: trace.spans.length,
});
};
render() {
const styles = getStyles(this.props);
const { scrollElement } = this.props;
return (
<>
<ListView
ref={this.setListView}
dataLength={this.getRowStates().length}
itemHeightGetter={this.getRowHeight}
itemRenderer={this.renderRow}
viewBuffer={50}
viewBufferMin={50}
itemsWrapperClassName={styles.rowsWrapper}
getKeyFromIndex={this.getKeyFromIndex}
getIndexFromKey={this.getIndexFromKey}
windowScroller={false}
scrollElement={scrollElement}
/>
<ToolbarButton
className={styles.scrollToTopButton}
onClick={this.scrollToTop}
title="Scroll to top"
icon="arrow-up"
></ToolbarButton>
</>
);
}
}
export default withTheme2(UnthemedVirtualizedTraceView);

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2017 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 { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { createTheme } from '@grafana/data';
import traceGenerator from '../demo/trace-generators';
import transformTraceData from '../model/transform-trace-data';
import TraceTimelineViewer, { TProps } from './index';
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
};
});
describe('<TraceTimelineViewer>', () => {
const trace = transformTraceData(traceGenerator.trace({}));
const props = {
trace,
textFilter: null,
viewRange: {
time: {
current: [0, 1],
},
},
traceTimeline: {
childrenHiddenIDs: new Set(),
hoverIndentGuideIds: new Set(),
spanNameColumnWidth: 0.5,
detailStates: new Map(),
},
expandAll: jest.fn(),
collapseAll: jest.fn(),
expandOne: jest.fn(),
registerAccessors: jest.fn(),
collapseOne: jest.fn(),
setTrace: jest.fn(),
theme: createTheme(),
history: {
replace: () => {},
},
location: {
search: null,
},
};
it('it does not explode', () => {
expect(() => render(<TraceTimelineViewer {...(props as unknown as TProps)} />)).not.toThrow();
});
it('it sets up actions', async () => {
render(<TraceTimelineViewer {...(props as unknown as TProps)} />);
const expandOne = screen.getByRole('button', { name: 'Expand +1' });
const collapseOne = screen.getByRole('button', { name: 'Collapse +1' });
const expandAll = screen.getByRole('button', { name: 'Expand All' });
const collapseAll = screen.getByRole('button', { name: 'Collapse All' });
expect(expandOne).toBeInTheDocument();
expect(collapseOne).toBeInTheDocument();
expect(expandAll).toBeInTheDocument();
expect(collapseAll).toBeInTheDocument();
await userEvent.click(expandOne);
expect(props.expandOne).toHaveBeenCalled();
await userEvent.click(collapseOne);
expect(props.collapseOne).toHaveBeenCalled();
await userEvent.click(expandAll);
expect(props.expandAll).toHaveBeenCalled();
await userEvent.click(collapseAll);
expect(props.collapseAll).toHaveBeenCalled();
});
});

View File

@@ -1,230 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import React, { RefObject } from 'react';
import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { stylesFactory, withTheme2 } from '@grafana/ui';
import { Accessors } from '../ScrollManager';
import { autoColor } from '../Theme';
import { merge as mergeShortcuts } from '../keyboard-shortcuts';
import { SpanBarOptions } from '../settings/SpanBarSettings';
import { SpanLinkFunc, TNil } from '../types';
import TTraceTimeline from '../types/TTraceTimeline';
import { TraceSpan, Trace, TraceLog, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace';
import TimelineHeaderRow from './TimelineHeaderRow';
import VirtualizedTraceView, { TopOfViewRefType } from './VirtualizedTraceView';
import { TUpdateViewRangeTimeFunction, ViewRange, ViewRangeTimeUpdate } from './types';
type TExtractUiFindFromStateReturn = {
uiFind: string | undefined;
};
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
TraceTimelineViewer: css`
label: TraceTimelineViewer;
border-bottom: 1px solid ${autoColor(theme, '#bbb')};
& .json-markup {
line-height: 17px;
font-size: 13px;
font-family: monospace;
white-space: pre-wrap;
}
& .json-markup-key {
font-weight: bold;
}
& .json-markup-bool {
color: ${autoColor(theme, 'firebrick')};
}
& .json-markup-string {
color: ${autoColor(theme, 'teal')};
}
& .json-markup-null {
color: ${autoColor(theme, 'teal')};
}
& .json-markup-number {
color: ${autoColor(theme, 'blue', 'black')};
}
`,
};
});
export type TProps = TExtractUiFindFromStateReturn & {
registerAccessors: (accessors: Accessors) => void;
findMatchesIDs: Set<string> | TNil;
scrollToFirstVisibleSpan: () => void;
traceTimeline: TTraceTimeline;
trace: Trace;
datasourceType: string;
spanBarOptions: SpanBarOptions | undefined;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
viewRange: ViewRange;
timeZone: TimeZone;
setSpanNameColumnWidth: (width: number) => void;
collapseAll: (spans: TraceSpan[]) => void;
collapseOne: (spans: TraceSpan[]) => void;
expandAll: () => void;
expandOne: (spans: TraceSpan[]) => void;
childrenToggle: (spanID: string) => void;
clearShouldScrollToFirstUiFindMatch: () => void;
detailLogItemToggle: (spanID: string, log: TraceLog) => void;
detailLogsToggle: (spanID: string) => void;
detailWarningsToggle: (spanID: string) => void;
detailStackTracesToggle: (spanID: string) => void;
detailReferencesToggle: (spanID: string) => void;
detailReferenceItemToggle: (spanID: string, reference: TraceSpanReference) => void;
detailProcessToggle: (spanID: string) => void;
detailTagsToggle: (spanID: string) => void;
detailToggle: (spanID: string) => void;
setTrace: (trace: Trace | TNil, uiFind: string | TNil) => void;
addHoverIndentGuideId: (spanID: string) => void;
removeHoverIndentGuideId: (spanID: string) => void;
linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[];
theme: GrafanaTheme2;
createSpanLink?: SpanLinkFunc;
scrollElement?: Element;
focusedSpanId?: string;
focusedSpanIdForSearch: string;
createFocusSpanLink: (traceId: string, spanId: string) => LinkModel;
topOfViewRef?: RefObject<HTMLDivElement>;
topOfViewRefType?: TopOfViewRefType;
};
type State = {
// Will be set to real height of the component so it can be passed down to size some other elements.
height: number;
};
const NUM_TICKS = 5;
/**
* `TraceTimelineViewer` now renders the header row because it is sensitive to
* `props.viewRange.time.cursor`. If `VirtualizedTraceView` renders it, it will
* re-render the ListView every time the cursor is moved on the trace minimap
* or `TimelineHeaderRow`.
*/
export class UnthemedTraceTimelineViewer extends React.PureComponent<TProps, State> {
constructor(props: TProps) {
super(props);
this.state = { height: 0 };
}
componentDidMount() {
mergeShortcuts({
collapseAll: this.collapseAll,
expandAll: this.expandAll,
collapseOne: this.collapseOne,
expandOne: this.expandOne,
});
}
collapseAll = () => {
this.props.collapseAll(this.props.trace.spans);
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
grafana_version: config.buildInfo.version,
type: 'collapseAll',
});
};
collapseOne = () => {
this.props.collapseOne(this.props.trace.spans);
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
grafana_version: config.buildInfo.version,
type: 'collapseOne',
});
};
expandAll = () => {
this.props.expandAll();
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
grafana_version: config.buildInfo.version,
type: 'expandAll',
});
};
expandOne = () => {
this.props.expandOne(this.props.trace.spans);
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
grafana_version: config.buildInfo.version,
type: 'expandOne',
});
};
render() {
const {
setSpanNameColumnWidth,
updateNextViewRangeTime,
updateViewRangeTime,
viewRange,
traceTimeline,
theme,
topOfViewRef,
focusedSpanIdForSearch,
...rest
} = this.props;
const { trace } = rest;
const styles = getStyles(theme);
return (
<div
className={styles.TraceTimelineViewer}
ref={(ref: HTMLDivElement | null) => ref && this.setState({ height: ref.getBoundingClientRect().height })}
>
<TimelineHeaderRow
duration={trace.duration}
nameColumnWidth={traceTimeline.spanNameColumnWidth}
numTicks={NUM_TICKS}
onCollapseAll={this.collapseAll}
onCollapseOne={this.collapseOne}
onColummWidthChange={setSpanNameColumnWidth}
onExpandAll={this.expandAll}
onExpandOne={this.expandOne}
viewRangeTime={viewRange.time}
updateNextViewRangeTime={updateNextViewRangeTime}
updateViewRangeTime={updateViewRangeTime}
columnResizeHandleHeight={this.state.height}
/>
<VirtualizedTraceView
{...rest}
{...traceTimeline}
setSpanNameColumnWidth={setSpanNameColumnWidth}
currentViewRangeTime={viewRange.time.current}
topOfViewRef={topOfViewRef}
focusedSpanIdForSearch={focusedSpanIdForSearch}
datasourceType={this.props.datasourceType}
/>
</div>
);
}
}
export default withTheme2(UnthemedTraceTimelineViewer);

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2017 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 { TNil } from '../types';
interface TimeCursorUpdate {
cursor: number | TNil;
}
interface TimeReframeUpdate {
reframe: {
anchor: number;
shift: number;
};
}
interface TimeShiftEndUpdate {
shiftEnd: number;
}
interface TimeShiftStartUpdate {
shiftStart: number;
}
export type TUpdateViewRangeTimeFunction = (start: number, end: number, trackSrc?: string) => void;
export type ViewRangeTimeUpdate = TimeCursorUpdate | TimeReframeUpdate | TimeShiftEndUpdate | TimeShiftStartUpdate;
export interface ViewRangeTime {
current: [number, number];
cursor?: number | TNil;
reframe?: {
anchor: number;
shift: number;
};
shiftEnd?: number;
shiftStart?: number;
}
export interface ViewRange {
time: ViewRangeTime;
}

View File

@@ -1,159 +0,0 @@
// Copyright (c) 2017 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 { TraceSpan } from 'src/types/trace';
import traceGenerator from '../demo/trace-generators';
import {
findServerChildSpan,
createViewedBoundsFunc,
isClientSpan,
isErrorSpan,
isServerSpan,
spanContainsErredSpan,
spanHasTag,
} from './utils';
describe('TraceTimelineViewer/utils', () => {
describe('getViewedBounds()', () => {
it('works for the full range', () => {
const args = { min: 1, max: 2, viewStart: 0, viewEnd: 1 };
const { start, end } = createViewedBoundsFunc(args)(1, 2);
expect(start).toBe(0);
expect(end).toBe(1);
});
it('works for a sub-range with a full view', () => {
const args = { min: 1, max: 2, viewStart: 0, viewEnd: 1 };
const { start, end } = createViewedBoundsFunc(args)(1.25, 1.75);
expect(start).toBe(0.25);
expect(end).toBe(0.75);
});
it('works for a sub-range that fills the view', () => {
const args = { min: 1, max: 2, viewStart: 0.25, viewEnd: 0.75 };
const { start, end } = createViewedBoundsFunc(args)(1.25, 1.75);
expect(start).toBe(0);
expect(end).toBe(1);
});
it('works for a sub-range that within a sub-view', () => {
const args = { min: 100, max: 200, viewStart: 0.1, viewEnd: 0.9 };
const { start, end } = createViewedBoundsFunc(args)(130, 170);
expect(start).toBe(0.25);
expect(end).toBe(0.75);
});
});
describe('spanHasTag() and variants', () => {
it('returns true iff the key/value pair is found', () => {
const span = traceGenerator.span;
span.tags = [{ key: 'span.kind', value: 'server' }];
expect(spanHasTag('span.kind', 'client', span)).toBe(false);
expect(spanHasTag('span.kind', 'client', span)).toBe(false);
expect(spanHasTag('span.kind', 'server', span)).toBe(true);
});
const spanTypeTestCases = [
{ fn: isClientSpan, name: 'isClientSpan', key: 'span.kind', value: 'client' },
{ fn: isServerSpan, name: 'isServerSpan', key: 'span.kind', value: 'server' },
{ fn: isErrorSpan, name: 'isErrorSpan', key: 'error', value: true },
{ fn: isErrorSpan, name: 'isErrorSpan', key: 'error', value: 'true' },
];
spanTypeTestCases.forEach((testCase) => {
const msg = `${testCase.name}() is true only when a ${testCase.key}=${testCase.value} tag is present`;
it(msg, () => {
const span = { tags: traceGenerator.tags() } as TraceSpan;
expect(testCase.fn(span)).toBe(false);
span.tags.push(testCase);
expect(testCase.fn(span)).toBe(true);
});
});
});
describe('spanContainsErredSpan()', () => {
it('returns true only when a descendant has an error tag', () => {
const errorTag = { key: 'error', type: 'bool', value: true };
const getTags = (withError: number) =>
withError ? traceGenerator.tags().concat(errorTag) : traceGenerator.tags();
// Using a string to generate the test spans. Each line results in a span. The
// left number indicates whether or not the generated span has a descendant
// with an error tag (the expectation). The length of the line indicates the
// depth of the span (i.e. further right is higher depth). The right number
// indicates whether or not the span has an error tag.
const config = `
1 0
1 0
0 1
0 0
1 0
1 1
0 1
0 0
1 0
0 1
0 0
`
.trim()
.split('\n')
.map((s) => s.trim());
// Get the expectation, str -> number -> bool
const expectations = config.map((s) => Boolean(Number(s[0])));
const spans = config.map((line) => ({
depth: line.length,
tags: getTags(+line.slice(-1)),
})) as TraceSpan[];
expectations.forEach((target, i) => {
// include the index in the expect condition to know which span failed
// (if there is a failure, that is)
const result = [i, spanContainsErredSpan(spans, i)];
expect(result).toEqual([i, target]);
});
});
});
describe('findServerChildSpan()', () => {
let spans: TraceSpan[];
beforeEach(() => {
spans = [
{ depth: 0, tags: [{ key: 'span.kind', value: 'client' }] },
{ depth: 1, tags: [] },
{ depth: 1, tags: [{ key: 'span.kind', value: 'server' }] },
{ depth: 1, tags: [{ key: 'span.kind', value: 'third-kind' }] },
{ depth: 1, tags: [{ key: 'span.kind', value: 'server' }] },
] as TraceSpan[];
});
it('returns falsy if the frist span is not a client', () => {
expect(findServerChildSpan(spans.slice(1))).toBeFalsy();
});
it('returns the first server span', () => {
const span = findServerChildSpan(spans);
expect(span).toBe(spans[2]);
});
it('bails when a non-child-depth span is encountered', () => {
spans[1].depth++;
expect(findServerChildSpan(spans)).toBeFalsy();
spans[1].depth = spans[0].depth;
expect(findServerChildSpan(spans)).toBeFalsy();
});
});
});

View File

@@ -1,118 +0,0 @@
// Copyright (c) 2017 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 { TraceSpan } from '../types/trace';
export type ViewedBoundsFunctionType = (start: number, end: number) => { start: number; end: number };
/**
* Given a range (`min`, `max`) and factoring in a zoom (`viewStart`, `viewEnd`)
* a function is created that will find the position of a sub-range (`start`, `end`).
* The calling the generated method will return the result as a `{ start, end }`
* object with values ranging in [0, 1].
*
* @param {number} min The start of the outer range.
* @param {number} max The end of the outer range.
* @param {number} viewStart The start of the zoom, on a range of [0, 1],
* relative to the `min`, `max`.
* @param {number} viewEnd The end of the zoom, on a range of [0, 1],
* relative to the `min`, `max`.
* @returns {(number, number) => Object} Created view bounds function
*/
export function createViewedBoundsFunc(viewRange: { min: number; max: number; viewStart: number; viewEnd: number }) {
const { min, max, viewStart, viewEnd } = viewRange;
const duration = max - min;
const viewMin = min + viewStart * duration;
const viewMax = max - (1 - viewEnd) * duration;
const viewWindow = viewMax - viewMin;
/**
* View bounds function
* @param {number} start The start of the sub-range.
* @param {number} end The end of the sub-range.
* @returns {Object} The resultant range.
*/
return (start: number, end: number) => ({
start: (start - viewMin) / viewWindow,
end: (end - viewMin) / viewWindow,
});
}
/**
* Returns `true` if the `span` has a tag matching `key` = `value`.
*
* @param {string} key The tag key to match on.
* @param {any} value The tag value to match.
* @param {{tags}} span An object with a `tags` property of { key, value }
* items.
* @returns {boolean} True if a match was found.
*/
export function spanHasTag(key: string, value: any, span: TraceSpan) {
if (!Array.isArray(span.tags) || !span.tags.length) {
return false;
}
return span.tags.some((tag) => tag.key === key && tag.value === value);
}
export const isClientSpan = spanHasTag.bind(null, 'span.kind', 'client');
export const isServerSpan = spanHasTag.bind(null, 'span.kind', 'server');
const isErrorBool = spanHasTag.bind(null, 'error', true);
const isErrorStr = spanHasTag.bind(null, 'error', 'true');
export const isErrorSpan = (span: TraceSpan) => isErrorBool(span) || isErrorStr(span);
/**
* Returns `true` if at least one of the descendants of the `parentSpanIndex`
* span contains an error tag.
*
* @param {TraceSpan[]} spans The spans for a trace - should be
* sorted with children following parents.
* @param {number} parentSpanIndex The index of the parent span - only
* subsequent spans with depth less than
* the parent span will be checked.
* @returns {boolean} Returns `true` if a descendant contains an error tag.
*/
export function spanContainsErredSpan(spans: TraceSpan[], parentSpanIndex: number) {
const { depth } = spans[parentSpanIndex];
let i = parentSpanIndex + 1;
for (; i < spans.length && spans[i].depth > depth; i++) {
if (isErrorSpan(spans[i])) {
return true;
}
}
return false;
}
/**
* Expects the first span to be the parent span.
*/
export function findServerChildSpan(spans: TraceSpan[]) {
if (spans.length <= 1 || !isClientSpan(spans[0])) {
return false;
}
const span = spans[0];
const spanChildDepth = span.depth + 1;
let i = 1;
while (i < spans.length && spans[i].depth === spanChildDepth) {
if (isServerSpan(spans[i])) {
return spans[i];
}
i++;
}
return null;
}
export const isKindClient = (span: TraceSpan): Boolean =>
span.tags.some(({ key, value }) => key === 'span.kind' && value === 'client');
export { formatDuration } from '../utils/date';

View File

@@ -1,196 +0,0 @@
// Copyright (c) 2017 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 Tween, { TweenState } from './Tween';
describe('Tween', () => {
const oldNow = Date.now;
const nowFn = jest.fn();
const oldSetTimeout = window.setTimeout;
const setTimeoutFn = jest.fn();
const oldRaf = window.requestAnimationFrame;
const rafFn = jest.fn();
const baseOptions = { duration: 10, from: 0, to: 1 };
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');
Date.now = nowFn;
window.requestAnimationFrame = rafFn;
beforeEach(() => {
nowFn.mockReset();
nowFn.mockReturnValue(0);
setTimeoutFn.mockReset();
rafFn.mockReset();
});
afterAll(() => {
Date.now = oldNow;
window.setTimeout = oldSetTimeout;
window.requestAnimationFrame = oldRaf;
});
describe('ctor', () => {
it('set startTime to the current time', () => {
const n = Math.random();
nowFn.mockReturnValue(n);
const tween = new Tween(baseOptions);
expect(tween.startTime).toBe(n);
});
it('adds delay to the startTime', () => {
const n = Math.random();
nowFn.mockReturnValue(n);
const tween = new Tween({ ...baseOptions, delay: 10 });
expect(tween.startTime).toBe(n + 10);
});
describe('with callbacks', () => {
it('schedules setTimeout if there is a delay', () => {
const delay = 10;
const tween = new Tween({ ...baseOptions, delay, onUpdate: jest.fn() });
expect(setTimeout).lastCalledWith(tween._frameCallback, delay);
});
it('schedules animation frame if there is not a delay', () => {
const tween = new Tween({ ...baseOptions, onUpdate: jest.fn() });
expect(rafFn).lastCalledWith(tween._frameCallback);
});
});
});
describe('getCurrent()', () => {
it('returns `{done: false, value: from}` when time is before the delay is finished', () => {
const tween = new Tween({ ...baseOptions, delay: 1 });
const current = tween.getCurrent();
expect(current).toEqual({ done: false, value: baseOptions.from });
});
describe('in progress tweens', () => {
it('returns `{done: false...`}', () => {
const tween = new Tween(baseOptions);
nowFn.mockReturnValue(1);
const current = tween.getCurrent();
expect(current.done).toBe(false);
expect(nowFn()).toBeLessThan(tween.startTime + tween.duration);
expect(nowFn()).toBeGreaterThan(tween.startTime);
});
it('progresses `{..., value} as time progresses', () => {
const tween = new Tween(baseOptions);
let lastValue = tween.getCurrent().value;
for (let i = 1; i < baseOptions.duration; i++) {
nowFn.mockReturnValue(i);
const { done, value } = tween.getCurrent();
expect(done).toBe(false);
expect(value).toBeGreaterThan(lastValue);
lastValue = value;
}
});
});
it('returns `{done: true, value: to}` when the time is past the duration', () => {
const tween = new Tween(baseOptions);
nowFn.mockReturnValue(baseOptions.duration);
const current = tween.getCurrent();
expect(current).toEqual({ done: true, value: baseOptions.to });
});
});
describe('_frameCallback', () => {
it('freezes the callback argument', () => {
let current: TweenState | undefined;
const fn = jest.fn((_current) => {
current = _current;
});
const tween = new Tween({ ...baseOptions, onUpdate: fn });
tween._frameCallback();
expect(current).toBeDefined();
const copy = { ...current };
try {
current!.done = !current!.done;
// eslint-disable-next-line no-empty
} catch (_) {}
expect(current).toEqual(copy);
});
it('calls onUpdate if there is an onUpdate callback', () => {
const fn = jest.fn();
const tween = new Tween({ ...baseOptions, onUpdate: fn });
tween._frameCallback();
const current = tween.getCurrent();
expect(current).toBeDefined();
expect(fn).lastCalledWith(current);
});
it('does not call onComplete if there is an onComplete callback and the tween is not complete', () => {
const fn = jest.fn();
const tween = new Tween({ ...baseOptions, onComplete: fn });
tween._frameCallback();
expect(fn.mock.calls.length).toBe(0);
});
it('calls onComplete if there is an onComplete callback and the tween is complete', () => {
const fn = jest.fn();
const tween = new Tween({ ...baseOptions, onComplete: fn });
nowFn.mockReturnValue(nowFn() + baseOptions.duration);
tween._frameCallback();
const current = tween.getCurrent();
expect(fn.mock.calls).toEqual([[current]]);
expect(current.done).toBe(true);
});
it('schedules an animatinon frame if the tween is not complete', () => {
expect(rafFn.mock.calls.length).toBe(0);
const tween = new Tween({ ...baseOptions, onUpdate: () => {} });
nowFn.mockReturnValue(nowFn() + 0.5 * baseOptions.duration);
rafFn.mockReset();
tween._frameCallback();
expect(rafFn.mock.calls).toEqual([[tween._frameCallback]]);
});
});
describe('cancel()', () => {
it('cancels scheduled timeouts or animation frames', () => {
const oldClearTimeout = window.clearTimeout;
const oldCancelRaf = window.cancelAnimationFrame;
const clearFn = jest.fn();
window.clearTimeout = clearFn;
const cancelFn = jest.fn();
window.cancelAnimationFrame = cancelFn;
const tween = new Tween(baseOptions);
const id = 1;
tween.timeoutID = id;
tween.requestID = id;
tween.cancel();
expect(clearFn.mock.calls).toEqual([[id]]);
expect(cancelFn.mock.calls).toEqual([[id]]);
expect(tween.timeoutID).toBe(undefined);
expect(tween.requestID).toBe(undefined);
window.clearTimeout = oldClearTimeout;
window.cancelAnimationFrame = oldCancelRaf;
});
it('releases references to callbacks', () => {
const tween = new Tween({ ...baseOptions, onComplete: () => {}, onUpdate: () => {} });
tween.cancel();
expect(tween.onComplete).toBe(undefined);
expect(tween.onUpdate).toBe(undefined);
});
});
});

View File

@@ -1,114 +0,0 @@
// Copyright (c) 2017 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 ease from 'tween-functions';
import { TNil } from './types';
export interface TweenState {
done: boolean;
value: number;
}
type TTweenCallback = (state: TweenState) => void;
export type TTweenOptions = {
delay?: number;
duration: number;
from: number;
onComplete?: TTweenCallback;
onUpdate?: TTweenCallback;
to: number;
};
export default class Tween {
onComplete: TTweenCallback | TNil;
onUpdate: TTweenCallback | TNil;
delay: number | TNil;
duration: number;
from: number;
requestID: number | TNil;
startTime: number;
timeoutID: number | TNil;
to: number;
constructor({ duration, from, to, delay, onUpdate, onComplete }: TTweenOptions) {
this.startTime = Date.now() + (delay || 0);
this.duration = duration;
this.from = from;
this.to = to;
if (!onUpdate && !onComplete) {
this.onComplete = undefined;
this.onUpdate = undefined;
this.timeoutID = undefined;
this.requestID = undefined;
} else {
this.onComplete = onComplete;
this.onUpdate = onUpdate;
if (delay) {
// setTimeout from @types/node returns NodeJS.Timeout, so prefix with `window.`
this.timeoutID = window.setTimeout(this._frameCallback, delay);
this.requestID = undefined;
} else {
this.requestID = window.requestAnimationFrame(this._frameCallback);
this.timeoutID = undefined;
}
}
}
_frameCallback = () => {
this.timeoutID = undefined;
this.requestID = undefined;
const current = Object.freeze(this.getCurrent());
if (this.onUpdate) {
this.onUpdate(current);
}
if (this.onComplete && current.done) {
this.onComplete(current);
}
if (current.done) {
this.onComplete = undefined;
this.onUpdate = undefined;
} else {
this.requestID = window.requestAnimationFrame(this._frameCallback);
}
};
cancel() {
if (this.timeoutID != null) {
clearTimeout(this.timeoutID);
this.timeoutID = undefined;
}
if (this.requestID != null) {
window.cancelAnimationFrame(this.requestID);
this.requestID = undefined;
}
this.onComplete = undefined;
this.onUpdate = undefined;
}
getCurrent(): TweenState {
const t = Date.now() - this.startTime;
if (t <= 0) {
// still in the delay period
return { done: false, value: this.from };
}
if (t >= this.duration) {
// after the expiration
return { done: true, value: this.to };
}
// mid-tween
return { done: false, value: ease.easeOutQuint(t, this.from, this.to, this.duration) };
}
}

View File

@@ -1,65 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import * as React from 'react';
import { useStyles2 } from '@grafana/ui';
const getStyles = () => {
return {
BreakableText: css`
label: BreakableText;
display: inline-block;
white-space: pre;
`,
};
};
const WORD_RX = /\W*\w+\W*/g;
type Props = {
text: string;
className?: string;
wordRegexp?: RegExp;
};
// TODO typescript doesn't understand text or null as react nodes
// https://github.com/Microsoft/TypeScript/issues/21699
export default function BreakableText(
props: Props
): any /* React.ReactNode /* React.ReactElement | React.ReactElement[] \*\/ */ {
const { className, text, wordRegexp = WORD_RX } = props;
const styles = useStyles2(getStyles);
if (!text) {
return typeof text === 'string' ? text : null;
}
const spans = [];
wordRegexp.exec('');
// if the given text has no words, set the first match to the entire text
let match: RegExpExecArray | string[] | null = wordRegexp.exec(text) || [text];
while (match) {
spans.push(
<span key={`${text}-${spans.length}`} className={className || styles.BreakableText}>
{match[0]}
</span>
);
match = wordRegexp.exec(text);
}
return spans;
}
BreakableText.defaultProps = {
wordRegexp: WORD_RX,
};

View File

@@ -1,51 +0,0 @@
// 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 { render, screen } from '@testing-library/react';
import * as copy from 'copy-to-clipboard';
import React from 'react';
import CopyIcon from './CopyIcon';
jest.mock('copy-to-clipboard');
describe('<CopyIcon />', () => {
const props = {
className: 'classNameValue',
copyText: 'copyTextValue',
tooltipTitle: 'tooltipTitleValue',
};
let copySpy: jest.SpyInstance;
beforeAll(() => {
copySpy = jest.spyOn(copy, 'default');
});
beforeEach(() => {
copySpy.mockReset();
});
it('renders as expected', () => {
expect(() => render(<CopyIcon {...props} />)).not.toThrow();
});
it('copies when clicked', () => {
render(<CopyIcon {...props} />);
const button = screen.getByRole('button');
button.click();
expect(copySpy).toHaveBeenCalledWith(props.copyText);
});
});

View File

@@ -1,65 +0,0 @@
// 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 { css } from '@emotion/css';
import cx from 'classnames';
import copy from 'copy-to-clipboard';
import React, { useState } from 'react';
import { Button, IconName, Tooltip, useStyles2 } from '@grafana/ui';
const getStyles = () => {
return {
CopyIcon: css`
background-color: transparent;
border: none;
color: inherit;
height: 100%;
overflow: hidden;
&:focus {
background-color: rgba(255, 255, 255, 0.25);
color: inherit;
}
`,
};
};
type PropsType = {
className?: string;
copyText: string;
icon?: IconName;
tooltipTitle: string;
};
export default function CopyIcon(props: PropsType) {
const styles = useStyles2(getStyles);
const [hasCopied, setHasCopied] = useState(false);
const handleClick = () => {
copy(props.copyText);
setHasCopied(true);
};
return (
<Tooltip content={hasCopied ? 'Copied' : props.tooltipTitle}>
<Button className={cx(styles.CopyIcon)} type="button" icon={props.icon} onClick={handleClick} />
</Tooltip>
);
}
CopyIcon.defaultProps = {
icon: 'copy',
className: undefined,
};

View File

@@ -1,55 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../Theme';
const getStyles = (theme: GrafanaTheme2) => {
return {
Divider: css`
background: ${autoColor(theme, '#ddd')};
`,
DividerVertical: css`
label: DividerVertical;
display: inline-block;
width: 1px;
height: 0.9em;
margin: 0 8px;
vertical-align: middle;
`,
DividerHorizontal: css`
label: DividerHorizontal;
display: block;
height: 1px;
width: 100%;
margin: 24px 0;
clear: both;
vertical-align: middle;
position: relative;
top: -0.06em;
`,
};
};
interface Props {
className?: string;
style?: React.CSSProperties;
type?: 'vertical' | 'horizontal';
}
export function Divider({ className, style, type }: Props) {
const styles = useStyles2(getStyles);
return (
<div
style={style}
className={cx(
styles.Divider,
type === 'horizontal' ? styles.DividerHorizontal : styles.DividerVertical,
className
)}
/>
);
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2019 The Jaeger Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from 'react';
import NewWindowIcon from './NewWindowIcon';
type Link = {
text: string;
url: string;
};
type ExternalLinksProps = {
links: Link[];
className?: string;
};
const LinkValue = (props: { href: string; title?: string; children?: React.ReactNode; className?: string }) => (
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer" className={props.className}>
{props.children} <NewWindowIcon />
</a>
);
export default function ExternalLinks(props: ExternalLinksProps) {
const { links } = props;
// TODO: handle multiple items
return <LinkValue href={links[0].url} title={links[0].text} className={props.className} />;
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { autoColor } from '../Theme';
const getStyles = (divider: boolean) => (theme: GrafanaTheme2) => {
return {
LabeledList: css`
label: LabeledList;
list-style: none;
margin: 0;
padding: 0;
${divider === true &&
`
margin-right: -8px;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
`}
`,
LabeledListItem: css`
label: LabeledListItem;
display: inline-block;
${divider === true &&
`
border-right: 1px solid ${autoColor(theme, '#ddd')};
padding: 0 8px;
`}
`,
LabeledListLabel: css`
label: LabeledListLabel;
color: ${theme.isLight ? '#999' : '#666'};
margin-right: 0.25rem;
`,
LabeledListValue: css`
label: LabeledListValue;
margin-right: 0.55rem;
`,
};
};
type LabeledListProps = {
className?: string;
divider?: boolean;
items: Array<{ key: string; label: React.ReactNode; value: React.ReactNode }>;
};
export default function LabeledList(props: LabeledListProps) {
const { className, divider = false, items } = props;
const styles = useStyles2(getStyles(divider));
return (
<ul className={cx(styles.LabeledList, className)}>
{items.map(({ key, label, value }) => {
return (
<li className={styles.LabeledListItem} key={`${key}`}>
<span className={styles.LabeledListLabel}>{label}</span>
<strong className={styles.LabeledListValue}>{value}</strong>
</li>
);
})}
</ul>
);
}

View File

@@ -1,26 +0,0 @@
// 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 { render } from '@testing-library/react';
import React from 'react';
import NewWindowIcon, { getStyles } from './NewWindowIcon';
describe('NewWindowIcon', () => {
it('adds is-large className when props.isLarge is true', () => {
const { container } = render(<NewWindowIcon isLarge />);
const styles = getStyles();
expect(container.firstChild).toHaveClass(styles.NewWindowIconLarge);
});
});

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2018 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 { css } from '@emotion/css';
import cx from 'classnames';
import React from 'react';
import IoAndroidOpen from 'react-icons/lib/io/android-open';
import { useStyles2 } from '@grafana/ui';
export const getStyles = () => {
return {
NewWindowIconLarge: css`
label: NewWindowIconLarge;
font-size: 1.5em;
`,
};
};
type Props = {
isLarge?: boolean;
className?: string;
};
export default function NewWindowIcon(props: Props) {
const { isLarge, className, ...rest } = props;
const styles = useStyles2(getStyles);
const cls = cx({ [styles.NewWindowIconLarge]: isLarge }, className);
return <IoAndroidOpen className={cls} {...rest} />;
}
NewWindowIcon.defaultProps = {
isLarge: false,
};

View File

@@ -1,39 +0,0 @@
import React, { ReactElement, useRef } from 'react';
import { Popover as GrafanaPopover, PopoverController } from '@grafana/ui';
export type PopoverProps = {
children: ReactElement;
content: ReactElement;
overlayClassName?: string;
};
export function Popover({ children, content, overlayClassName }: PopoverProps) {
const popoverRef = useRef<HTMLElement>(null);
return (
<PopoverController content={content} hideAfter={300}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{popoverRef.current && (
<GrafanaPopover
{...popperProps}
referenceElement={popoverRef.current}
wrapperClassName={overlayClassName}
onMouseLeave={hidePopper}
onMouseEnter={showPopper}
/>
)}
{React.cloneElement(children, {
ref: popoverRef,
onMouseEnter: showPopper,
onMouseLeave: hidePopper,
})}
</>
);
}}
</PopoverController>
);
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2017 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 { css } from '@emotion/css';
import cx from 'classnames';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { FALLBACK_TRACE_NAME } from '../constants';
import { TNil } from '../types';
import BreakableText from './BreakableText';
const getStyles = (theme: GrafanaTheme2) => {
return {
TraceName: css`
label: TraceName;
font-size: ${theme.typography.size.lg};
`,
};
};
type Props = {
className?: string;
traceName?: string | TNil;
};
export default function TraceName(props: Props) {
const { className, traceName } = props;
const styles = useStyles2(getStyles);
const text = String(traceName || FALLBACK_TRACE_NAME);
const title = <BreakableText text={text} />;
return <span className={cx(styles.TraceName, className)}>{title}</span>;
}

View File

@@ -1,36 +0,0 @@
// 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 { render, screen } from '@testing-library/react';
import React from 'react';
import UiFindInput from './UiFindInput';
describe('UiFindInput', () => {
describe('rendering', () => {
it('renders as expected with no value', () => {
render(<UiFindInput />);
const uiFindInput = screen.queryByPlaceholderText('Find...');
expect(uiFindInput).toBeInTheDocument();
expect(uiFindInput?.getAttribute('value')).toEqual('');
});
it('renders as expected with value', () => {
render(<UiFindInput value="value" />);
const uiFindInput = screen.queryByPlaceholderText('Find...');
expect(uiFindInput).toBeInTheDocument();
expect(uiFindInput?.getAttribute('value')).toEqual('value');
});
});
});

View File

@@ -1,62 +0,0 @@
// 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 * as React from 'react';
import { IconButton, Input } from '@grafana/ui';
import { TNil } from '../types/index';
type Props = {
allowClear?: boolean;
inputProps: Record<string, any>;
location: Location;
match: any;
trackFindFunction?: (str: string | TNil) => void;
value: string | undefined;
onChange: (value: string) => void;
};
export default class UiFindInput extends React.PureComponent<Props> {
static defaultProps: Partial<Props> = {
inputProps: {},
trackFindFunction: undefined,
value: undefined,
};
clearUiFind = () => {
this.props.onChange('');
};
render() {
const { allowClear, inputProps, value } = this.props;
const suffix = (
<>
{inputProps.suffix}
{allowClear && value && value.length && <IconButton name="times" onClick={this.clearUiFind} />}
</>
);
return (
<Input
placeholder="Find..."
{...inputProps}
onChange={(e) => this.props.onChange(e.currentTarget.value)}
suffix={suffix}
value={value}
/>
);
}
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2017 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 deepFreeze from 'deep-freeze';
import { FALLBACK_DAG_MAX_NUM_SERVICES } from './index';
export default deepFreeze(
Object.defineProperty(
{
archiveEnabled: false,
dependencies: {
dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES,
menuEnabled: true,
},
linkPatterns: [],
menu: [
{
label: 'About Jaeger',
items: [
{
label: 'GitHub',
url: 'https://github.com/uber/jaeger',
},
{
label: 'Docs',
url: 'http://jaeger.readthedocs.io/en/latest/',
},
{
label: 'Twitter',
url: 'https://twitter.com/JaegerTracing',
},
{
label: 'Discussion Group',
url: 'https://groups.google.com/forum/#!forum/jaeger-tracing',
},
{
label: 'Gitter.im',
url: 'https://gitter.im/jaegertracing/Lobby',
},
{
label: 'Blog',
url: 'https://medium.com/jaegertracing/',
},
],
},
],
search: {
maxLookback: {
label: '2 Days',
value: '2d',
},
maxLimit: 1500,
},
tracking: {
gaID: null,
trackErrors: true,
},
},
// fields that should be individually merged vs wholesale replaced
'__mergeFields',
{ value: ['dependencies', 'search', 'tracking'] }
)
);
export const deprecations = [
{
formerKey: 'dependenciesMenuEnabled',
currentKey: 'dependencies.menuEnabled',
},
{
formerKey: 'gaTrackingID',
currentKey: 'tracking.gaID',
},
];

View File

@@ -1,26 +0,0 @@
// Copyright (c) 2017 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 const FALLBACK_DAG_MAX_NUM_SERVICES = 100 as 100;
export const FALLBACK_TRACE_NAME = '<trace-without-root-span>' as '<trace-without-root-span>';
export const FETCH_DONE = 'FETCH_DONE' as 'FETCH_DONE';
export const FETCH_ERROR = 'FETCH_ERROR' as 'FETCH_ERROR';
export const FETCH_LOADING = 'FETCH_LOADING' as 'FETCH_LOADING';
export const fetchedState = {
DONE: FETCH_DONE,
ERROR: FETCH_ERROR,
LOADING: FETCH_LOADING,
};

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2018 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 const HTTP_METHOD = 'http.method' as 'http.method';
export const PEER_SERVICE = 'peer.service' as 'peer.service';
export const SPAN_KIND = 'span.kind' as 'span.kind';

View File

@@ -1,5 +0,0 @@
{
"rules": {
"import/no-extraneous-dependencies": 0
}
}

View File

@@ -1 +0,0 @@
declare module 'chance';

View File

@@ -1,186 +0,0 @@
// Copyright (c) 2017 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 Chance from 'chance';
import { TraceSpanData, TraceProcess } from 'src/types/trace';
import { getSpanId } from '../selectors/span';
const chance = new Chance();
export const SERVICE_LIST = ['serviceA', 'serviceB', 'serviceC', 'serviceD', 'serviceE', 'serviceF'];
export const OPERATIONS_LIST = [
'GET',
'PUT',
'POST',
'DELETE',
'MySQL::SELECT',
'MySQL::INSERT',
'MongoDB::find',
'MongoDB::update',
];
type Process = TraceProcess & {
processID: string;
};
function setupParentSpan(spans: TraceSpanData[], parentSpanValues: TraceSpanData) {
Object.assign(spans[0], parentSpanValues);
return spans;
}
function getParentSpanId(span: TraceSpanData, levels: string[][]) {
let nestingLevel = chance.integer({ min: 1, max: levels.length });
// pick the correct nesting level if allocated by the levels calculation
levels.forEach((level, idx) => {
if (level.indexOf(getSpanId(span)) >= 0) {
nestingLevel = idx;
}
});
return nestingLevel - 1 >= 0 ? chance.pickone(levels[nestingLevel - 1]) : null;
}
/* this simulates the hierarchy created by CHILD_OF tags */
function attachReferences(spans: TraceSpanData[], depth: number, spansPerLevel: null) {
let levels: string[][] = [[getSpanId(spans[0])]];
const duplicateLevelFilter = (currentLevels: string[][]) => (span: TraceSpanData) =>
!currentLevels.find((level) => level.indexOf(span.spanID) >= 0);
while (levels.length < depth) {
const remainingSpans = spans.filter(duplicateLevelFilter(levels));
if (remainingSpans.length <= 0) {
break;
}
const newLevel = chance.pickset(remainingSpans, spansPerLevel || chance.integer({ min: 4, max: 8 })).map(getSpanId);
levels.push(newLevel);
}
// filter out empty levels
levels = levels.filter((level) => level.length > 0);
return spans.map((span) => {
const parentSpanId = getParentSpanId(span, levels);
return parentSpanId
? {
...span,
references: [
{
refType: 'CHILD_OF',
traceID: span.traceID,
spanID: parentSpanId,
},
],
}
: span;
});
}
export default chance.mixin({
trace({
// long trace
// very short trace
// average case
numberOfSpans = chance.pickone([
Math.ceil(chance.normal({ mean: 200, dev: 10 })) + 1,
Math.ceil(chance.integer({ min: 3, max: 10 })),
Math.ceil(chance.normal({ mean: 45, dev: 15 })) + 1,
]),
numberOfProcesses = chance.integer({ min: 1, max: 10 }),
maxDepth = chance.integer({ min: 1, max: 10 }),
spansPerLevel = null,
}) {
const traceID = chance.guid();
const duration: number = chance.integer({ min: 10000, max: 5000000 });
const timestamp = (new Date().getTime() - chance.integer({ min: 0, max: 1000 }) * 1000) * 1000;
const processArray: Process[] = chance.processes({ numberOfProcesses });
const processes = processArray.reduce((pMap, p) => ({ ...pMap, [p.processID]: p }), {});
let spans = chance.n(chance.span, numberOfSpans, {
traceID,
processes,
traceStartTime: timestamp,
traceEndTime: timestamp + duration,
});
spans = attachReferences(spans, maxDepth, spansPerLevel);
if (spans.length > 1) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
spans = setupParentSpan(spans, { startTime: timestamp, duration } as TraceSpanData);
}
return {
traceID,
spans,
processes,
};
},
tag() {
return {
key: 'http.url',
type: 'String',
value: `/v2/${chance.pickone(['alpha', 'beta', 'gamma'])}/${chance.guid()}`,
};
},
span({
traceID = chance.guid(),
processes = {},
traceStartTime = 0,
traceEndTime = 0,
operations = OPERATIONS_LIST,
}) {
// Set default values for trace start/end time.
traceStartTime = traceStartTime || chance.timestamp() * 1000 * 1000;
traceEndTime = traceEndTime || traceStartTime + 100000;
const startTime = chance.integer({
min: traceStartTime,
max: traceEndTime,
});
const maxDuration = traceEndTime - startTime;
return {
traceID,
processID: chance.pickone(Object.keys(processes)),
spanID: chance.guid(),
flags: 0,
operationName: chance.pickone(operations),
references: [],
startTime,
duration: chance.integer({ min: 1, max: maxDuration <= 1 ? 2 : maxDuration }),
tags: chance.tags(),
logs: [],
};
},
process({ services = SERVICE_LIST }) {
return {
processID: chance.guid(),
serviceName: chance.pickone(services),
tags: chance.tags(),
};
},
traces({ numberOfTraces = chance.integer({ min: 5, max: 15 }) }) {
return chance.n(chance.trace, numberOfTraces, {});
},
tags() {
return chance.n(chance.tag, chance.integer({ min: 1, max: 10 }), {});
},
processes({ numberOfProcesses = chance.integer({ min: 1, max: 25 }) }) {
return chance.n(chance.process, numberOfProcesses, {});
},
});

View File

@@ -1,9 +0,0 @@
export { default as TraceTimelineViewer } from './TraceTimelineViewer';
export { default as TracePageHeader } from './TracePageHeader';
export { default as SpanBarSettings } from './settings/SpanBarSettings';
export * from './types';
export * from './TraceTimelineViewer/types';
export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailState';
export { default as transformTraceData } from './model/transform-trace-data';
export { default as filterSpans } from './utils/filter-spans';
export * from './Theme';

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2017 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.
const keyboardMappings: Record<string, { binding: string | string[]; label: string }> = {
scrollPageDown: { binding: 's', label: 'Scroll down' },
scrollPageUp: { binding: 'w', label: 'Scroll up' },
scrollToNextVisibleSpan: { binding: 'f', label: 'Scroll to the next visible span' },
scrollToPrevVisibleSpan: { binding: 'b', label: 'Scroll to the previous visible span' },
panLeft: { binding: ['a', 'left'], label: 'Pan left' },
panLeftFast: { binding: ['shift+a', 'shift+left'], label: 'Pan left — Large' },
panRight: { binding: ['d', 'right'], label: 'Pan right' },
panRightFast: { binding: ['shift+d', 'shift+right'], label: 'Pan right — Large' },
zoomIn: { binding: 'up', label: 'Zoom in' },
zoomInFast: { binding: 'shift+up', label: 'Zoom in — Large' },
zoomOut: { binding: 'down', label: 'Zoom out' },
zoomOutFast: { binding: 'shift+down', label: 'Zoom out — Large' },
collapseAll: { binding: ']', label: 'Collapse All' },
expandAll: { binding: '[', label: 'Expand All' },
collapseOne: { binding: 'p', label: 'Collapse One Level' },
expandOne: { binding: 'o', label: 'Expand One Level' },
searchSpans: { binding: 'ctrl+b', label: 'Search Spans' },
clearSearch: { binding: 'escape', label: 'Clear Search' },
};
export default keyboardMappings;

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2017 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 Combokeys from 'combokeys';
import * as React from 'react';
import keyboardMappings from './keyboard-mappings';
export type CombokeysHandler =
| (() => void)
| ((event: React.KeyboardEvent<any>) => void)
| ((event: React.KeyboardEvent<any>, s: string) => void);
export type ShortcutCallbacks = {
[name: string]: CombokeysHandler;
};
let instance: Combokeys | undefined;
function getInstance(): Combokeys {
if (instance) {
return instance;
}
const local = new Combokeys(document.body);
instance = local;
return local;
}
export function merge(callbacks: ShortcutCallbacks) {
const inst = getInstance();
Object.keys(callbacks).forEach((name) => {
const keysHandler = callbacks[name];
if (keysHandler) {
inst.bind(keyboardMappings[name].binding, keysHandler);
}
});
}
export function reset() {
const combokeys = getInstance();
combokeys.reset();
}

View File

@@ -1,187 +0,0 @@
// 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 PathElem from './PathElem';
import { simplePath } from './sample-paths.test.resources';
import { TDdgOperation, TDdgPath, TDdgService } from './types';
describe('PathElem', () => {
const getPath = () => {
const path = {
focalIdx: 2,
} as TDdgPath;
const members = simplePath.map(
({ operation, service }, i) =>
new PathElem({
memberIdx: i,
operation: {
name: operation,
service: {
name: service,
} as TDdgService,
} as TDdgOperation,
path,
})
);
members[2].visibilityIdx = 0;
members[3].visibilityIdx = 1;
members[1].visibilityIdx = 2;
members[4].visibilityIdx = 3;
members[0].visibilityIdx = 4;
path.members = members;
return path;
};
const testMemberIdx = 3;
const testOperation = {} as TDdgOperation;
const testPath = {
focalIdx: 4,
members: ['member0', 'member1', 'member2', 'member3', 'member4', 'member5'],
} as unknown as TDdgPath;
const testVisibilityIdx = 105;
let pathElem: PathElem;
beforeEach(() => {
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testMemberIdx });
});
it('initializes instance properties', () => {
expect(pathElem.memberIdx).toBe(testMemberIdx);
expect(pathElem.memberOf).toBe(testPath);
expect(pathElem.operation).toBe(testOperation);
});
it('calculates distance', () => {
expect(pathElem.distance).toBe(-1);
});
it('sets visibilityIdx', () => {
pathElem.visibilityIdx = testVisibilityIdx;
expect(pathElem.visibilityIdx).toBe(testVisibilityIdx);
});
it('errors when trying to access unset visibilityIdx', () => {
expect(() => pathElem.visibilityIdx).toThrowError();
});
it('errors when trying to override visibilityIdx', () => {
pathElem.visibilityIdx = testVisibilityIdx;
expect(() => {
pathElem.visibilityIdx = testVisibilityIdx;
}).toThrowError();
});
it('has externalSideNeighbor if distance is not 0 and it is not external', () => {
expect(pathElem.externalSideNeighbor).toBe(testPath.members[testMemberIdx - 1]);
});
it('has a null externalSideNeighbor if distance is 0', () => {
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testPath.focalIdx });
expect(pathElem.externalSideNeighbor).toBe(null);
});
it('has an undefined externalSideNeighbor if is external', () => {
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: 0 });
expect(pathElem.externalSideNeighbor).toBe(undefined);
});
it('has focalSideNeighbor if distance is not 0', () => {
expect(pathElem.focalSideNeighbor).toBe(testPath.members[testMemberIdx + 1]);
});
it('has a null focalSideNeighbor if distance is 0', () => {
pathElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: testPath.focalIdx });
expect(pathElem.focalSideNeighbor).toBe(null);
});
it('is external if it is first or last PathElem in memberOf.path and not the focalElem', () => {
expect(pathElem.isExternal).toBe(false);
const firstElem = new PathElem({ path: testPath, operation: testOperation, memberIdx: 0 });
expect(firstElem.isExternal).toBe(true);
const lastElem = new PathElem({
path: testPath,
operation: testOperation,
memberIdx: testPath.members.length - 1,
});
expect(lastElem.isExternal).toBe(true);
const path = {
...testPath,
focalIdx: testPath.members.length - 1,
};
const focalElem = new PathElem({ path, operation: testOperation, memberIdx: path.members.length - 1 });
expect(focalElem.isExternal).toBe(false);
});
describe('externalPath', () => {
const path = getPath();
it('returns array of itself if it is focal elem', () => {
const targetPathElem = path.members[path.focalIdx];
expect(targetPathElem.externalPath).toEqual([targetPathElem]);
});
it('returns path away from focal elem in correct order for upstream elem', () => {
const idx = path.focalIdx - 1;
const targetPathElem = path.members[idx];
expect(targetPathElem.externalPath).toEqual(path.members.slice(0, idx + 1));
});
it('returns path away from focal elem in correct order for downstream elem', () => {
const idx = path.focalIdx + 1;
const targetPathElem = path.members[idx];
expect(targetPathElem.externalPath).toEqual(path.members.slice(idx));
});
});
describe('focalPath', () => {
const path = getPath();
it('returns array of itself if it is focal elem', () => {
const targetPathElem = path.members[path.focalIdx];
expect(targetPathElem.focalPath).toEqual([targetPathElem]);
});
it('returns path to focal elem in correct order for upstream elem', () => {
const targetPathElem = path.members[0];
expect(targetPathElem.focalPath).toEqual(path.members.slice(0, path.focalIdx + 1));
});
it('returns path to focal elem in correct order for downstream elem', () => {
const idx = path.members.length - 1;
const targetPathElem = path.members[idx];
expect(targetPathElem.focalPath).toEqual(path.members.slice(path.focalIdx, idx + 1));
});
});
describe('legibility', () => {
const path = getPath();
const targetPathElem = path.members[1];
it('creates consumable JSON', () => {
expect(targetPathElem.toJSON()).toMatchSnapshot();
});
it('creates consumable string', () => {
expect(targetPathElem.toString()).toBe(JSON.stringify(targetPathElem.toJSON(), null, 2));
});
it('creates informative string tag', () => {
expect(Object.prototype.toString.call(targetPathElem)).toEqual(
`[object PathElem ${targetPathElem.visibilityIdx}]`
);
});
});
});

View File

@@ -1,125 +0,0 @@
// 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 { TDdgOperation, TDdgPath } from './types';
export default class PathElem {
memberIdx: number;
memberOf: TDdgPath;
operation: TDdgOperation;
private _visibilityIdx?: number;
constructor({ path, operation, memberIdx }: { path: TDdgPath; operation: TDdgOperation; memberIdx: number }) {
this.memberIdx = memberIdx;
this.memberOf = path;
this.operation = operation;
}
get distance() {
return this.memberIdx - this.memberOf.focalIdx;
}
get externalPath(): PathElem[] {
const result: PathElem[] = [];
let current: PathElem | null | undefined = this;
while (current) {
result.push(current);
current = current.externalSideNeighbor;
}
if (this.distance < 0) {
result.reverse();
}
return result;
}
get externalSideNeighbor(): PathElem | null | undefined {
if (!this.distance) {
return null;
}
return this.memberOf.members[this.memberIdx + Math.sign(this.distance)];
}
get focalPath(): PathElem[] {
const result: PathElem[] = [];
let current: PathElem | null = this;
while (current) {
result.push(current);
current = current.focalSideNeighbor;
}
if (this.distance > 0) {
result.reverse();
}
return result;
}
get focalSideNeighbor(): PathElem | null {
if (!this.distance) {
return null;
}
return this.memberOf.members[this.memberIdx - Math.sign(this.distance)];
}
get isExternal(): boolean {
return Boolean(this.distance) && (this.memberIdx === 0 || this.memberIdx === this.memberOf.members.length - 1);
}
set visibilityIdx(visibilityIdx: number) {
if (this._visibilityIdx == null) {
this._visibilityIdx = visibilityIdx;
} else {
throw new Error('Visibility Index cannot be changed once set');
}
}
get visibilityIdx(): number {
if (this._visibilityIdx == null) {
throw new Error('Visibility Index was never set for this PathElem');
}
return this._visibilityIdx;
}
private toJSONHelper = () => ({
memberIdx: this.memberIdx,
operation: this.operation.name,
service: this.operation.service.name,
visibilityIdx: this._visibilityIdx,
});
/*
* Because the memberOf on a PathElem contains an array of all of its members which in turn all contain
* memberOf back to the path, some assistance is necessary when creating error messages. toJSON is called by
* JSON.stringify and expected to return a JSON object. To that end, this method simplifies the
* representation of the PathElems in memberOf's path to remove the circular reference.
*/
toJSON() {
return {
...this.toJSONHelper(),
memberOf: {
focalIdx: this.memberOf.focalIdx,
members: this.memberOf.members.map((member) => member.toJSONHelper()),
},
};
}
// `toJSON` is called by `JSON.stringify` while `toString` is used by template strings and string concat
toString() {
return JSON.stringify(this.toJSON(), null, 2);
}
// `[Symbol.toStringTag]` is used when attempting to use an object as a key on an object, where a full
// stringified JSON would reduce clarity
get [Symbol.toStringTag]() {
return `PathElem ${this._visibilityIdx}`;
}
}

View File

@@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PathElem legibility creates consumable JSON 1`] = `
{
"memberIdx": 1,
"memberOf": {
"focalIdx": 2,
"members": [
{
"memberIdx": 0,
"operation": "firstOperation",
"service": "firstService",
"visibilityIdx": 4,
},
{
"memberIdx": 1,
"operation": "beforeOperation",
"service": "beforeService",
"visibilityIdx": 2,
},
{
"memberIdx": 2,
"operation": "focalOperation",
"service": "focalService",
"visibilityIdx": 0,
},
{
"memberIdx": 3,
"operation": "afterOperation",
"service": "afterService",
"visibilityIdx": 1,
},
{
"memberIdx": 4,
"operation": "lastOperation",
"service": "lastService",
"visibilityIdx": 3,
},
],
},
"operation": "beforeOperation",
"service": "beforeService",
"visibilityIdx": 2,
}
`;

View File

@@ -1,117 +0,0 @@
// 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.
export const simplePayloadElemMaker = (label: string) => ({
operation: `${label}Operation`,
service: `${label}Service`,
});
export const focalPayloadElem = simplePayloadElemMaker('focal');
const sameFocalServicePayloadElem = {
operation: 'someOtherOperation',
service: focalPayloadElem.service,
};
const pathLengthener = (path: Array<{ operation: string; service: string }>) => {
const prequels: Array<{ operation: string; service: string }> = [];
const sequels: Array<{ operation: string; service: string }> = [];
path.forEach(({ operation, service }) => {
if (operation !== focalPayloadElem.operation && service !== focalPayloadElem.service) {
prequels.push({
operation: `prequel-${operation}`,
service,
});
sequels.push({
operation,
service: `sequel-${service}`,
});
}
});
return [...prequels, ...path, ...sequels];
};
export const firstPayloadElem = simplePayloadElemMaker('first');
export const beforePayloadElem = simplePayloadElemMaker('before');
export const midPayloadElem = simplePayloadElemMaker('mid');
export const afterPayloadElem = simplePayloadElemMaker('after');
export const lastPayloadElem = simplePayloadElemMaker('last');
export const shortPath = [beforePayloadElem, focalPayloadElem];
export const simplePath = [firstPayloadElem, beforePayloadElem, focalPayloadElem, afterPayloadElem, lastPayloadElem];
export const longSimplePath = pathLengthener(simplePath);
export const noFocalPath = [firstPayloadElem, beforePayloadElem, midPayloadElem, afterPayloadElem, lastPayloadElem];
export const doubleFocalPath = [
firstPayloadElem,
beforePayloadElem,
focalPayloadElem,
midPayloadElem,
focalPayloadElem,
afterPayloadElem,
lastPayloadElem,
];
export const almostDoubleFocalPath = [
firstPayloadElem,
beforePayloadElem,
sameFocalServicePayloadElem,
midPayloadElem,
focalPayloadElem,
afterPayloadElem,
lastPayloadElem,
];
const divergentPayloadElem = simplePayloadElemMaker('divergentPayloadElem');
export const convergentPaths = [
[firstPayloadElem, focalPayloadElem, divergentPayloadElem, afterPayloadElem, lastPayloadElem],
[firstPayloadElem, focalPayloadElem, midPayloadElem, afterPayloadElem, lastPayloadElem],
];
const generationPayloadElems = {
afterFocalMid: simplePayloadElemMaker('afterFocalMid'),
afterTarget0: simplePayloadElemMaker('afterTarget0'),
afterTarget1: simplePayloadElemMaker('afterTarget1'),
beforeFocalMid: simplePayloadElemMaker('beforeFocalMid'),
beforeTarget0: simplePayloadElemMaker('beforeTarget0'),
beforeTarget1: simplePayloadElemMaker('beforeTarget1'),
target: simplePayloadElemMaker('target'),
};
export const generationPaths = [
[
generationPayloadElems.beforeTarget0,
generationPayloadElems.target,
generationPayloadElems.beforeFocalMid,
focalPayloadElem,
],
[
generationPayloadElems.beforeTarget1,
generationPayloadElems.target,
generationPayloadElems.beforeFocalMid,
focalPayloadElem,
],
[focalPayloadElem, generationPayloadElems.afterFocalMid, generationPayloadElems.target],
[
focalPayloadElem,
generationPayloadElems.afterFocalMid,
generationPayloadElems.target,
generationPayloadElems.afterTarget0,
],
[
focalPayloadElem,
generationPayloadElems.afterFocalMid,
generationPayloadElems.target,
generationPayloadElems.afterTarget1,
],
[generationPayloadElems.target, generationPayloadElems.beforeFocalMid, focalPayloadElem],
];

View File

@@ -1,46 +0,0 @@
// 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 PathElem from './PathElem';
export { default as PathElem } from './PathElem';
export type TDdgService = {
name: string;
operations: Map<string, TDdgOperation>;
};
export type TDdgOperation = {
name: string;
pathElems: PathElem[];
service: TDdgService;
};
export type TDdgServiceMap = Map<string, TDdgService>;
export type TDdgPath = {
focalIdx: number;
members: PathElem[];
traceIDs: string[];
};
export type TDdgDistanceToPathElems = Map<number, PathElem[]>;
export type TDdgModel = {
distanceToPathElems: TDdgDistanceToPathElems;
hash: string;
paths: TDdgPath[];
services: TDdgServiceMap;
visIdxToPathElem: PathElem[];
};

Some files were not shown because too many files have changed in this diff Show More