[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:
Dominique Clarke 2021-02-11 17:48:18 -05:00 committed by GitHub
parent 2e42d18db9
commit 53f4d4840b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1576 additions and 379 deletions

View file

@ -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({

View file

@ -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']);

View file

@ -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);

View file

@ -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[];

View file

@ -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 = {

View file

@ -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}
/>

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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();
});
});
});

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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%;
`;

View file

@ -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]);
});
});

View file

@ -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,
};
};

View file

@ -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={{

View file

@ -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>
);
};

View file

@ -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}
/>
</>
);
};

View file

@ -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,

View file

@ -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';

View file

@ -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;

View file

@ -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 {

View file

@ -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,
};
}),
};