[Uptime] Feature/80166 add waterfall flyout (#89449)
* adjust network events * add metaData to data formatting * add useFlyout * adjust waterfall data types * adjust MiddleTruncatedText to use span instead of div * add flyout * adjust sidebar button style * update tests * convert content to use sentence case * pass onBarClick and onProjectionClick as WaterfallChart props * use undefined value for initial flyoutData state * add telemetry * adjust typo in get_network_events * adjust connection time * added space between value and units * adjust flyout spacing, rearrange certificates, and right align values * adjust flyout labels * add focus management support to flyout * improve performance with memoization * add external link to MiddleTruncatedText * update data_formatting function * remove EuiPortal * add moment mock to data_formatting test * adjust data_formatting * adjust network_events runtime types * remove extra space in test tile * toggle flyout on sidebar click * update styling and html for open in new tab resource link * rename metaData to metadata * adjust MiddleTruncatedText styling * adjust WaterfallFlyout heading * adjust waterfall sidebar item types * adjust SidebarItem onClick type * fix license header * align middle truncated text left * move flyout logic to a render prop for better composability * add ip to flyout * update label for bytes downloaded (compressed) * lowercase compressed Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
2e42d18db9
commit
53f4d4840b
|
@ -20,24 +20,36 @@ const NetworkTimingsType = t.type({
|
|||
ssl: t.number,
|
||||
});
|
||||
|
||||
export type NetworkTimings = t.TypeOf<typeof NetworkTimingsType>;
|
||||
const CertificateDataType = t.partial({
|
||||
validFrom: t.number,
|
||||
validTo: t.number,
|
||||
issuer: t.string,
|
||||
subjectName: t.string,
|
||||
});
|
||||
|
||||
const NetworkEventType = t.intersection([
|
||||
t.type({
|
||||
timestamp: t.string,
|
||||
requestSentTime: t.number,
|
||||
loadEndTime: t.number,
|
||||
url: t.string,
|
||||
}),
|
||||
t.partial({
|
||||
bytesDownloadedCompressed: t.number,
|
||||
certificates: CertificateDataType,
|
||||
ip: t.string,
|
||||
method: t.string,
|
||||
url: t.string,
|
||||
status: t.number,
|
||||
mimeType: t.string,
|
||||
requestStartTime: t.number,
|
||||
responseHeaders: t.record(t.string, t.string),
|
||||
requestHeaders: t.record(t.string, t.string),
|
||||
timings: NetworkTimingsType,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type NetworkTimings = t.TypeOf<typeof NetworkTimingsType>;
|
||||
export type CertificateData = t.TypeOf<typeof CertificateDataType>;
|
||||
export type NetworkEvent = t.TypeOf<typeof NetworkEventType>;
|
||||
|
||||
export const SyntheticsNetworkEventsApiResponseType = t.type({
|
||||
|
|
|
@ -4,12 +4,25 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { colourPalette, getSeriesAndDomain, getSidebarItems } from './data_formatting';
|
||||
import { NetworkItems, MimeType } from './types';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
colourPalette,
|
||||
getConnectingTime,
|
||||
getSeriesAndDomain,
|
||||
getSidebarItems,
|
||||
} from './data_formatting';
|
||||
import {
|
||||
NetworkItems,
|
||||
MimeType,
|
||||
FriendlyFlyoutLabels,
|
||||
FriendlyTimingLabels,
|
||||
Timings,
|
||||
Metadata,
|
||||
} from './types';
|
||||
import { mockMoment } from '../../../../../lib/helper/test_helpers';
|
||||
import { WaterfallDataEntry } from '../../waterfall/types';
|
||||
|
||||
const networkItems: NetworkItems = [
|
||||
export const networkItems: NetworkItems = [
|
||||
{
|
||||
timestamp: '2021-01-05T19:22:28.928Z',
|
||||
method: 'GET',
|
||||
|
@ -31,6 +44,20 @@ const networkItems: NetworkItems = [
|
|||
ssl: 55.38700000033714,
|
||||
dns: 3.559999997378327,
|
||||
},
|
||||
bytesDownloadedCompressed: 1000,
|
||||
requestHeaders: {
|
||||
sample_request_header: 'sample request header',
|
||||
},
|
||||
responseHeaders: {
|
||||
sample_response_header: 'sample response header',
|
||||
},
|
||||
certificates: {
|
||||
issuer: 'Sample Issuer',
|
||||
validFrom: 1578441600000,
|
||||
validTo: 1617883200000,
|
||||
subjectName: '*.elastic.co',
|
||||
},
|
||||
ip: '104.18.8.22',
|
||||
},
|
||||
{
|
||||
timestamp: '2021-01-05T19:22:28.928Z',
|
||||
|
@ -56,7 +83,7 @@ const networkItems: NetworkItems = [
|
|||
},
|
||||
];
|
||||
|
||||
const networkItemsWithoutFullTimings: NetworkItems = [
|
||||
export const networkItemsWithoutFullTimings: NetworkItems = [
|
||||
networkItems[0],
|
||||
{
|
||||
timestamp: '2021-01-05T19:22:28.928Z',
|
||||
|
@ -81,7 +108,7 @@ const networkItemsWithoutFullTimings: NetworkItems = [
|
|||
},
|
||||
];
|
||||
|
||||
const networkItemsWithoutAnyTimings: NetworkItems = [
|
||||
export const networkItemsWithoutAnyTimings: NetworkItems = [
|
||||
{
|
||||
timestamp: '2021-01-05T19:22:28.928Z',
|
||||
method: 'GET',
|
||||
|
@ -105,7 +132,7 @@ const networkItemsWithoutAnyTimings: NetworkItems = [
|
|||
},
|
||||
];
|
||||
|
||||
const networkItemsWithoutTimingsObject: NetworkItems = [
|
||||
export const networkItemsWithoutTimingsObject: NetworkItems = [
|
||||
{
|
||||
timestamp: '2021-01-05T19:22:28.928Z',
|
||||
method: 'GET',
|
||||
|
@ -117,7 +144,7 @@ const networkItemsWithoutTimingsObject: NetworkItems = [
|
|||
},
|
||||
];
|
||||
|
||||
const networkItemsWithUncommonMimeType: NetworkItems = [
|
||||
export const networkItemsWithUncommonMimeType: NetworkItems = [
|
||||
{
|
||||
timestamp: '2021-01-05T19:22:28.928Z',
|
||||
method: 'GET',
|
||||
|
@ -142,6 +169,28 @@ const networkItemsWithUncommonMimeType: NetworkItems = [
|
|||
},
|
||||
];
|
||||
|
||||
describe('getConnectingTime', () => {
|
||||
it('returns `connect` value if `ssl` is undefined', () => {
|
||||
expect(getConnectingTime(10)).toBe(10);
|
||||
});
|
||||
|
||||
it('returns `undefined` if `connect` is not defined', () => {
|
||||
expect(getConnectingTime(undefined, 23)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns `connect` value if `ssl` is 0', () => {
|
||||
expect(getConnectingTime(10, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it('returns `connect` value if `ssl` is -1', () => {
|
||||
expect(getConnectingTime(10, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces `connect` value by `ssl` value if both are defined', () => {
|
||||
expect(getConnectingTime(10, 3)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Palettes', () => {
|
||||
it('A colour palette comprising timing and mime type colours is correctly generated', () => {
|
||||
expect(colourPalette).toEqual({
|
||||
|
@ -163,299 +212,326 @@ describe('Palettes', () => {
|
|||
});
|
||||
|
||||
describe('getSeriesAndDomain', () => {
|
||||
it('formats timings', () => {
|
||||
beforeEach(() => {
|
||||
mockMoment();
|
||||
});
|
||||
|
||||
it('formats series timings', () => {
|
||||
const actual = getSeriesAndDomain(networkItems);
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"domain": Object {
|
||||
"max": 140.7760000010603,
|
||||
"min": 0,
|
||||
expect(actual.series).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"value": "Queued / Blocked: 0.854ms",
|
||||
},
|
||||
},
|
||||
"x": 0,
|
||||
"y": 0.8540000017092098,
|
||||
"y0": 0,
|
||||
},
|
||||
"series": Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"value": "Queued / Blocked: 0.854ms",
|
||||
},
|
||||
},
|
||||
"x": 0,
|
||||
"y": 0.8540000017092098,
|
||||
"y0": 0,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#54b399",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#54b399",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#54b399",
|
||||
"value": "DNS: 3.560ms",
|
||||
},
|
||||
"value": "DNS: 3.560ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 4.413999999087537,
|
||||
"y0": 0.8540000017092098,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 4.413999999087537,
|
||||
"y0": 0.8540000017092098,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#da8b45",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#da8b45",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#da8b45",
|
||||
"value": "Connecting: 25.721ms",
|
||||
},
|
||||
"value": "Connecting: 25.721ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 30.135000000882428,
|
||||
"y0": 4.413999999087537,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 30.135000000882428,
|
||||
"y0": 4.413999999087537,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#edc5a2",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#edc5a2",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#edc5a2",
|
||||
"value": "TLS: 55.387ms",
|
||||
},
|
||||
"value": "TLS: 55.387ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 85.52200000121957,
|
||||
"y0": 30.135000000882428,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 85.52200000121957,
|
||||
"y0": 30.135000000882428,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#d36086",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#d36086",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#d36086",
|
||||
"value": "Sending request: 0.360ms",
|
||||
},
|
||||
"value": "Sending request: 0.360ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 85.88200000303914,
|
||||
"y0": 85.52200000121957,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 85.88200000303914,
|
||||
"y0": 85.52200000121957,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"value": "Waiting (TTFB): 34.578ms",
|
||||
},
|
||||
"value": "Waiting (TTFB): 34.578ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 120.4600000019127,
|
||||
"y0": 85.88200000303914,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 120.4600000019127,
|
||||
"y0": 85.88200000303914,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#ca8eae",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#ca8eae",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#ca8eae",
|
||||
"value": "Content downloading (CSS): 0.552ms",
|
||||
},
|
||||
"value": "Content downloading (CSS): 0.552ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 121.01200000324752,
|
||||
"y0": 120.4600000019127,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 121.01200000324752,
|
||||
"y0": 120.4600000019127,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"id": 1,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"value": "Queued / Blocked: 84.546ms",
|
||||
},
|
||||
"value": "Queued / Blocked: 84.546ms",
|
||||
},
|
||||
"x": 1,
|
||||
"y": 84.90799999795854,
|
||||
"y0": 0.3619999997317791,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 1,
|
||||
"y": 84.90799999795854,
|
||||
"y0": 0.3619999997317791,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#d36086",
|
||||
"id": 1,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#d36086",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#d36086",
|
||||
"value": "Sending request: 0.239ms",
|
||||
},
|
||||
"value": "Sending request: 0.239ms",
|
||||
},
|
||||
"x": 1,
|
||||
"y": 85.14699999883305,
|
||||
"y0": 84.90799999795854,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 1,
|
||||
"y": 85.14699999883305,
|
||||
"y0": 84.90799999795854,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"id": 1,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"value": "Waiting (TTFB): 52.561ms",
|
||||
},
|
||||
"value": "Waiting (TTFB): 52.561ms",
|
||||
},
|
||||
"x": 1,
|
||||
"y": 137.70799999925657,
|
||||
"y0": 85.14699999883305,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 1,
|
||||
"y": 137.70799999925657,
|
||||
"y0": 85.14699999883305,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#9170b8",
|
||||
"id": 1,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#9170b8",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#9170b8",
|
||||
"value": "Content downloading (JS): 3.068ms",
|
||||
},
|
||||
"value": "Content downloading (JS): 3.068ms",
|
||||
},
|
||||
"x": 1,
|
||||
"y": 140.7760000010603,
|
||||
"y0": 137.70799999925657,
|
||||
},
|
||||
],
|
||||
"totalHighlightedRequests": 2,
|
||||
}
|
||||
"x": 1,
|
||||
"y": 140.7760000010603,
|
||||
"y0": 137.70799999925657,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles formatting when only total timing values are available', () => {
|
||||
const actual = getSeriesAndDomain(networkItemsWithoutFullTimings);
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"domain": Object {
|
||||
"max": 121.01200000324752,
|
||||
"min": 0,
|
||||
},
|
||||
"series": Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
it('handles series formatting when only total timing values are available', () => {
|
||||
const { series } = getSeriesAndDomain(networkItemsWithoutFullTimings);
|
||||
expect(series).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#dcd4c4",
|
||||
"value": "Queued / Blocked: 0.854ms",
|
||||
},
|
||||
"value": "Queued / Blocked: 0.854ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 0.8540000017092098,
|
||||
"y0": 0,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 0.8540000017092098,
|
||||
"y0": 0,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#54b399",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#54b399",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#54b399",
|
||||
"value": "DNS: 3.560ms",
|
||||
},
|
||||
"value": "DNS: 3.560ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 4.413999999087537,
|
||||
"y0": 0.8540000017092098,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 4.413999999087537,
|
||||
"y0": 0.8540000017092098,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#da8b45",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#da8b45",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#da8b45",
|
||||
"value": "Connecting: 25.721ms",
|
||||
},
|
||||
"value": "Connecting: 25.721ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 30.135000000882428,
|
||||
"y0": 4.413999999087537,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 30.135000000882428,
|
||||
"y0": 4.413999999087537,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#edc5a2",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#edc5a2",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#edc5a2",
|
||||
"value": "TLS: 55.387ms",
|
||||
},
|
||||
"value": "TLS: 55.387ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 85.52200000121957,
|
||||
"y0": 30.135000000882428,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 85.52200000121957,
|
||||
"y0": 30.135000000882428,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#d36086",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#d36086",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#d36086",
|
||||
"value": "Sending request: 0.360ms",
|
||||
},
|
||||
"value": "Sending request: 0.360ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 85.88200000303914,
|
||||
"y0": 85.52200000121957,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 85.88200000303914,
|
||||
"y0": 85.52200000121957,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#b0c9e0",
|
||||
"value": "Waiting (TTFB): 34.578ms",
|
||||
},
|
||||
"value": "Waiting (TTFB): 34.578ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 120.4600000019127,
|
||||
"y0": 85.88200000303914,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 120.4600000019127,
|
||||
"y0": 85.88200000303914,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#ca8eae",
|
||||
"id": 0,
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#ca8eae",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#ca8eae",
|
||||
"value": "Content downloading (CSS): 0.552ms",
|
||||
},
|
||||
"value": "Content downloading (CSS): 0.552ms",
|
||||
},
|
||||
"x": 0,
|
||||
"y": 121.01200000324752,
|
||||
"y0": 120.4600000019127,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"x": 0,
|
||||
"y": 121.01200000324752,
|
||||
"y0": 120.4600000019127,
|
||||
},
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "#9170b8",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#9170b8",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": true,
|
||||
"tooltipProps": Object {
|
||||
"colour": "#9170b8",
|
||||
"value": "Content downloading (JS): 2.793ms",
|
||||
},
|
||||
"value": "Content downloading (JS): 2.793ms",
|
||||
},
|
||||
"x": 1,
|
||||
"y": 3.714999998046551,
|
||||
"y0": 0.9219999983906746,
|
||||
},
|
||||
],
|
||||
"totalHighlightedRequests": 2,
|
||||
}
|
||||
"x": 1,
|
||||
"y": 3.714999998046551,
|
||||
"y0": 0.9219999983906746,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles series formatting when there is no timing information available', () => {
|
||||
const { series } = getSeriesAndDomain(networkItemsWithoutAnyTimings);
|
||||
expect(series).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
"colour": "",
|
||||
"isHighlighted": true,
|
||||
"showTooltip": false,
|
||||
"tooltipProps": undefined,
|
||||
},
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"y0": 0,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
|
@ -467,6 +543,53 @@ describe('getSeriesAndDomain', () => {
|
|||
"max": 0,
|
||||
"min": 0,
|
||||
},
|
||||
"metadata": Array [
|
||||
Object {
|
||||
"certificates": undefined,
|
||||
"details": Array [
|
||||
Object {
|
||||
"name": "Content type",
|
||||
"value": "text/javascript",
|
||||
},
|
||||
Object {
|
||||
"name": "Request start",
|
||||
"value": "0.000 ms",
|
||||
},
|
||||
Object {
|
||||
"name": "DNS",
|
||||
"value": undefined,
|
||||
},
|
||||
Object {
|
||||
"name": "Connecting",
|
||||
"value": undefined,
|
||||
},
|
||||
Object {
|
||||
"name": "TLS",
|
||||
"value": undefined,
|
||||
},
|
||||
Object {
|
||||
"name": "Waiting (TTFB)",
|
||||
"value": undefined,
|
||||
},
|
||||
Object {
|
||||
"name": "Content downloading",
|
||||
"value": undefined,
|
||||
},
|
||||
Object {
|
||||
"name": "Bytes downloaded (compressed)",
|
||||
"value": undefined,
|
||||
},
|
||||
Object {
|
||||
"name": "IP",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
"requestHeaders": undefined,
|
||||
"responseHeaders": undefined,
|
||||
"url": "file:///Users/dominiqueclarke/dev/synthetics/examples/todos/app/app.js",
|
||||
"x": 0,
|
||||
},
|
||||
],
|
||||
"series": Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
|
@ -486,32 +609,24 @@ describe('getSeriesAndDomain', () => {
|
|||
});
|
||||
|
||||
it('handles formatting when the timings object is undefined', () => {
|
||||
const actual = getSeriesAndDomain(networkItemsWithoutTimingsObject);
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"domain": Object {
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
},
|
||||
"series": Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
"isHighlighted": true,
|
||||
"showTooltip": false,
|
||||
},
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"y0": 0,
|
||||
const { series } = getSeriesAndDomain(networkItemsWithoutTimingsObject);
|
||||
expect(series).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"config": Object {
|
||||
"isHighlighted": true,
|
||||
"showTooltip": false,
|
||||
},
|
||||
],
|
||||
"totalHighlightedRequests": 1,
|
||||
}
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"y0": 0,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles formatting when mime type is not mapped to a specific mime type bucket', () => {
|
||||
const actual = getSeriesAndDomain(networkItemsWithUncommonMimeType);
|
||||
const { series } = actual;
|
||||
const { series } = getSeriesAndDomain(networkItemsWithUncommonMimeType);
|
||||
/* verify that raw mime type appears in the tooltip config and that
|
||||
* the colour is mapped to mime type other */
|
||||
const contentDownloadedingConfigItem = series.find((item: WaterfallDataEntry) => {
|
||||
|
@ -527,6 +642,48 @@ describe('getSeriesAndDomain', () => {
|
|||
expect(contentDownloadedingConfigItem).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[FriendlyFlyoutLabels[Metadata.MimeType], 'text/css'],
|
||||
[FriendlyFlyoutLabels[Metadata.RequestStart], '0.000 ms'],
|
||||
[FriendlyTimingLabels[Timings.Dns], '3.560 ms'],
|
||||
[FriendlyTimingLabels[Timings.Connect], '25.721 ms'],
|
||||
[FriendlyTimingLabels[Timings.Ssl], '55.387 ms'],
|
||||
[FriendlyTimingLabels[Timings.Wait], '34.578 ms'],
|
||||
[FriendlyTimingLabels[Timings.Receive], '0.552 ms'],
|
||||
[FriendlyFlyoutLabels[Metadata.BytesDownloadedCompressed], '1.000 KB'],
|
||||
[FriendlyFlyoutLabels[Metadata.IP], '104.18.8.22'],
|
||||
])('handles metadata details formatting', (name, value) => {
|
||||
const { metadata } = getSeriesAndDomain(networkItems);
|
||||
const metadataEntry = metadata[0];
|
||||
expect(
|
||||
metadataEntry.details.find((item) => item.value === value && item.name === name)
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles metadata headers formatting', () => {
|
||||
const { metadata } = getSeriesAndDomain(networkItems);
|
||||
const metadataEntry = metadata[0];
|
||||
metadataEntry.requestHeaders?.forEach((header) => {
|
||||
expect(header).toEqual({ name: header.name, value: header.value });
|
||||
});
|
||||
metadataEntry.responseHeaders?.forEach((header) => {
|
||||
expect(header).toEqual({ name: header.name, value: header.value });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles certificate formatting', () => {
|
||||
const { metadata } = getSeriesAndDomain([networkItems[0]]);
|
||||
const metadataEntry = metadata[0];
|
||||
expect(metadataEntry.certificates).toEqual([
|
||||
{ name: 'Issuer', value: networkItems[0].certificates?.issuer },
|
||||
{ name: 'Valid from', value: moment(networkItems[0].certificates?.validFrom).format('L LT') },
|
||||
{ name: 'Valid until', value: moment(networkItems[0].certificates?.validTo).format('L LT') },
|
||||
{ name: 'Common name', value: networkItems[0].certificates?.subjectName },
|
||||
]);
|
||||
metadataEntry.responseHeaders?.forEach((header) => {
|
||||
expect(header).toEqual({ name: header.name, value: header.value });
|
||||
});
|
||||
});
|
||||
it('counts the total number of highlighted items', () => {
|
||||
// only one CSS file in this array of network Items
|
||||
const actual = getSeriesAndDomain(networkItems, false, '', ['stylesheet']);
|
||||
|
|
|
@ -6,20 +6,23 @@
|
|||
*/
|
||||
|
||||
import { euiPaletteColorBlind } from '@elastic/eui';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
NetworkItems,
|
||||
NetworkItem,
|
||||
FriendlyFlyoutLabels,
|
||||
FriendlyTimingLabels,
|
||||
FriendlyMimetypeLabels,
|
||||
MimeType,
|
||||
MimeTypesMap,
|
||||
Timings,
|
||||
Metadata,
|
||||
TIMING_ORDER,
|
||||
SidebarItems,
|
||||
LegendItems,
|
||||
} from './types';
|
||||
import { WaterfallData } from '../../waterfall';
|
||||
import { WaterfallData, WaterfallMetadata } from '../../waterfall';
|
||||
import { NetworkEvent } from '../../../../../../common/runtime_types';
|
||||
|
||||
export const extractItems = (data: NetworkEvent[]): NetworkItems => {
|
||||
|
@ -71,6 +74,29 @@ export const isHighlightedItem = (
|
|||
return !!(matchQuery && matchFilters);
|
||||
};
|
||||
|
||||
const getFriendlyMetadataValue = ({ value, postFix }: { value?: number; postFix?: string }) => {
|
||||
// value === -1 indicates timing data cannot be extracted
|
||||
if (value === undefined || value === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let formattedValue = formatValueForDisplay(value);
|
||||
|
||||
if (postFix) {
|
||||
formattedValue = `${formattedValue} ${postFix}`;
|
||||
}
|
||||
|
||||
return formattedValue;
|
||||
};
|
||||
|
||||
export const getConnectingTime = (connect?: number, ssl?: number) => {
|
||||
if (ssl && connect && ssl > 0) {
|
||||
return connect - ssl;
|
||||
} else {
|
||||
return connect;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSeriesAndDomain = (
|
||||
items: NetworkItems,
|
||||
onlyHighlighted = false,
|
||||
|
@ -80,34 +106,36 @@ export const getSeriesAndDomain = (
|
|||
const getValueForOffset = (item: NetworkItem) => {
|
||||
return item.requestSentTime;
|
||||
};
|
||||
|
||||
// The earliest point in time a request is sent or started. This will become our notion of "0".
|
||||
const zeroOffset = items.reduce<number>((acc, item) => {
|
||||
const offsetValue = getValueForOffset(item);
|
||||
return offsetValue < acc ? offsetValue : acc;
|
||||
}, Infinity);
|
||||
let zeroOffset = Infinity;
|
||||
items.forEach((i) => (zeroOffset = Math.min(zeroOffset, getValueForOffset(i))));
|
||||
|
||||
const getValue = (timings: NetworkEvent['timings'], timing: Timings) => {
|
||||
if (!timings) return;
|
||||
|
||||
// SSL is a part of the connect timing
|
||||
if (timing === Timings.Connect && timings.ssl > 0) {
|
||||
return timings.connect - timings.ssl;
|
||||
} else {
|
||||
return timings[timing];
|
||||
if (timing === Timings.Connect) {
|
||||
return getConnectingTime(timings.connect, timings.ssl);
|
||||
}
|
||||
return timings[timing];
|
||||
};
|
||||
|
||||
const series: WaterfallData = [];
|
||||
const metadata: WaterfallMetadata = [];
|
||||
let totalHighlightedRequests = 0;
|
||||
|
||||
const series = items.reduce<WaterfallData>((acc, item, index) => {
|
||||
items.forEach((item, index) => {
|
||||
const mimeTypeColour = getColourForMimeType(item.mimeType);
|
||||
const offsetValue = getValueForOffset(item);
|
||||
let currentOffset = offsetValue - zeroOffset;
|
||||
metadata.push(formatMetadata({ item, index, requestStart: currentOffset }));
|
||||
const isHighlighted = isHighlightedItem(item, query, activeFilters);
|
||||
if (isHighlighted) {
|
||||
totalHighlightedRequests++;
|
||||
}
|
||||
|
||||
if (!item.timings) {
|
||||
acc.push({
|
||||
series.push({
|
||||
x: index,
|
||||
y0: 0,
|
||||
y: 0,
|
||||
|
@ -116,14 +144,9 @@ export const getSeriesAndDomain = (
|
|||
showTooltip: false,
|
||||
},
|
||||
});
|
||||
return acc;
|
||||
return;
|
||||
}
|
||||
|
||||
const offsetValue = getValueForOffset(item);
|
||||
const mimeTypeColour = getColourForMimeType(item.mimeType);
|
||||
|
||||
let currentOffset = offsetValue - zeroOffset;
|
||||
|
||||
let timingValueFound = false;
|
||||
|
||||
TIMING_ORDER.forEach((timing) => {
|
||||
|
@ -133,11 +156,12 @@ export const getSeriesAndDomain = (
|
|||
const colour = timing === Timings.Receive ? mimeTypeColour : colourPalette[timing];
|
||||
const y = currentOffset + value;
|
||||
|
||||
acc.push({
|
||||
series.push({
|
||||
x: index,
|
||||
y0: currentOffset,
|
||||
y,
|
||||
config: {
|
||||
id: index,
|
||||
colour,
|
||||
isHighlighted,
|
||||
showTooltip: true,
|
||||
|
@ -161,7 +185,7 @@ export const getSeriesAndDomain = (
|
|||
if (!timingValueFound) {
|
||||
const total = item.timings.total;
|
||||
const hasTotal = total !== -1;
|
||||
acc.push({
|
||||
series.push({
|
||||
x: index,
|
||||
y0: hasTotal ? currentOffset : 0,
|
||||
y: hasTotal ? currentOffset + item.timings.total : 0,
|
||||
|
@ -182,8 +206,7 @@ export const getSeriesAndDomain = (
|
|||
},
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
const yValues = series.map((serie) => serie.y);
|
||||
const domain = { min: 0, max: Math.max(...yValues) };
|
||||
|
@ -193,7 +216,108 @@ export const getSeriesAndDomain = (
|
|||
filteredSeries = series.filter((item) => item.config.isHighlighted);
|
||||
}
|
||||
|
||||
return { series: filteredSeries, domain, totalHighlightedRequests };
|
||||
return { series: filteredSeries, domain, metadata, totalHighlightedRequests };
|
||||
};
|
||||
|
||||
const formatHeaders = (headers?: Record<string, unknown>) => {
|
||||
if (typeof headers === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(headers).map((key) => ({
|
||||
name: key,
|
||||
value: `${headers[key]}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatMetadata = ({
|
||||
item,
|
||||
index,
|
||||
requestStart,
|
||||
}: {
|
||||
item: NetworkItem;
|
||||
index: number;
|
||||
requestStart: number;
|
||||
}) => {
|
||||
const {
|
||||
bytesDownloadedCompressed,
|
||||
certificates,
|
||||
ip,
|
||||
mimeType,
|
||||
requestHeaders,
|
||||
responseHeaders,
|
||||
url,
|
||||
} = item;
|
||||
const { dns, connect, ssl, wait, receive, total } = item.timings || {};
|
||||
const contentDownloaded = receive && receive > 0 ? receive : total;
|
||||
return {
|
||||
x: index,
|
||||
url,
|
||||
requestHeaders: formatHeaders(requestHeaders),
|
||||
responseHeaders: formatHeaders(responseHeaders),
|
||||
certificates: certificates
|
||||
? [
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.CertificateIssuer],
|
||||
value: certificates.issuer,
|
||||
},
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.CertificateIssueDate],
|
||||
value: certificates.validFrom
|
||||
? moment(certificates.validFrom).format('L LT')
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.CertificateExpiryDate],
|
||||
value: certificates.validTo ? moment(certificates.validTo).format('L LT') : undefined,
|
||||
},
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.CertificateSubject],
|
||||
value: certificates.subjectName,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
details: [
|
||||
{ name: FriendlyFlyoutLabels[Metadata.MimeType], value: mimeType },
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.RequestStart],
|
||||
value: getFriendlyMetadataValue({ value: requestStart, postFix: 'ms' }),
|
||||
},
|
||||
{
|
||||
name: FriendlyTimingLabels[Timings.Dns],
|
||||
value: getFriendlyMetadataValue({ value: dns, postFix: 'ms' }),
|
||||
},
|
||||
{
|
||||
name: FriendlyTimingLabels[Timings.Connect],
|
||||
value: getFriendlyMetadataValue({ value: getConnectingTime(connect, ssl), postFix: 'ms' }),
|
||||
},
|
||||
{
|
||||
name: FriendlyTimingLabels[Timings.Ssl],
|
||||
value: getFriendlyMetadataValue({ value: ssl, postFix: 'ms' }),
|
||||
},
|
||||
{
|
||||
name: FriendlyTimingLabels[Timings.Wait],
|
||||
value: getFriendlyMetadataValue({ value: wait, postFix: 'ms' }),
|
||||
},
|
||||
{
|
||||
name: FriendlyTimingLabels[Timings.Receive],
|
||||
value: getFriendlyMetadataValue({
|
||||
value: contentDownloaded,
|
||||
postFix: 'ms',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.BytesDownloadedCompressed],
|
||||
value: getFriendlyMetadataValue({
|
||||
value: bytesDownloadedCompressed ? bytesDownloadedCompressed / 1000 : undefined,
|
||||
postFix: 'KB',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: FriendlyFlyoutLabels[Metadata.IP],
|
||||
value: ip,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const getSidebarItems = (
|
||||
|
@ -206,7 +330,7 @@ export const getSidebarItems = (
|
|||
const isHighlighted = isHighlightedItem(item, query, activeFilters);
|
||||
const offsetIndex = index + 1;
|
||||
const { url, status, method } = item;
|
||||
return { url, status, method, isHighlighted, offsetIndex };
|
||||
return { url, status, method, isHighlighted, offsetIndex, index };
|
||||
});
|
||||
if (onlyHighlighted) {
|
||||
return sideBarItems.filter((item) => item.isHighlighted);
|
||||
|
|
|
@ -18,6 +18,17 @@ export enum Timings {
|
|||
Receive = 'receive',
|
||||
}
|
||||
|
||||
export enum Metadata {
|
||||
BytesDownloadedCompressed = 'bytesDownloadedCompressed',
|
||||
CertificateIssuer = 'certificateIssuer',
|
||||
CertificateIssueDate = 'certificateIssueDate',
|
||||
CertificateExpiryDate = 'certificateExpiryDate',
|
||||
CertificateSubject = 'certificateSubject',
|
||||
IP = 'ip',
|
||||
MimeType = 'mimeType',
|
||||
RequestStart = 'requestStart',
|
||||
}
|
||||
|
||||
export const FriendlyTimingLabels = {
|
||||
[Timings.Blocked]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.timings.blocked',
|
||||
|
@ -51,6 +62,54 @@ export const FriendlyTimingLabels = {
|
|||
),
|
||||
};
|
||||
|
||||
export const FriendlyFlyoutLabels = {
|
||||
[Metadata.MimeType]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.contentType',
|
||||
{
|
||||
defaultMessage: 'Content type',
|
||||
}
|
||||
),
|
||||
[Metadata.RequestStart]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.requestStart',
|
||||
{
|
||||
defaultMessage: 'Request start',
|
||||
}
|
||||
),
|
||||
[Metadata.BytesDownloadedCompressed]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.bytesDownloadedCompressed',
|
||||
{
|
||||
defaultMessage: 'Bytes downloaded (compressed)',
|
||||
}
|
||||
),
|
||||
[Metadata.CertificateIssuer]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateIssuer',
|
||||
{
|
||||
defaultMessage: 'Issuer',
|
||||
}
|
||||
),
|
||||
[Metadata.CertificateIssueDate]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateIssueDate',
|
||||
{
|
||||
defaultMessage: 'Valid from',
|
||||
}
|
||||
),
|
||||
[Metadata.CertificateExpiryDate]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateExpiryDate',
|
||||
{
|
||||
defaultMessage: 'Valid until',
|
||||
}
|
||||
),
|
||||
[Metadata.CertificateSubject]: i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfallChart.labels.metadata.certificateSubject',
|
||||
{
|
||||
defaultMessage: 'Common name',
|
||||
}
|
||||
),
|
||||
[Metadata.IP]: i18n.translate('xpack.uptime.synthetics.waterfallChart.labels.metadata.ip', {
|
||||
defaultMessage: 'IP',
|
||||
}),
|
||||
};
|
||||
|
||||
export const TIMING_ORDER = [
|
||||
Timings.Blocked,
|
||||
Timings.Dns,
|
||||
|
@ -61,6 +120,19 @@ export const TIMING_ORDER = [
|
|||
Timings.Receive,
|
||||
] as const;
|
||||
|
||||
export const META_DATA_ORDER_FLYOUT = [
|
||||
Metadata.MimeType,
|
||||
Timings.Dns,
|
||||
Timings.Connect,
|
||||
Timings.Ssl,
|
||||
Timings.Wait,
|
||||
Timings.Receive,
|
||||
] as const;
|
||||
|
||||
export type CalculatedTimings = {
|
||||
[K in Timings]?: number;
|
||||
};
|
||||
|
||||
export enum MimeType {
|
||||
Html = 'html',
|
||||
Script = 'script',
|
||||
|
@ -155,6 +227,7 @@ export type NetworkItems = NetworkItem[];
|
|||
|
||||
export type SidebarItem = Pick<NetworkItem, 'url' | 'status' | 'method'> & {
|
||||
isHighlighted: boolean;
|
||||
index: number;
|
||||
offsetIndex: number;
|
||||
};
|
||||
export type SidebarItems = SidebarItem[];
|
||||
|
|
|
@ -6,14 +6,12 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { WaterfallChartWrapper } from './waterfall_chart_wrapper';
|
||||
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '../../../../../lib/helper/rtl_helpers';
|
||||
import { WaterfallChartWrapper } from './waterfall_chart_wrapper';
|
||||
import { networkItems as mockNetworkItems } from './data_formatting.test';
|
||||
|
||||
import { extractItems, isHighlightedItem } from './data_formatting';
|
||||
|
||||
import 'jest-canvas-mock';
|
||||
import { BAR_HEIGHT } from '../../waterfall/components/constants';
|
||||
import { MimeType } from './types';
|
||||
import {
|
||||
|
@ -26,8 +24,10 @@ const getHighLightedItems = (query: string, filters: string[]) => {
|
|||
return NETWORK_EVENTS.events.filter((item) => isHighlightedItem(item, query, filters));
|
||||
};
|
||||
|
||||
describe('waterfall chart wrapper', () => {
|
||||
jest.useFakeTimers();
|
||||
describe('WaterfallChartWrapper', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('renders the correct sidebar items', () => {
|
||||
const { getAllByTestId } = render(
|
||||
|
@ -129,6 +129,69 @@ describe('waterfall chart wrapper', () => {
|
|||
expect(queryAllByTestId('sideBarHighlightedItem')).toHaveLength(0);
|
||||
expect(queryAllByTestId('sideBarDimmedItem')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens flyout on sidebar click and closes on flyout close button', async () => {
|
||||
const { getByText, getAllByText, getByTestId, queryByText, getByRole } = render(
|
||||
<WaterfallChartWrapper total={mockNetworkItems.length} data={mockNetworkItems} />
|
||||
);
|
||||
|
||||
expect(getByText(`1. ${mockNetworkItems[0].url}`)).toBeInTheDocument();
|
||||
expect(queryByText('Content type')).not.toBeInTheDocument();
|
||||
expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument();
|
||||
|
||||
// open flyout
|
||||
// selecter matches both button and accessible text. Button is the second element in the array;
|
||||
const sidebarButton = getAllByText(/1./)[1];
|
||||
fireEvent.click(sidebarButton);
|
||||
|
||||
// check for sample flyout items
|
||||
await waitFor(() => {
|
||||
const waterfallFlyout = getByRole('dialog');
|
||||
expect(waterfallFlyout).toBeInTheDocument();
|
||||
expect(getByText('Content type')).toBeInTheDocument();
|
||||
expect(getByText(`${mockNetworkItems[0]?.mimeType}`)).toBeInTheDocument();
|
||||
// close flyout
|
||||
const closeButton = getByTestId('euiFlyoutCloseButton');
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
|
||||
/* check that sample flyout items are gone from the DOM */
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Content type')).not.toBeInTheDocument();
|
||||
expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens flyout on sidebar click and closes on second sidebar click', async () => {
|
||||
const { getByText, getAllByText, getByTestId, queryByText } = render(
|
||||
<WaterfallChartWrapper total={mockNetworkItems.length} data={mockNetworkItems} />
|
||||
);
|
||||
|
||||
expect(getByText(`1. ${mockNetworkItems[0].url}`)).toBeInTheDocument();
|
||||
expect(queryByText('Content type')).not.toBeInTheDocument();
|
||||
expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument();
|
||||
|
||||
// open flyout
|
||||
// selecter matches both button and accessible text. Button is the second element in the array;
|
||||
const sidebarButton = getAllByText(/1./)[1];
|
||||
fireEvent.click(sidebarButton);
|
||||
|
||||
// check for sample flyout items and that the flyout is focused
|
||||
await waitFor(() => {
|
||||
const waterfallFlyout = getByTestId('waterfallFlyout');
|
||||
expect(waterfallFlyout).toBeInTheDocument();
|
||||
expect(getByText('Content type')).toBeInTheDocument();
|
||||
expect(getByText(`${mockNetworkItems[0]?.mimeType}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(sidebarButton);
|
||||
|
||||
/* check that sample flyout items are gone from the DOM */
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Content type')).not.toBeInTheDocument();
|
||||
expect(queryByText(`${mockNetworkItems[0]?.mimeType}`)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const NETWORK_EVENTS = {
|
||||
|
|
|
@ -7,11 +7,12 @@
|
|||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { EuiHealth } from '@elastic/eui';
|
||||
import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public';
|
||||
import { getSeriesAndDomain, getSidebarItems, getLegendItems } from './data_formatting';
|
||||
import { SidebarItem, LegendItem, NetworkItems } from './types';
|
||||
import { WaterfallProvider, WaterfallChart, RenderItem } from '../../waterfall';
|
||||
import { WaterfallProvider, WaterfallChart, RenderItem, useFlyout } from '../../waterfall';
|
||||
import { useTrackMetric, METRIC_TYPE } from '../../../../../../../observability/public';
|
||||
import { WaterfallFilter } from './waterfall_filter';
|
||||
import { WaterfallFlyout } from './waterfall_flyout';
|
||||
import { WaterfallSidebarItem } from './waterfall_sidebar_item';
|
||||
|
||||
export const renderLegendItem: RenderItem<LegendItem> = (item) => {
|
||||
|
@ -32,7 +33,7 @@ export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
|
|||
|
||||
const hasFilters = activeFilters.length > 0;
|
||||
|
||||
const { series, domain, totalHighlightedRequests } = useMemo(() => {
|
||||
const { series, domain, metadata, totalHighlightedRequests } = useMemo(() => {
|
||||
return getSeriesAndDomain(networkData, onlyHighlighted, query, activeFilters);
|
||||
}, [networkData, query, activeFilters, onlyHighlighted]);
|
||||
|
||||
|
@ -40,7 +41,18 @@ export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
|
|||
return getSidebarItems(networkData, onlyHighlighted, query, activeFilters);
|
||||
}, [networkData, query, activeFilters, onlyHighlighted]);
|
||||
|
||||
const legendItems = getLegendItems();
|
||||
const legendItems = useMemo(() => {
|
||||
return getLegendItems();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
flyoutData,
|
||||
onBarClick,
|
||||
onProjectionClick,
|
||||
onSidebarClick,
|
||||
isFlyoutVisible,
|
||||
onFlyoutClose,
|
||||
} = useFlyout(metadata);
|
||||
|
||||
const renderFilter = useCallback(() => {
|
||||
return (
|
||||
|
@ -55,16 +67,27 @@ export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
|
|||
);
|
||||
}, [activeFilters, setActiveFilters, onlyHighlighted, setOnlyHighlighted, query, setQuery]);
|
||||
|
||||
const renderFlyout = useCallback(() => {
|
||||
return (
|
||||
<WaterfallFlyout
|
||||
flyoutData={flyoutData}
|
||||
onFlyoutClose={onFlyoutClose}
|
||||
isFlyoutVisible={isFlyoutVisible}
|
||||
/>
|
||||
);
|
||||
}, [flyoutData, isFlyoutVisible, onFlyoutClose]);
|
||||
|
||||
const renderSidebarItem: RenderItem<SidebarItem> = useCallback(
|
||||
(item) => {
|
||||
return (
|
||||
<WaterfallSidebarItem
|
||||
item={item}
|
||||
renderFilterScreenReaderText={hasFilters && !onlyHighlighted}
|
||||
onClick={onSidebarClick}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[hasFilters, onlyHighlighted]
|
||||
[hasFilters, onlyHighlighted, onSidebarClick]
|
||||
);
|
||||
|
||||
useTrackMetric({ app: 'uptime', metric: 'waterfall_chart_view', metricType: METRIC_TYPE.COUNT });
|
||||
|
@ -81,17 +104,21 @@ export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
|
|||
fetchedNetworkRequests={networkData.length}
|
||||
highlightedNetworkRequests={totalHighlightedRequests}
|
||||
data={series}
|
||||
onElementClick={useCallback(onBarClick, [onBarClick])}
|
||||
onProjectionClick={useCallback(onProjectionClick, [onProjectionClick])}
|
||||
onSidebarClick={onSidebarClick}
|
||||
showOnlyHighlightedNetworkRequests={onlyHighlighted}
|
||||
sidebarItems={sidebarItems}
|
||||
legendItems={legendItems}
|
||||
renderTooltipItem={(tooltipProps) => {
|
||||
metadata={metadata}
|
||||
renderTooltipItem={useCallback((tooltipProps) => {
|
||||
return <EuiHealth color={String(tooltipProps?.colour)}>{tooltipProps?.value}</EuiHealth>;
|
||||
}}
|
||||
}, [])}
|
||||
>
|
||||
<WaterfallChart
|
||||
tickFormat={(d: number) => `${Number(d).toFixed(0)} ms`}
|
||||
tickFormat={useCallback((d: number) => `${Number(d).toFixed(0)} ms`, [])}
|
||||
domain={domain}
|
||||
barStyleAccessor={(datum) => {
|
||||
barStyleAccessor={useCallback((datum) => {
|
||||
if (!datum.datum.config.isHighlighted) {
|
||||
return {
|
||||
rect: {
|
||||
|
@ -101,9 +128,10 @@ export const WaterfallChartWrapper: React.FC<Props> = ({ data, total }) => {
|
|||
};
|
||||
}
|
||||
return datum.datum.config.colour;
|
||||
}}
|
||||
}, [])}
|
||||
renderSidebarItem={renderSidebarItem}
|
||||
renderLegendItem={renderLegendItem}
|
||||
renderFlyout={renderFlyout}
|
||||
renderFilter={renderFilter}
|
||||
fullHeight={true}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../../../../lib/helper/rtl_helpers';
|
||||
import {
|
||||
WaterfallFlyout,
|
||||
DETAILS,
|
||||
CERTIFICATES,
|
||||
REQUEST_HEADERS,
|
||||
RESPONSE_HEADERS,
|
||||
} from './waterfall_flyout';
|
||||
import { WaterfallMetadataEntry } from '../../waterfall/types';
|
||||
|
||||
describe('WaterfallFlyout', () => {
|
||||
const flyoutData: WaterfallMetadataEntry = {
|
||||
x: 0,
|
||||
url: 'http://elastic.co',
|
||||
requestHeaders: undefined,
|
||||
responseHeaders: undefined,
|
||||
certificates: undefined,
|
||||
details: [
|
||||
{
|
||||
name: 'Content type',
|
||||
value: 'text/html',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
flyoutData,
|
||||
isFlyoutVisible: true,
|
||||
onFlyoutClose: () => null,
|
||||
};
|
||||
|
||||
it('displays flyout information and omits sections that are undefined', () => {
|
||||
const { getByText, queryByText } = render(<WaterfallFlyout {...defaultProps} />);
|
||||
|
||||
expect(getByText(flyoutData.url)).toBeInTheDocument();
|
||||
expect(queryByText(DETAILS)).toBeInTheDocument();
|
||||
flyoutData.details.forEach((detail) => {
|
||||
expect(getByText(detail.name)).toBeInTheDocument();
|
||||
expect(getByText(`${detail.value}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(queryByText(CERTIFICATES)).not.toBeInTheDocument();
|
||||
expect(queryByText(REQUEST_HEADERS)).not.toBeInTheDocument();
|
||||
expect(queryByText(RESPONSE_HEADERS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays flyout certificates information', () => {
|
||||
const certificates = [
|
||||
{
|
||||
name: 'Issuer',
|
||||
value: 'Sample Issuer',
|
||||
},
|
||||
{
|
||||
name: 'Valid From',
|
||||
value: 'January 1, 2020 7:00PM',
|
||||
},
|
||||
{
|
||||
name: 'Valid Until',
|
||||
value: 'January 31, 2020 7:00PM',
|
||||
},
|
||||
{
|
||||
name: 'Common Name',
|
||||
value: '*.elastic.co',
|
||||
},
|
||||
];
|
||||
const flyoutDataWithCertificates = {
|
||||
...flyoutData,
|
||||
certificates,
|
||||
};
|
||||
|
||||
const { getByText } = render(
|
||||
<WaterfallFlyout {...defaultProps} flyoutData={flyoutDataWithCertificates} />
|
||||
);
|
||||
|
||||
expect(getByText(flyoutData.url)).toBeInTheDocument();
|
||||
expect(getByText(DETAILS)).toBeInTheDocument();
|
||||
expect(getByText(CERTIFICATES)).toBeInTheDocument();
|
||||
flyoutData.certificates?.forEach((detail) => {
|
||||
expect(getByText(detail.name)).toBeInTheDocument();
|
||||
expect(getByText(`${detail.value}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays flyout request and response headers information', () => {
|
||||
const requestHeaders = [
|
||||
{
|
||||
name: 'sample_request_header',
|
||||
value: 'Sample Request Header value',
|
||||
},
|
||||
];
|
||||
const responseHeaders = [
|
||||
{
|
||||
name: 'sample_response_header',
|
||||
value: 'sample response header value',
|
||||
},
|
||||
];
|
||||
const flyoutDataWithHeaders = {
|
||||
...flyoutData,
|
||||
requestHeaders,
|
||||
responseHeaders,
|
||||
};
|
||||
const { getByText } = render(
|
||||
<WaterfallFlyout {...defaultProps} flyoutData={flyoutDataWithHeaders} />
|
||||
);
|
||||
|
||||
expect(getByText(flyoutData.url)).toBeInTheDocument();
|
||||
expect(getByText(DETAILS)).toBeInTheDocument();
|
||||
expect(getByText(REQUEST_HEADERS)).toBeInTheDocument();
|
||||
expect(getByText(RESPONSE_HEADERS)).toBeInTheDocument();
|
||||
flyoutData.requestHeaders?.forEach((detail) => {
|
||||
expect(getByText(detail.name)).toBeInTheDocument();
|
||||
expect(getByText(`${detail.value}`)).toBeInTheDocument();
|
||||
});
|
||||
flyoutData.responseHeaders?.forEach((detail) => {
|
||||
expect(getByText(detail.name)).toBeInTheDocument();
|
||||
expect(getByText(`${detail.value}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders null when isFlyoutVisible is false', () => {
|
||||
const { queryByText } = render(<WaterfallFlyout {...defaultProps} isFlyoutVisible={false} />);
|
||||
|
||||
expect(queryByText(flyoutData.url)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders null when flyoutData is undefined', () => {
|
||||
const { queryByText } = render(<WaterfallFlyout {...defaultProps} flyoutData={undefined} />);
|
||||
|
||||
expect(queryByText(flyoutData.url)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutBody,
|
||||
EuiTitle,
|
||||
EuiSpacer,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Table } from '../../waterfall/components/waterfall_flyout_table';
|
||||
import { MiddleTruncatedText } from '../../waterfall';
|
||||
import { WaterfallMetadataEntry } from '../../waterfall/types';
|
||||
import { OnFlyoutClose } from '../../waterfall/components/use_flyout';
|
||||
import { METRIC_TYPE, useUiTracker } from '../../../../../../../observability/public';
|
||||
|
||||
export const DETAILS = i18n.translate('xpack.uptime.synthetics.waterfall.flyout.details', {
|
||||
defaultMessage: 'Details',
|
||||
});
|
||||
|
||||
export const CERTIFICATES = i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfall.flyout.certificates',
|
||||
{
|
||||
defaultMessage: 'Certificate headers',
|
||||
}
|
||||
);
|
||||
|
||||
export const REQUEST_HEADERS = i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfall.flyout.requestHeaders',
|
||||
{
|
||||
defaultMessage: 'Request headers',
|
||||
}
|
||||
);
|
||||
|
||||
export const RESPONSE_HEADERS = i18n.translate(
|
||||
'xpack.uptime.synthetics.waterfall.flyout.responseHeaders',
|
||||
{
|
||||
defaultMessage: 'Response headers',
|
||||
}
|
||||
);
|
||||
|
||||
const FlyoutContainer = styled(EuiFlyout)`
|
||||
z-index: ${(props) => props.theme.eui.euiZLevel5};
|
||||
`;
|
||||
|
||||
export interface WaterfallFlyoutProps {
|
||||
flyoutData?: WaterfallMetadataEntry;
|
||||
onFlyoutClose: OnFlyoutClose;
|
||||
isFlyoutVisible?: boolean;
|
||||
}
|
||||
|
||||
export const WaterfallFlyout = ({
|
||||
flyoutData,
|
||||
isFlyoutVisible,
|
||||
onFlyoutClose,
|
||||
}: WaterfallFlyoutProps) => {
|
||||
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||
const trackMetric = useUiTracker({ app: 'uptime' });
|
||||
|
||||
useEffect(() => {
|
||||
if (isFlyoutVisible && flyoutData && flyoutRef.current) {
|
||||
flyoutRef.current?.focus();
|
||||
}
|
||||
}, [flyoutData, isFlyoutVisible, flyoutRef]);
|
||||
|
||||
if (!flyoutData || !isFlyoutVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { url, details, certificates, requestHeaders, responseHeaders } = flyoutData;
|
||||
|
||||
trackMetric({ metric: 'waterfall_flyout', metricType: METRIC_TYPE.CLICK });
|
||||
|
||||
return (
|
||||
<div
|
||||
tab-index={-1}
|
||||
ref={flyoutRef}
|
||||
data-test-subj="waterfallFlyout"
|
||||
aria-labelledby="flyoutTitle"
|
||||
>
|
||||
<FlyoutContainer size="s" onClose={onFlyoutClose}>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 id="flyoutTitle">
|
||||
<EuiFlexItem>
|
||||
<MiddleTruncatedText text={url} url={url} ariaLabel={url} />
|
||||
</EuiFlexItem>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<Table rows={details} title={DETAILS} />
|
||||
{!!requestHeaders && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<Table rows={requestHeaders} title={REQUEST_HEADERS} />
|
||||
</>
|
||||
)}
|
||||
{!!responseHeaders && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<Table rows={responseHeaders} title={RESPONSE_HEADERS} />
|
||||
</>
|
||||
)}
|
||||
{!!certificates && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<Table rows={certificates} title={CERTIFICATES} />
|
||||
</>
|
||||
)}
|
||||
</EuiFlyoutBody>
|
||||
</FlyoutContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -5,20 +5,35 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { RefObject, useMemo, useCallback, useState } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui';
|
||||
import { SidebarItem } from '../waterfall/types';
|
||||
import { MiddleTruncatedText } from '../../waterfall';
|
||||
import { SideBarItemHighlighter } from '../../waterfall/components/styles';
|
||||
import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations';
|
||||
import { OnSidebarClick } from '../../waterfall/components/use_flyout';
|
||||
|
||||
interface SidebarItemProps {
|
||||
item: SidebarItem;
|
||||
renderFilterScreenReaderText?: boolean;
|
||||
onClick?: OnSidebarClick;
|
||||
}
|
||||
|
||||
export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: SidebarItemProps) => {
|
||||
const { status, offsetIndex, isHighlighted } = item;
|
||||
export const WaterfallSidebarItem = ({
|
||||
item,
|
||||
renderFilterScreenReaderText,
|
||||
onClick,
|
||||
}: SidebarItemProps) => {
|
||||
const [buttonRef, setButtonRef] = useState<RefObject<HTMLButtonElement | null>>();
|
||||
const { status, offsetIndex, index, isHighlighted, url } = item;
|
||||
|
||||
const handleSidebarClick = useMemo(() => {
|
||||
if (onClick) {
|
||||
return () => onClick({ buttonRef, networkItemIndex: index });
|
||||
}
|
||||
}, [buttonRef, index, onClick]);
|
||||
|
||||
const setRef = useCallback((ref) => setButtonRef(ref), [setButtonRef]);
|
||||
|
||||
const isErrorStatusCode = (statusCode: number) => {
|
||||
const is400 = statusCode >= 400 && statusCode <= 499;
|
||||
|
@ -40,11 +55,23 @@ export const WaterfallSidebarItem = ({ item, renderFilterScreenReaderText }: Sid
|
|||
data-test-subj={isHighlighted ? 'sideBarHighlightedItem' : 'sideBarDimmedItem'}
|
||||
>
|
||||
{!status || !isErrorStatusCode(status) ? (
|
||||
<MiddleTruncatedText text={text} ariaLabel={ariaLabel} />
|
||||
<MiddleTruncatedText
|
||||
text={text}
|
||||
url={url}
|
||||
ariaLabel={ariaLabel}
|
||||
onClick={handleSidebarClick}
|
||||
setButtonRef={setRef}
|
||||
/>
|
||||
) : (
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<MiddleTruncatedText text={text} ariaLabel={ariaLabel} />
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false} style={{ minWidth: 0 }}>
|
||||
<MiddleTruncatedText
|
||||
text={text}
|
||||
url={url}
|
||||
ariaLabel={ariaLabel}
|
||||
onClick={handleSidebarClick}
|
||||
setButtonRef={setRef}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem component="span" grow={false}>
|
||||
<EuiBadge color="danger">{status}</EuiBadge>
|
||||
|
|
|
@ -6,20 +6,22 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SidebarItem } from '../waterfall/types';
|
||||
|
||||
import { render } from '../../../../../lib/helper/rtl_helpers';
|
||||
|
||||
import 'jest-canvas-mock';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import { SidebarItem } from '../waterfall/types';
|
||||
import { render } from '../../../../../lib/helper/rtl_helpers';
|
||||
import { WaterfallSidebarItem } from './waterfall_sidebar_item';
|
||||
import { SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL } from '../../waterfall/components/translations';
|
||||
|
||||
describe('waterfall filter', () => {
|
||||
const url = 'http://www.elastic.co';
|
||||
const offsetIndex = 1;
|
||||
const index = 0;
|
||||
const offsetIndex = index + 1;
|
||||
const item: SidebarItem = {
|
||||
url,
|
||||
isHighlighted: true,
|
||||
index,
|
||||
offsetIndex,
|
||||
};
|
||||
|
||||
|
@ -40,12 +42,14 @@ describe('waterfall filter', () => {
|
|||
});
|
||||
|
||||
it('does not render screen reader text when renderFilterScreenReaderText is false', () => {
|
||||
const { queryByLabelText } = render(
|
||||
<WaterfallSidebarItem item={item} renderFilterScreenReaderText={false} />
|
||||
const onClick = jest.fn();
|
||||
const { getByRole } = render(
|
||||
<WaterfallSidebarItem item={item} renderFilterScreenReaderText={false} onClick={onClick} />
|
||||
);
|
||||
const button = getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(
|
||||
queryByLabelText(`${SIDEBAR_FILTER_MATCHES_SCREENREADER_LABEL} ${offsetIndex}. ${url}`)
|
||||
).not.toBeInTheDocument();
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(onClick).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { getChunks, MiddleTruncatedText } from './middle_truncated_text';
|
||||
import { render, within } from '@testing-library/react';
|
||||
import { render, within, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const longString =
|
||||
|
@ -25,9 +25,10 @@ describe('getChunks', () => {
|
|||
});
|
||||
|
||||
describe('Component', () => {
|
||||
const url = 'http://www.elastic.co';
|
||||
it('renders truncated text and aria label', () => {
|
||||
const { getByText, getByLabelText } = render(
|
||||
<MiddleTruncatedText text={longString} ariaLabel={longString} />
|
||||
<MiddleTruncatedText text={longString} ariaLabel={longString} url={url} />
|
||||
);
|
||||
|
||||
expect(getByText(first)).toBeInTheDocument();
|
||||
|
@ -38,11 +39,39 @@ describe('Component', () => {
|
|||
|
||||
it('renders screen reader only text', () => {
|
||||
const { getByTestId } = render(
|
||||
<MiddleTruncatedText text={longString} ariaLabel={longString} />
|
||||
<MiddleTruncatedText text={longString} ariaLabel={longString} url={url} />
|
||||
);
|
||||
|
||||
const { getByText } = within(getByTestId('middleTruncatedTextSROnly'));
|
||||
|
||||
expect(getByText(longString)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders external link', () => {
|
||||
const { getByText } = render(
|
||||
<MiddleTruncatedText text={longString} ariaLabel={longString} url={url} />
|
||||
);
|
||||
const link = getByText('Open resource in new tab').closest('a');
|
||||
|
||||
expect(link).toHaveAttribute('href', url);
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('renders a button when onClick function is passed', async () => {
|
||||
const handleClick = jest.fn();
|
||||
const { getByTestId } = render(
|
||||
<MiddleTruncatedText
|
||||
text={longString}
|
||||
ariaLabel={longString}
|
||||
url={url}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
const button = getByTestId('middleTruncatedTextButton');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleClick).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,41 +7,57 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiScreenReaderOnly, EuiToolTip, EuiButtonEmpty, EuiLink } from '@elastic/eui';
|
||||
import { FIXED_AXIS_HEIGHT } from './constants';
|
||||
|
||||
interface Props {
|
||||
ariaLabel: string;
|
||||
text: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
setButtonRef?: (ref: HTMLButtonElement | HTMLAnchorElement | null) => void;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const OuterContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
const OuterContainer = styled.span`
|
||||
position: relative;
|
||||
`;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
.euiToolTipAnchor {
|
||||
min-width: 0;
|
||||
}
|
||||
`; // NOTE: min-width: 0 ensures flexbox and no-wrap children can co-exist
|
||||
|
||||
const InnerContainer = styled.span`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
`; // NOTE: min-width: 0 ensures flexbox and no-wrap children can co-exist
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const FirstChunk = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
line-height: ${FIXED_AXIS_HEIGHT}px;
|
||||
`;
|
||||
text-align: left;
|
||||
`; // safari doesn't auto align text left in some cases
|
||||
|
||||
const LastChunk = styled.span`
|
||||
flex-shrink: 0;
|
||||
line-height: ${FIXED_AXIS_HEIGHT}px;
|
||||
text-align: left;
|
||||
`; // safari doesn't auto align text left in some cases
|
||||
|
||||
const StyledButton = styled(EuiButtonEmpty)`
|
||||
&&& {
|
||||
height: auto;
|
||||
border: none;
|
||||
|
||||
.euiButtonContent {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const getChunks = (text: string) => {
|
||||
|
@ -55,24 +71,49 @@ export const getChunks = (text: string) => {
|
|||
// Helper component for adding middle text truncation, e.g.
|
||||
// really-really-really-long....ompressed.js
|
||||
// Can be used to accomodate content in sidebar item rendering.
|
||||
export const MiddleTruncatedText = ({ ariaLabel, text }: Props) => {
|
||||
export const MiddleTruncatedText = ({ ariaLabel, text, onClick, setButtonRef, url }: Props) => {
|
||||
const chunks = useMemo(() => {
|
||||
return getChunks(text);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OuterContainer aria-label={ariaLabel} data-test-subj="middleTruncatedTextContainer">
|
||||
<EuiScreenReaderOnly>
|
||||
<span data-test-subj="middleTruncatedTextSROnly">{text}</span>
|
||||
</EuiScreenReaderOnly>
|
||||
<EuiToolTip content={text} position="top" data-test-subj="middleTruncatedTextToolTip">
|
||||
<InnerContainer aria-hidden={true}>
|
||||
<FirstChunk>{chunks.first}</FirstChunk>
|
||||
<LastChunk>{chunks.last}</LastChunk>
|
||||
</InnerContainer>
|
||||
</EuiToolTip>
|
||||
</OuterContainer>
|
||||
</>
|
||||
<OuterContainer aria-label={ariaLabel} data-test-subj="middleTruncatedTextContainer">
|
||||
<EuiScreenReaderOnly>
|
||||
<span data-test-subj="middleTruncatedTextSROnly">{text}</span>
|
||||
</EuiScreenReaderOnly>
|
||||
<EuiToolTip content={text} position="top" data-test-subj="middleTruncatedTextToolTip">
|
||||
<>
|
||||
{onClick ? (
|
||||
<StyledButton
|
||||
onClick={onClick}
|
||||
data-test-subj="middleTruncatedTextButton"
|
||||
buttonRef={setButtonRef}
|
||||
>
|
||||
<InnerContainer>
|
||||
<FirstChunk>{chunks.first}</FirstChunk>
|
||||
<LastChunk>{chunks.last}</LastChunk>
|
||||
</InnerContainer>
|
||||
</StyledButton>
|
||||
) : (
|
||||
<InnerContainer aria-hidden={true}>
|
||||
<FirstChunk>{chunks.first}</FirstChunk>
|
||||
<LastChunk>{chunks.last}</LastChunk>
|
||||
</InnerContainer>
|
||||
)}
|
||||
</>
|
||||
</EuiToolTip>
|
||||
<span>
|
||||
<EuiLink href={url} external target="_blank">
|
||||
<EuiScreenReaderOnly>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id="xpack.uptime.synthetics.waterfall.resource.externalLink"
|
||||
defaultMessage="Open resource in new tab"
|
||||
/>
|
||||
</span>
|
||||
</EuiScreenReaderOnly>
|
||||
</EuiLink>
|
||||
</span>
|
||||
</OuterContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,15 +5,15 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexItem } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants';
|
||||
import { IWaterfallContext } from '../context/waterfall_chart';
|
||||
import { IWaterfallContext, useWaterfallContext } from '../context/waterfall_chart';
|
||||
import {
|
||||
WaterfallChartSidebarContainer,
|
||||
WaterfallChartSidebarContainerInnerPanel,
|
||||
WaterfallChartSidebarContainerFlexGroup,
|
||||
WaterfallChartSidebarFlexItem,
|
||||
WaterfallChartSidebarWrapper,
|
||||
} from './styles';
|
||||
import { WaterfallChartProps } from './waterfall_chart';
|
||||
|
||||
|
@ -23,8 +23,11 @@ interface SidebarProps {
|
|||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ items, render }) => {
|
||||
const { onSidebarClick } = useWaterfallContext();
|
||||
const handleSidebarClick = useMemo(() => onSidebarClick, [onSidebarClick]);
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={SIDEBAR_GROW_SIZE}>
|
||||
<WaterfallChartSidebarWrapper grow={SIDEBAR_GROW_SIZE}>
|
||||
<WaterfallChartSidebarContainer
|
||||
height={items.length * FIXED_AXIS_HEIGHT}
|
||||
data-test-subj="wfSidebarContainer"
|
||||
|
@ -35,14 +38,16 @@ export const Sidebar: React.FC<SidebarProps> = ({ items, render }) => {
|
|||
gutterSize="none"
|
||||
responsive={false}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<WaterfallChartSidebarFlexItem key={item.offsetIndex}>
|
||||
{render(item)}
|
||||
</WaterfallChartSidebarFlexItem>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<WaterfallChartSidebarFlexItem key={index}>
|
||||
{render(item, index, handleSidebarClick)}
|
||||
</WaterfallChartSidebarFlexItem>
|
||||
);
|
||||
})}
|
||||
</WaterfallChartSidebarContainerFlexGroup>
|
||||
</WaterfallChartSidebarContainerInnerPanel>
|
||||
</WaterfallChartSidebarContainer>
|
||||
</EuiFlexItem>
|
||||
</WaterfallChartSidebarWrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanelProps } from '@elastic/eui';
|
||||
import { rgba } from 'polished';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { StyledComponent } from 'styled-components';
|
||||
import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiPanelProps } from '@elastic/eui';
|
||||
import { rgba } from 'polished';
|
||||
import { FIXED_AXIS_HEIGHT, SIDEBAR_GROW_SIZE } from './constants';
|
||||
import { euiStyled, EuiTheme } from '../../../../../../../../../src/plugins/kibana_react/common';
|
||||
import { FIXED_AXIS_HEIGHT } from './constants';
|
||||
|
||||
interface WaterfallChartOuterContainerProps {
|
||||
height?: string;
|
||||
|
@ -82,6 +82,11 @@ interface WaterfallChartSidebarContainer {
|
|||
height: number;
|
||||
}
|
||||
|
||||
export const WaterfallChartSidebarWrapper = euiStyled(EuiFlexItem)`
|
||||
max-width: ${SIDEBAR_GROW_SIZE * 10}%;
|
||||
z-index: ${(props) => props.theme.eui.euiZLevel5};
|
||||
`;
|
||||
|
||||
export const WaterfallChartSidebarContainer = euiStyled.div<WaterfallChartSidebarContainer>`
|
||||
height: ${(props) => `${props.height}px`};
|
||||
overflow-y: hidden;
|
||||
|
@ -104,10 +109,10 @@ export const WaterfallChartSidebarFlexItem = euiStyled(EuiFlexItem)`
|
|||
min-width: 0;
|
||||
padding-left: ${(props) => props.theme.eui.paddingSizes.m};
|
||||
padding-right: ${(props) => props.theme.eui.paddingSizes.m};
|
||||
z-index: ${(props) => props.theme.eui.euiZLevel4};
|
||||
justify-content: space-around;
|
||||
`;
|
||||
|
||||
export const SideBarItemHighlighter = euiStyled.span<{ isHighlighted: boolean }>`
|
||||
export const SideBarItemHighlighter = euiStyled(EuiFlexItem)<{ isHighlighted: boolean }>`
|
||||
opacity: ${(props) => (props.isHighlighted ? 1 : 0.4)};
|
||||
height: 100%;
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useFlyout } from './use_flyout';
|
||||
import { IWaterfallContext } from '../context/waterfall_chart';
|
||||
|
||||
import { ProjectedValues, XYChartElementEvent } from '@elastic/charts';
|
||||
|
||||
describe('useFlyoutHook', () => {
|
||||
const metadata: IWaterfallContext['metadata'] = [
|
||||
{
|
||||
x: 0,
|
||||
url: 'http://elastic.co',
|
||||
requestHeaders: undefined,
|
||||
responseHeaders: undefined,
|
||||
certificates: undefined,
|
||||
details: [
|
||||
{
|
||||
name: 'Content type',
|
||||
value: 'text/html',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('sets isFlyoutVisible to true and sets flyoutData when calling onSidebarClick', () => {
|
||||
const index = 0;
|
||||
const { result } = renderHook((props) => useFlyout(props.metadata), {
|
||||
initialProps: { metadata },
|
||||
});
|
||||
|
||||
expect(result.current.isFlyoutVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.onSidebarClick({ buttonRef: { current: null }, networkItemIndex: index });
|
||||
});
|
||||
|
||||
expect(result.current.isFlyoutVisible).toBe(true);
|
||||
expect(result.current.flyoutData).toEqual(metadata[index]);
|
||||
});
|
||||
|
||||
it('sets isFlyoutVisible to true and sets flyoutData when calling onBarClick', () => {
|
||||
const index = 0;
|
||||
const elementData = [
|
||||
{
|
||||
datum: {
|
||||
config: {
|
||||
id: index,
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
];
|
||||
|
||||
const { result } = renderHook((props) => useFlyout(props.metadata), {
|
||||
initialProps: { metadata },
|
||||
});
|
||||
|
||||
expect(result.current.isFlyoutVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.onBarClick([elementData as XYChartElementEvent]);
|
||||
});
|
||||
|
||||
expect(result.current.isFlyoutVisible).toBe(true);
|
||||
expect(result.current.flyoutData).toEqual(metadata[0]);
|
||||
});
|
||||
|
||||
it('sets isFlyoutVisible to true and sets flyoutData when calling onProjectionClick', () => {
|
||||
const index = 0;
|
||||
const geometry = { x: index };
|
||||
|
||||
const { result } = renderHook((props) => useFlyout(props.metadata), {
|
||||
initialProps: { metadata },
|
||||
});
|
||||
|
||||
expect(result.current.isFlyoutVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.onProjectionClick(geometry as ProjectedValues);
|
||||
});
|
||||
|
||||
expect(result.current.isFlyoutVisible).toBe(true);
|
||||
expect(result.current.flyoutData).toEqual(metadata[0]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { RefObject, useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
ElementClickListener,
|
||||
ProjectionClickListener,
|
||||
ProjectedValues,
|
||||
XYChartElementEvent,
|
||||
} from '@elastic/charts';
|
||||
|
||||
import { WaterfallMetadata, WaterfallMetadataEntry } from '../types';
|
||||
|
||||
interface OnSidebarClickParams {
|
||||
buttonRef?: ButtonRef;
|
||||
networkItemIndex: number;
|
||||
}
|
||||
|
||||
export type ButtonRef = RefObject<HTMLButtonElement | null>;
|
||||
export type OnSidebarClick = (params: OnSidebarClickParams) => void;
|
||||
export type OnProjectionClick = ProjectionClickListener;
|
||||
export type OnElementClick = ElementClickListener;
|
||||
export type OnFlyoutClose = () => void;
|
||||
|
||||
export const useFlyout = (metadata: WaterfallMetadata) => {
|
||||
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
|
||||
const [flyoutData, setFlyoutData] = useState<WaterfallMetadataEntry | undefined>(undefined);
|
||||
const [currentSidebarItemRef, setCurrentSidebarItemRef] = useState<
|
||||
RefObject<HTMLButtonElement | null>
|
||||
>();
|
||||
|
||||
const handleFlyout = useCallback(
|
||||
(flyoutEntry: WaterfallMetadataEntry) => {
|
||||
setFlyoutData(flyoutEntry);
|
||||
setIsFlyoutVisible(true);
|
||||
},
|
||||
[setIsFlyoutVisible, setFlyoutData]
|
||||
);
|
||||
|
||||
const onFlyoutClose = useCallback(() => {
|
||||
setIsFlyoutVisible(false);
|
||||
currentSidebarItemRef?.current?.focus();
|
||||
}, [currentSidebarItemRef, setIsFlyoutVisible]);
|
||||
|
||||
const onBarClick: ElementClickListener = useCallback(
|
||||
([elementData]) => {
|
||||
setIsFlyoutVisible(false);
|
||||
const { datum } = (elementData as XYChartElementEvent)[0];
|
||||
const metadataEntry = metadata[datum.config.id];
|
||||
handleFlyout(metadataEntry);
|
||||
},
|
||||
[metadata, handleFlyout]
|
||||
);
|
||||
|
||||
const onProjectionClick: ProjectionClickListener = useCallback(
|
||||
(projectionData) => {
|
||||
setIsFlyoutVisible(false);
|
||||
const { x } = projectionData as ProjectedValues;
|
||||
if (typeof x === 'number' && x >= 0) {
|
||||
const metadataEntry = metadata[x];
|
||||
handleFlyout(metadataEntry);
|
||||
}
|
||||
},
|
||||
[metadata, handleFlyout]
|
||||
);
|
||||
|
||||
const onSidebarClick: OnSidebarClick = useCallback(
|
||||
({ buttonRef, networkItemIndex }) => {
|
||||
if (isFlyoutVisible && buttonRef === currentSidebarItemRef) {
|
||||
setIsFlyoutVisible(false);
|
||||
} else {
|
||||
const metadataEntry = metadata[networkItemIndex];
|
||||
setCurrentSidebarItemRef(buttonRef);
|
||||
handleFlyout(metadataEntry);
|
||||
}
|
||||
},
|
||||
[currentSidebarItemRef, handleFlyout, isFlyoutVisible, metadata, setIsFlyoutVisible]
|
||||
);
|
||||
|
||||
return {
|
||||
flyoutData,
|
||||
onBarClick,
|
||||
onProjectionClick,
|
||||
onSidebarClick,
|
||||
isFlyoutVisible,
|
||||
onFlyoutClose,
|
||||
};
|
||||
};
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Axis,
|
||||
BarSeries,
|
||||
|
@ -67,6 +67,10 @@ export const WaterfallBarChart = ({
|
|||
index,
|
||||
}: Props) => {
|
||||
const theme = useChartTheme();
|
||||
const { onElementClick, onProjectionClick } = useWaterfallContext();
|
||||
const handleElementClick = useMemo(() => onElementClick, [onElementClick]);
|
||||
const handleProjectionClick = useMemo(() => onProjectionClick, [onProjectionClick]);
|
||||
const memoizedTickFormat = useCallback(tickFormat, [tickFormat]);
|
||||
|
||||
return (
|
||||
<WaterfallChartChartContainer
|
||||
|
@ -80,13 +84,15 @@ export const WaterfallBarChart = ({
|
|||
rotation={90}
|
||||
tooltip={{ customTooltip: Tooltip }}
|
||||
theme={theme}
|
||||
onProjectionClick={handleProjectionClick}
|
||||
onElementClick={handleElementClick}
|
||||
/>
|
||||
|
||||
<Axis
|
||||
aria-hidden={true}
|
||||
id="time"
|
||||
position={Position.Top}
|
||||
tickFormat={tickFormat}
|
||||
tickFormat={memoizedTickFormat}
|
||||
domain={domain}
|
||||
showGridLines={true}
|
||||
style={{
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import { TickFormatter, DomainRange, BarStyleAccessor } from '@elastic/charts';
|
||||
|
||||
import { useWaterfallContext } from '../context/waterfall_chart';
|
||||
import {
|
||||
WaterfallChartOuterContainer,
|
||||
WaterfallChartFixedTopContainer,
|
||||
WaterfallChartFixedTopContainerSidebarCover,
|
||||
WaterfallChartSidebarWrapper,
|
||||
WaterfallChartTopContainer,
|
||||
RelativeContainer,
|
||||
WaterfallChartFilterContainer,
|
||||
|
@ -27,8 +27,12 @@ import { WaterfallBarChart } from './waterfall_bar_chart';
|
|||
import { WaterfallChartFixedAxis } from './waterfall_chart_fixed_axis';
|
||||
import { NetworkRequestsTotal } from './network_requests_total';
|
||||
|
||||
export type RenderItem<I = any> = (item: I, index?: number) => JSX.Element;
|
||||
export type RenderFilter = () => JSX.Element;
|
||||
export type RenderItem<I = any> = (
|
||||
item: I,
|
||||
index: number,
|
||||
onClick?: (event: any) => void
|
||||
) => JSX.Element;
|
||||
export type RenderElement = () => JSX.Element;
|
||||
|
||||
export interface WaterfallChartProps {
|
||||
tickFormat: TickFormatter;
|
||||
|
@ -36,7 +40,8 @@ export interface WaterfallChartProps {
|
|||
barStyleAccessor: BarStyleAccessor;
|
||||
renderSidebarItem?: RenderItem;
|
||||
renderLegendItem?: RenderItem;
|
||||
renderFilter?: RenderFilter;
|
||||
renderFilter?: RenderElement;
|
||||
renderFlyout?: RenderElement;
|
||||
maxHeight?: string;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
@ -48,6 +53,7 @@ export const WaterfallChart = ({
|
|||
renderSidebarItem,
|
||||
renderLegendItem,
|
||||
renderFilter,
|
||||
renderFlyout,
|
||||
maxHeight = '800px',
|
||||
fullHeight = false,
|
||||
}: WaterfallChartProps) => {
|
||||
|
@ -82,7 +88,7 @@ export const WaterfallChart = ({
|
|||
<WaterfallChartFixedTopContainer>
|
||||
<WaterfallChartTopContainer gutterSize="none" responsive={false}>
|
||||
{shouldRenderSidebar && (
|
||||
<EuiFlexItem grow={SIDEBAR_GROW_SIZE}>
|
||||
<WaterfallChartSidebarWrapper grow={SIDEBAR_GROW_SIZE}>
|
||||
<WaterfallChartFixedTopContainerSidebarCover paddingSize="none" hasShadow={false} />
|
||||
<NetworkRequestsTotal
|
||||
totalNetworkRequests={totalNetworkRequests}
|
||||
|
@ -93,7 +99,7 @@ export const WaterfallChart = ({
|
|||
{renderFilter && (
|
||||
<WaterfallChartFilterContainer>{renderFilter()}</WaterfallChartFilterContainer>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</WaterfallChartSidebarWrapper>
|
||||
)}
|
||||
|
||||
<WaterfallChartAxisOnlyContainer
|
||||
|
@ -130,6 +136,7 @@ export const WaterfallChart = ({
|
|||
</EuiFlexGroup>
|
||||
</WaterfallChartOuterContainer>
|
||||
{shouldRenderLegend && <Legend items={legendItems!} render={renderLegendItem!} />}
|
||||
{renderFlyout && renderFlyout()}
|
||||
</RelativeContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { EuiText, EuiBasicTable, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
interface Row {
|
||||
name: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: Row[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
const StyledText = styled(EuiText)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
class TableWithoutHeader extends EuiBasicTable {
|
||||
renderTableHead() {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
export const Table = (props: Props) => {
|
||||
const { rows, title } = props;
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'name',
|
||||
name: '',
|
||||
sortable: false,
|
||||
render: (_name: string, item: Row) => (
|
||||
<EuiText size="xs">
|
||||
<strong>{item.name}</strong>
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'value',
|
||||
name: '',
|
||||
sortable: false,
|
||||
render: (_name: string, item: Row) => {
|
||||
return (
|
||||
<StyledText size="xs" textAlign="right">
|
||||
{item.value ?? '--'}
|
||||
</StyledText>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiText>
|
||||
<h4>{title}</h4>
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
<TableWithoutHeader
|
||||
tableLayout={'fixed'}
|
||||
compressed
|
||||
responsive={false}
|
||||
columns={columns}
|
||||
items={rows}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -6,7 +6,8 @@
|
|||
*/
|
||||
|
||||
import React, { createContext, useContext, Context } from 'react';
|
||||
import { WaterfallData, WaterfallDataEntry } from '../types';
|
||||
import { WaterfallData, WaterfallDataEntry, WaterfallMetadata } from '../types';
|
||||
import { OnSidebarClick, OnElementClick, OnProjectionClick } from '../components/use_flyout';
|
||||
import { SidebarItems } from '../../step_detail/waterfall/types';
|
||||
|
||||
export interface IWaterfallContext {
|
||||
|
@ -14,9 +15,13 @@ export interface IWaterfallContext {
|
|||
highlightedNetworkRequests: number;
|
||||
fetchedNetworkRequests: number;
|
||||
data: WaterfallData;
|
||||
onElementClick?: OnElementClick;
|
||||
onProjectionClick?: OnProjectionClick;
|
||||
onSidebarClick?: OnSidebarClick;
|
||||
showOnlyHighlightedNetworkRequests: boolean;
|
||||
sidebarItems?: SidebarItems;
|
||||
legendItems?: unknown[];
|
||||
metadata: WaterfallMetadata;
|
||||
renderTooltipItem: (
|
||||
item: WaterfallDataEntry['config']['tooltipProps'],
|
||||
index?: number
|
||||
|
@ -30,18 +35,26 @@ interface ProviderProps {
|
|||
highlightedNetworkRequests: number;
|
||||
fetchedNetworkRequests: number;
|
||||
data: IWaterfallContext['data'];
|
||||
onElementClick?: IWaterfallContext['onElementClick'];
|
||||
onProjectionClick?: IWaterfallContext['onProjectionClick'];
|
||||
onSidebarClick?: IWaterfallContext['onSidebarClick'];
|
||||
showOnlyHighlightedNetworkRequests: IWaterfallContext['showOnlyHighlightedNetworkRequests'];
|
||||
sidebarItems?: IWaterfallContext['sidebarItems'];
|
||||
legendItems?: IWaterfallContext['legendItems'];
|
||||
metadata: IWaterfallContext['metadata'];
|
||||
renderTooltipItem: IWaterfallContext['renderTooltipItem'];
|
||||
}
|
||||
|
||||
export const WaterfallProvider: React.FC<ProviderProps> = ({
|
||||
children,
|
||||
data,
|
||||
onElementClick,
|
||||
onProjectionClick,
|
||||
onSidebarClick,
|
||||
showOnlyHighlightedNetworkRequests,
|
||||
sidebarItems,
|
||||
legendItems,
|
||||
metadata,
|
||||
renderTooltipItem,
|
||||
totalNetworkRequests,
|
||||
highlightedNetworkRequests,
|
||||
|
@ -54,6 +67,10 @@ export const WaterfallProvider: React.FC<ProviderProps> = ({
|
|||
showOnlyHighlightedNetworkRequests,
|
||||
sidebarItems,
|
||||
legendItems,
|
||||
metadata,
|
||||
onElementClick,
|
||||
onProjectionClick,
|
||||
onSidebarClick,
|
||||
renderTooltipItem,
|
||||
totalNetworkRequests,
|
||||
highlightedNetworkRequests,
|
||||
|
|
|
@ -8,4 +8,10 @@
|
|||
export { WaterfallChart, RenderItem, WaterfallChartProps } from './components/waterfall_chart';
|
||||
export { WaterfallProvider, useWaterfallContext } from './context/waterfall_chart';
|
||||
export { MiddleTruncatedText } from './components/middle_truncated_text';
|
||||
export { WaterfallData, WaterfallDataEntry } from './types';
|
||||
export { useFlyout } from './components/use_flyout';
|
||||
export {
|
||||
WaterfallData,
|
||||
WaterfallDataEntry,
|
||||
WaterfallMetadata,
|
||||
WaterfallMetadataEntry,
|
||||
} from './types';
|
||||
|
|
|
@ -16,8 +16,26 @@ export interface WaterfallDataSeriesConfigProperties {
|
|||
showTooltip: boolean;
|
||||
}
|
||||
|
||||
export interface WaterfallMetadataItem {
|
||||
name: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface WaterfallMetadataEntry {
|
||||
x: number;
|
||||
url: string;
|
||||
requestHeaders?: WaterfallMetadataItem[];
|
||||
responseHeaders?: WaterfallMetadataItem[];
|
||||
certificates?: WaterfallMetadataItem[];
|
||||
details: WaterfallMetadataItem[];
|
||||
}
|
||||
|
||||
export type WaterfallDataEntry = PlotProperties & {
|
||||
config: WaterfallDataSeriesConfigProperties & Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WaterfallMetadata = WaterfallMetadataEntry[];
|
||||
|
||||
export type WaterfallData = WaterfallDataEntry[];
|
||||
|
||||
export type RenderItem<I = any> = (item: I, index: number) => JSX.Element;
|
||||
|
|
|
@ -239,11 +239,43 @@ describe('getNetworkEvents', () => {
|
|||
Object {
|
||||
"events": Array [
|
||||
Object {
|
||||
"bytesDownloadedCompressed": 337,
|
||||
"certificates": Object {
|
||||
"issuer": "DigiCert TLS RSA SHA256 2020 CA1",
|
||||
"subjectName": "syndication.twitter.com",
|
||||
"validFrom": 1606694400000,
|
||||
"validTo": 1638230399000,
|
||||
},
|
||||
"ip": "104.244.42.200",
|
||||
"loadEndTime": 3287298.251,
|
||||
"method": "GET",
|
||||
"mimeType": "image/gif",
|
||||
"requestHeaders": Object {
|
||||
"referer": "www.test.com",
|
||||
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/88.0.4324.0 Safari/537.36",
|
||||
},
|
||||
"requestSentTime": 3287154.973,
|
||||
"requestStartTime": 3287155.502,
|
||||
"responseHeaders": Object {
|
||||
"cache_control": "no-cache, no-store, must-revalidate, pre-check=0, post-check=0",
|
||||
"content_encoding": "gzip",
|
||||
"content_length": "65",
|
||||
"content_type": "image/gif;charset=utf-8",
|
||||
"date": "Mon, 14 Dec 2020 10:46:39 GMT",
|
||||
"expires": "Tue, 31 Mar 1981 05:00:00 GMT",
|
||||
"last_modified": "Mon, 14 Dec 2020 10:46:39 GMT",
|
||||
"pragma": "no-cache",
|
||||
"server": "tsa_f",
|
||||
"status": "200 OK",
|
||||
"strict_transport_security": "max-age=631138519",
|
||||
"x_connection_hash": "cb6fe99b8676f4e4b827cc3e6512c90d",
|
||||
"x_content_type_options": "nosniff",
|
||||
"x_frame_options": "SAMEORIGIN",
|
||||
"x_response_time": "108",
|
||||
"x_transaction": "008fff3d00a1e64c",
|
||||
"x_twitter_response_tags": "BouncerCompliant",
|
||||
"x_xss_protection": "0",
|
||||
},
|
||||
"status": 200,
|
||||
"timestamp": "2020-12-14T10:46:39.183Z",
|
||||
"timings": Object {
|
||||
|
|
|
@ -50,6 +50,7 @@ export const getNetworkEvents: UMElasticsearchQueryFn<
|
|||
event._source.synthetics.payload.response.timing
|
||||
? secondsToMillis(event._source.synthetics.payload.response.timing.request_time)
|
||||
: undefined;
|
||||
const securityDetails = event._source.synthetics.payload.response?.security_details;
|
||||
|
||||
return {
|
||||
timestamp: event._source['@timestamp'],
|
||||
|
@ -61,6 +62,22 @@ export const getNetworkEvents: UMElasticsearchQueryFn<
|
|||
requestStartTime,
|
||||
loadEndTime,
|
||||
timings: event._source.synthetics.payload.timings,
|
||||
bytesDownloadedCompressed: event._source.synthetics.payload.response?.encoded_data_length,
|
||||
certificates: securityDetails
|
||||
? {
|
||||
issuer: securityDetails.issuer,
|
||||
subjectName: securityDetails.subject_name,
|
||||
validFrom: securityDetails.valid_from
|
||||
? secondsToMillis(securityDetails.valid_from)
|
||||
: undefined,
|
||||
validTo: securityDetails.valid_to
|
||||
? secondsToMillis(securityDetails.valid_to)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
requestHeaders: event._source.synthetics.payload.request?.headers,
|
||||
responseHeaders: event._source.synthetics.payload.response?.headers,
|
||||
ip: event._source.synthetics.payload.response?.remote_i_p_address,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue