mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"rules": {
|
||||
"import/no-extraneous-dependencies": 0
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
declare module 'chance';
|
||||
@@ -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, {});
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}]`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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],
|
||||
];
|
||||
@@ -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
Reference in New Issue
Block a user