[Synthetics] Support synthetics dedupe strategy in Uptime app (#101678)

* Add new runtime types for parsing on client/server.

* Add more runtime types.

* Remove dead code.

* Mark parameter as unused.

* Improve typing for failed journey request function.

* Add new API functions, improve typing in a few others.

* Modify API calls to work with new screenshot_ref data.

* Fix untested refactor error.

* Add required fields to runtime type.

* Update typing in failed steps component.

* Adapt client to work with old screenshots as well as new screenshot_ref.

* Refactor composite code to reusable hook.

* Implement screenshot blocks endpoint.

* Define runtime types for full-size screenshots.

* Delete dedicated screenshot and ref queries.

* Optimize screenshot endpoint by combining queries.

* Handle parsing error.

* Clean up screenshot/ref typings.

* Remove dead types. DRY a type out.

* Simplify types.

* Improve typing in step screenshot components.

* Prefer PNG to JPG for canvas composite op.

* Simplify and clean up some code.

* Remove reliance on `Ping` type, clean up types.

* Add a comment.

* Add a comment.

* Fix typing for `FailedStep` component.

* Standardize loading spinner sizes.

* Add comments to composite code.

* Remove unnecessary optional chaining.

* Reformat error string.

* Remove unneeded key from request return object.

* Add a comment to a return object explaining very large cache value.

* Make type annotation more accurate.

* Resolve some type and test errors.

* Clean up remaining type errors.

* Move type definitions to simplify imports.

* Simplify `PingTimestamp` interface.

* Refactor failing unit test to use RTL and actually test things.

* Add tests for new helper functions.

* Add a comment.

* Test `PingTimestamp` for screenshot ref data.

* Test `StepImageCaption` for ref data.

* Improve typing for step list column definitions.

* Harden a test.

* Extract code to avoid repeated declarations.

* Create centralized mock for `useCompositeImage`.

* Add test for ref to `StepScreenshotDisplay`.

* Add tests for `getJourneyDetails`.

* Extract search results wrapper to helper lib.

* Add tests for `getJourneyFailedSteps`.

* Add support for aggs to result helper wrapper.

* Write tests for `getJourneyScreenshot` and simplify type checking.

* Write tests for `getJourneyScreenshotBlocks`.

* Simplify prop types for `FailedStep`.

* Remove unused type.

* Fix regression in step navigating for new style screenshots.

* Implement PR feedback.

* Implement PR feedback.

* Implement PR feedback.

* Reduce limit of screenshot block queries from 10k to 1k.

* Remove redundant field selection from ES query.

* Implement PR feedback.

* Fix regression that caused "Last successful step" to not show an image.

* Delete unused props from `Ping` runtime type.

* More precise naming.

* Naming improvements. Add `useCallback` to prevent callback re-declaration.

* Prefer explicit props to `{...spread}` syntax.

* Remove redundant type checking.

* Delete obsolete unit tests.

* Fix a regression.

* Add effect to `useEffect`.
This commit is contained in:
Justin Kambic 2021-06-29 08:08:52 -04:00 committed by GitHub
parent 39ba747728
commit 0c8d5e8f89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1937 additions and 736 deletions

View file

@ -1,31 +0,0 @@
/*
* 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 * as t from 'io-ts';
// IO type for validation
export const PingErrorType = t.intersection([
t.partial({
code: t.string,
id: t.string,
stack_trace: t.string,
type: t.string,
}),
t.type({
// this is _always_ on the error field
message: t.string,
}),
]);
// Typescript type for type checking
export type PingError = t.TypeOf<typeof PingErrorType>;
export const MonitorDetailsType = t.intersection([
t.type({ monitorId: t.string }),
t.partial({ error: PingErrorType, timestamp: t.string, alerts: t.unknown }),
]);
export type MonitorDetails = t.TypeOf<typeof MonitorDetailsType>;

View file

@ -5,6 +5,5 @@
* 2.0.
*/
export * from './details';
export * from './locations';
export * from './state';

View file

@ -7,3 +7,4 @@
export * from './histogram';
export * from './ping';
export * from './synthetics';

View file

@ -7,7 +7,29 @@
import * as t from 'io-ts';
import { DateRangeType } from '../common';
import { PingErrorType } from '../monitor';
// IO type for validation
export const PingErrorType = t.intersection([
t.partial({
code: t.string,
id: t.string,
stack_trace: t.string,
type: t.string,
}),
t.type({
// this is _always_ on the error field
message: t.string,
}),
]);
// Typescript type for type checking
export type PingError = t.TypeOf<typeof PingErrorType>;
export const MonitorDetailsType = t.intersection([
t.type({ monitorId: t.string }),
t.partial({ error: PingErrorType, timestamp: t.string, alerts: t.unknown }),
]);
export type MonitorDetails = t.TypeOf<typeof MonitorDetailsType>;
export const HttpResponseBodyType = t.partial({
bytes: t.number,
@ -193,10 +215,6 @@ export const PingType = t.intersection([
name: t.string,
}),
type: t.string,
// ui-related field
screenshotLoading: t.boolean,
// ui-related field
screenshotExists: t.boolean,
blob: t.string,
blob_mime: t.string,
payload: t.partial({
@ -242,36 +260,6 @@ export const PingType = t.intersection([
}),
]);
export const SyntheticsJourneyApiResponseType = t.intersection([
t.type({
checkGroup: t.string,
steps: t.array(PingType),
}),
t.partial({
details: t.union([
t.intersection([
t.type({
timestamp: t.string,
journey: PingType,
}),
t.partial({
next: t.type({
timestamp: t.string,
checkGroup: t.string,
}),
previous: t.type({
timestamp: t.string,
checkGroup: t.string,
}),
}),
]),
t.null,
]),
}),
]);
export type SyntheticsJourneyApiResponse = t.TypeOf<typeof SyntheticsJourneyApiResponseType>;
export type Ping = t.TypeOf<typeof PingType>;
// Convenience function for tests etc that makes an empty ping

View file

@ -0,0 +1,150 @@
/*
* 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 {
isRefResult,
isFullScreenshot,
isScreenshotRef,
isScreenshotImageBlob,
RefResult,
FullScreenshot,
ScreenshotImageBlob,
ScreenshotRefImageData,
} from './synthetics';
describe('synthetics runtime types', () => {
let refResult: RefResult;
let fullScreenshot: FullScreenshot;
let screenshotImageBlob: ScreenshotImageBlob;
let screenshotRef: ScreenshotRefImageData;
beforeEach(() => {
refResult = {
'@timestamp': '123',
monitor: {
check_group: 'check-group',
},
screenshot_ref: {
width: 1200,
height: 900,
blocks: [
{
hash: 'hash1',
top: 0,
left: 0,
height: 120,
width: 90,
},
{
hash: 'hash2',
top: 0,
left: 90,
height: 120,
width: 90,
},
],
},
synthetics: {
package_version: 'v1',
step: {
name: 'step name',
index: 0,
},
type: 'step/screenshot_ref',
},
};
fullScreenshot = {
synthetics: {
blob: 'image data',
blob_mime: 'image/jpeg',
step: {
name: 'step name',
},
type: 'step/screenshot',
},
};
screenshotImageBlob = {
stepName: null,
maxSteps: 1,
src: 'image data',
};
screenshotRef = {
stepName: null,
maxSteps: 1,
ref: {
screenshotRef: refResult,
blocks: [
{
id: 'hash1',
synthetics: {
blob: 'image data',
blob_mime: 'image/jpeg',
},
},
{
id: 'hash2',
synthetics: {
blob: 'image data',
blob_mime: 'image/jpeg',
},
},
],
},
};
});
describe('isRefResult', () => {
it('identifies refs correctly', () => {
expect(isRefResult(refResult)).toBe(true);
});
it('fails objects that do not correspond to the type', () => {
expect(isRefResult(fullScreenshot)).toBe(false);
expect(isRefResult(screenshotRef)).toBe(false);
expect(isRefResult(screenshotImageBlob)).toBe(false);
});
});
describe('isScreenshot', () => {
it('identifies screenshot objects correctly', () => {
expect(isFullScreenshot(fullScreenshot)).toBe(true);
});
it('fails objects that do not correspond to the type', () => {
expect(isFullScreenshot(refResult)).toBe(false);
expect(isFullScreenshot(screenshotRef)).toBe(false);
expect(isFullScreenshot(screenshotImageBlob)).toBe(false);
});
});
describe('isScreenshotImageBlob', () => {
it('identifies screenshot image blob objects correctly', () => {
expect(isScreenshotImageBlob(screenshotImageBlob)).toBe(true);
});
it('fails objects that do not correspond to the type', () => {
expect(isScreenshotImageBlob(refResult)).toBe(false);
expect(isScreenshotImageBlob(screenshotRef)).toBe(false);
expect(isScreenshotImageBlob(fullScreenshot)).toBe(false);
});
});
describe('isScreenshotRef', () => {
it('identifies screenshot ref objects correctly', () => {
expect(isScreenshotRef(screenshotRef)).toBe(true);
});
it('fails objects that do not correspond to the type', () => {
expect(isScreenshotRef(refResult)).toBe(false);
expect(isScreenshotRef(fullScreenshot)).toBe(false);
expect(isScreenshotRef(screenshotImageBlob)).toBe(false);
});
});
});

View file

@ -0,0 +1,207 @@
/*
* 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 { isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
/**
* This type has some overlap with the Ping type, but it helps avoid runtime type
* check failures and removes a lot of unnecessary fields that our Synthetics UI code
* does not care about.
*/
export const JourneyStepType = t.intersection([
t.partial({
monitor: t.partial({
duration: t.type({
us: t.number,
}),
name: t.string,
status: t.string,
type: t.string,
}),
synthetics: t.partial({
error: t.partial({
message: t.string,
stack: t.string,
}),
payload: t.partial({
message: t.string,
source: t.string,
status: t.string,
text: t.string,
}),
step: t.type({
index: t.number,
name: t.string,
}),
isFullScreenshot: t.boolean,
isScreenshotRef: t.boolean,
}),
}),
t.type({
_id: t.string,
'@timestamp': t.string,
monitor: t.type({
id: t.string,
check_group: t.string,
}),
synthetics: t.type({
type: t.string,
}),
}),
]);
export type JourneyStep = t.TypeOf<typeof JourneyStepType>;
export const FailedStepsApiResponseType = t.type({
checkGroups: t.array(t.string),
steps: t.array(JourneyStepType),
});
export type FailedStepsApiResponse = t.TypeOf<typeof FailedStepsApiResponseType>;
/**
* The individual screenshot blocks Synthetics uses to reduce disk footprint.
*/
export const ScreenshotBlockType = t.type({
hash: t.string,
top: t.number,
left: t.number,
height: t.number,
width: t.number,
});
/**
* The old style of screenshot document that contains a full screenshot blob.
*/
export const FullScreenshotType = t.type({
synthetics: t.intersection([
t.partial({
blob: t.string,
}),
t.type({
blob: t.string,
blob_mime: t.string,
step: t.type({
name: t.string,
}),
type: t.literal('step/screenshot'),
}),
]),
});
export type FullScreenshot = t.TypeOf<typeof FullScreenshotType>;
export function isFullScreenshot(data: unknown): data is FullScreenshot {
return isRight(FullScreenshotType.decode(data));
}
/**
* The ref used by synthetics to organize the blocks needed to recompose a
* fragmented image.
*/
export const RefResultType = t.type({
'@timestamp': t.string,
monitor: t.type({
check_group: t.string,
}),
screenshot_ref: t.type({
width: t.number,
height: t.number,
blocks: t.array(ScreenshotBlockType),
}),
synthetics: t.type({
package_version: t.string,
step: t.type({
name: t.string,
index: t.number,
}),
type: t.literal('step/screenshot_ref'),
}),
});
export type RefResult = t.TypeOf<typeof RefResultType>;
export function isRefResult(data: unknown): data is RefResult {
return isRight(RefResultType.decode(data));
}
/**
* Represents the result of querying for the legacy-style full screenshot blob.
*/
export const ScreenshotImageBlobType = t.type({
stepName: t.union([t.null, t.string]),
maxSteps: t.number,
src: t.string,
});
export type ScreenshotImageBlob = t.TypeOf<typeof ScreenshotImageBlobType>;
export function isScreenshotImageBlob(data: unknown): data is ScreenshotImageBlob {
return isRight(ScreenshotImageBlobType.decode(data));
}
/**
* Represents the block blobs stored by hash. These documents are used to recompose synthetics images.
*/
export const ScreenshotBlockDocType = t.type({
id: t.string,
synthetics: t.type({
blob: t.string,
blob_mime: t.string,
}),
});
export type ScreenshotBlockDoc = t.TypeOf<typeof ScreenshotBlockDocType>;
/**
* Contains the fields requried by the Synthetics UI when utilizing screenshot refs.
*/
export const ScreenshotRefImageDataType = t.type({
stepName: t.union([t.null, t.string]),
maxSteps: t.number,
ref: t.type({
screenshotRef: RefResultType,
blocks: t.array(ScreenshotBlockDocType),
}),
});
export type ScreenshotRefImageData = t.TypeOf<typeof ScreenshotRefImageDataType>;
export function isScreenshotRef(data: unknown): data is ScreenshotRefImageData {
return isRight(ScreenshotRefImageDataType.decode(data));
}
export const SyntheticsJourneyApiResponseType = t.intersection([
t.type({
checkGroup: t.string,
steps: t.array(JourneyStepType),
}),
t.partial({
details: t.union([
t.intersection([
t.type({
timestamp: t.string,
journey: JourneyStepType,
}),
t.partial({
next: t.type({
timestamp: t.string,
checkGroup: t.string,
}),
previous: t.type({
timestamp: t.string,
checkGroup: t.string,
}),
}),
]),
t.null,
]),
}),
]);
export type SyntheticsJourneyApiResponse = t.TypeOf<typeof SyntheticsJourneyApiResponseType>;

View file

@ -6,16 +6,16 @@
*/
import React from 'react';
import { Ping, SyntheticsJourneyApiResponse } from '../../../../../common/runtime_types/ping';
import { FailedStepsApiResponse } from '../../../../../common/runtime_types/ping/synthetics';
interface Props {
ping: Ping;
failedSteps?: SyntheticsJourneyApiResponse;
checkGroup?: string;
failedSteps?: FailedStepsApiResponse;
}
export const FailedStep = ({ ping, failedSteps }: Props) => {
export const FailedStep = ({ checkGroup, failedSteps }: Props) => {
const thisFailedStep = failedSteps?.steps?.find(
(fs) => fs.monitor.check_group === ping.monitor.check_group
(fs) => !!checkGroup && fs.monitor.check_group === checkGroup
);
if (!thisFailedStep) {

View file

@ -1,89 +0,0 @@
/*
* 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 { fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { render } from '../../../../../lib/helper/rtl_helpers';
import { NavButtons, NavButtonsProps } from './nav_buttons';
describe('NavButtons', () => {
let defaultProps: NavButtonsProps;
beforeEach(() => {
defaultProps = {
maxSteps: 3,
stepNumber: 2,
setStepNumber: jest.fn(),
setIsImagePopoverOpen: jest.fn(),
};
});
it('labels prev and next buttons', () => {
const { getByLabelText } = render(<NavButtons {...defaultProps} />);
expect(getByLabelText('Previous step'));
expect(getByLabelText('Next step'));
});
it('increments step number on next click', async () => {
const { getByLabelText } = render(<NavButtons {...defaultProps} />);
const nextButton = getByLabelText('Next step');
fireEvent.click(nextButton);
await waitFor(() => {
expect(defaultProps.setStepNumber).toHaveBeenCalledTimes(1);
expect(defaultProps.setStepNumber).toHaveBeenCalledWith(3);
});
});
it('decrements step number on prev click', async () => {
const { getByLabelText } = render(<NavButtons {...defaultProps} />);
const nextButton = getByLabelText('Previous step');
fireEvent.click(nextButton);
await waitFor(() => {
expect(defaultProps.setStepNumber).toHaveBeenCalledTimes(1);
expect(defaultProps.setStepNumber).toHaveBeenCalledWith(1);
});
});
it('disables `next` button on final step', () => {
defaultProps.stepNumber = 3;
const { getByLabelText } = render(<NavButtons {...defaultProps} />);
// getByLabelText('Next step');
expect(getByLabelText('Next step')).toHaveAttribute('disabled');
expect(getByLabelText('Previous step')).not.toHaveAttribute('disabled');
});
it('disables `prev` button on final step', () => {
defaultProps.stepNumber = 1;
const { getByLabelText } = render(<NavButtons {...defaultProps} />);
expect(getByLabelText('Next step')).not.toHaveAttribute('disabled');
expect(getByLabelText('Previous step')).toHaveAttribute('disabled');
});
it('opens popover when mouse enters', async () => {
const { getByLabelText } = render(<NavButtons {...defaultProps} />);
const nextButton = getByLabelText('Next step');
fireEvent.mouseEnter(nextButton);
await waitFor(() => {
expect(defaultProps.setIsImagePopoverOpen).toHaveBeenCalledTimes(1);
expect(defaultProps.setIsImagePopoverOpen).toHaveBeenCalledWith(true);
});
});
});

View file

@ -1,59 +0,0 @@
/*
* 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 { EuiButtonIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import React, { MouseEvent } from 'react';
import { nextAriaLabel, prevAriaLabel } from './translations';
export interface NavButtonsProps {
maxSteps?: number;
setIsImagePopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
setStepNumber: React.Dispatch<React.SetStateAction<number>>;
stepNumber: number;
}
export const NavButtons: React.FC<NavButtonsProps> = ({
maxSteps,
setIsImagePopoverOpen,
setStepNumber,
stepNumber,
}) => (
<EuiFlexGroup
className="stepArrows"
gutterSize="s"
alignItems="center"
onMouseEnter={() => setIsImagePopoverOpen(true)}
style={{ position: 'absolute', bottom: 0, left: 30 }}
>
<EuiFlexItem grow={false}>
<EuiButtonIcon
disabled={stepNumber === 1}
color="subdued"
size="s"
onClick={(evt: MouseEvent<HTMLButtonElement>) => {
setStepNumber(stepNumber - 1);
evt.stopPropagation();
}}
iconType="arrowLeft"
aria-label={prevAriaLabel}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
disabled={stepNumber === maxSteps}
color="subdued"
size="s"
onClick={(evt: MouseEvent<HTMLButtonElement>) => {
setStepNumber(stepNumber + 1);
evt.stopPropagation();
}}
iconType="arrowRight"
aria-label={nextAriaLabel}
/>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -27,7 +27,7 @@ export const NoImageDisplay: React.FC<NoImageDisplayProps> = ({
{isLoading || isPending ? (
<EuiLoadingSpinner
aria-label={imageLoadingSpinnerAriaLabel}
size="xl"
size="l"
data-test-subj="pingTimestampSpinner"
/>
) : (

View file

@ -10,10 +10,11 @@ import { fireEvent, waitFor } from '@testing-library/react';
import { PingTimestamp } from './ping_timestamp';
import { mockReduxHooks } from '../../../../../lib/helper/test_helpers';
import { render } from '../../../../../lib/helper/rtl_helpers';
import { Ping } from '../../../../../../common/runtime_types/ping';
import * as observabilityPublic from '../../../../../../../observability/public';
import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column';
import moment from 'moment';
import '../../../../../lib/__mocks__/use_composite_image.mock';
import { mockRef } from '../../../../../lib/__mocks__/screenshot_ref.mock';
mockReduxHooks();
@ -27,40 +28,13 @@ jest.mock('../../../../../../../observability/public', () => {
});
describe('Ping Timestamp component', () => {
let response: Ping;
let checkGroup: string;
let timestamp: string;
const { FETCH_STATUS } = observabilityPublic;
beforeAll(() => {
response = {
ecs: { version: '1.6.0' },
agent: {
ephemeral_id: '52ce1110-464f-4d74-b94c-3c051bf12589',
id: '3ebcd3c2-f5c3-499e-8d86-80f98e5f4c08',
name: 'docker-desktop',
type: 'heartbeat',
version: '7.10.0',
hostname: 'docker-desktop',
},
monitor: {
status: 'up',
check_group: 'f58a484f-2ffb-11eb-9b35-025000000001',
duration: { us: 1528598 },
id: 'basic addition and completion of single task',
name: 'basic addition and completion of single task',
type: 'browser',
timespan: { lt: '2020-11-26T15:29:56.820Z', gte: '2020-11-26T15:28:56.820Z' },
},
url: {
full: 'file:///opt/elastic-synthetics/examples/todos/app/index.html',
scheme: 'file',
domain: '',
path: '/opt/elastic-synthetics/examples/todos/app/index.html',
},
synthetics: { type: 'heartbeat/summary' },
summary: { up: 1, down: 0 },
timestamp: '2020-11-26T15:28:56.896Z',
docId: '0WErBXYB0mvWTKLO-yQm',
};
checkGroup = 'f58a484f-2ffb-11eb-9b35-025000000001';
timestamp = '2020-11-26T15:28:56.896Z';
});
it.each([[FETCH_STATUS.PENDING], [FETCH_STATUS.LOADING]])(
@ -70,7 +44,7 @@ describe('Ping Timestamp component', () => {
.spyOn(observabilityPublic, 'useFetcher')
.mockReturnValue({ status: fetchStatus, data: null, refetch: () => null });
const { getByTestId } = render(
<PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} />
<PingTimestamp checkGroup={checkGroup} label={getShortTimeStamp(moment(timestamp))} />
);
expect(getByTestId('pingTimestampSpinner')).toBeInTheDocument();
}
@ -81,7 +55,7 @@ describe('Ping Timestamp component', () => {
.spyOn(observabilityPublic, 'useFetcher')
.mockReturnValue({ status: FETCH_STATUS.SUCCESS, data: null, refetch: () => null });
const { getByTestId } = render(
<PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} />
<PingTimestamp checkGroup={checkGroup} label={getShortTimeStamp(moment(timestamp))} />
);
expect(getByTestId('pingTimestampNoImageAvailable')).toBeInTheDocument();
});
@ -90,11 +64,11 @@ describe('Ping Timestamp component', () => {
const src = 'http://sample.com/sampleImageSrc.png';
jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({
status: FETCH_STATUS.SUCCESS,
data: { src },
data: { maxSteps: 2, stepName: 'test', src },
refetch: () => null,
});
const { container } = render(
<PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} />
<PingTimestamp checkGroup={checkGroup} label={getShortTimeStamp(moment(timestamp))} />
);
expect(container.querySelector('img')?.src).toBe(src);
});
@ -103,12 +77,13 @@ describe('Ping Timestamp component', () => {
const src = 'http://sample.com/sampleImageSrc.png';
jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({
status: FETCH_STATUS.SUCCESS,
data: { src },
data: { maxSteps: 1, stepName: null, src },
refetch: () => null,
});
const { getByAltText, getAllByText, queryByAltText } = render(
<PingTimestamp ping={response} label={getShortTimeStamp(moment(response.timestamp))} />
<PingTimestamp checkGroup={checkGroup} label={getShortTimeStamp(moment(timestamp))} />
);
const caption = getAllByText('Nov 26, 2020 10:28:56 AM');
fireEvent.mouseEnter(caption[0]);
@ -120,4 +95,29 @@ describe('Ping Timestamp component', () => {
await waitFor(() => expect(queryByAltText(altText)).toBeNull());
});
it('handles screenshot ref data', async () => {
jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({
status: FETCH_STATUS.SUCCESS,
data: mockRef,
refetch: () => null,
});
const { getByAltText, getByText, getByRole, getAllByText, queryByAltText } = render(
<PingTimestamp checkGroup={checkGroup} label={getShortTimeStamp(moment(timestamp))} />
);
await waitFor(() => getByRole('img'));
const caption = getAllByText('Nov 26, 2020 10:28:56 AM');
fireEvent.mouseEnter(caption[0]);
const altText = `A larger version of the screenshot for this journey step's thumbnail.`;
await waitFor(() => getByAltText(altText));
fireEvent.mouseLeave(caption[0]);
await waitFor(() => expect(queryByAltText(altText)).toBeNull());
expect(getByText('Step: 1 of 1'));
});
});

View file

@ -9,7 +9,11 @@ import React, { useContext, useEffect, useState } from 'react';
import useIntersection from 'react-use/lib/useIntersection';
import styled from 'styled-components';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Ping } from '../../../../../../common/runtime_types/ping';
import {
isScreenshotImageBlob,
isScreenshotRef,
ScreenshotRefImageData,
} from '../../../../../../common/runtime_types/ping';
import { useFetcher, FETCH_STATUS } from '../../../../../../../observability/public';
import { getJourneyScreenshot } from '../../../../../state/api/journey';
import { UptimeSettingsContext } from '../../../../../contexts';
@ -27,12 +31,12 @@ const StepDiv = styled.div`
`;
interface Props {
checkGroup?: string;
label?: string;
ping: Ping;
initialStepNo?: number;
}
export const PingTimestamp = ({ label, ping, initialStepNo = 1 }: Props) => {
export const PingTimestamp = ({ label, checkGroup, initialStepNo = 1 }: Props) => {
const [stepNumber, setStepNumber] = useState(initialStepNo);
const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false);
@ -42,7 +46,7 @@ export const PingTimestamp = ({ label, ping, initialStepNo = 1 }: Props) => {
const { basePath } = useContext(UptimeSettingsContext);
const imgPath = `${basePath}/api/uptime/journey/screenshot/${ping.monitor.check_group}/${stepNumber}`;
const imgPath = `${basePath}/api/uptime/journey/screenshot/${checkGroup}/${stepNumber}`;
const intersection = useIntersection(intersectionRef, {
root: null,
@ -55,13 +59,19 @@ export const PingTimestamp = ({ label, ping, initialStepNo = 1 }: Props) => {
return getJourneyScreenshot(imgPath);
}, [intersection?.intersectionRatio, stepNumber]);
const [screenshotRef, setScreenshotRef] = useState<ScreenshotRefImageData | undefined>(undefined);
useEffect(() => {
if (data) {
if (isScreenshotRef(data)) {
setScreenshotRef(data);
} else if (isScreenshotImageBlob(data)) {
setStepImages((prevState) => [...prevState, data?.src]);
}
}, [data]);
const imgSrc = stepImages?.[stepNumber - 1] ?? data?.src;
let imgSrc;
if (isScreenshotImageBlob(data)) {
imgSrc = stepImages?.[stepNumber - 1] ?? data.src;
}
const captionContent = formatCaptionContent(stepNumber, data?.maxSteps);
@ -71,6 +81,7 @@ export const PingTimestamp = ({ label, ping, initialStepNo = 1 }: Props) => {
<StepImageCaption
captionContent={captionContent}
imgSrc={imgSrc}
imgRef={screenshotRef}
maxSteps={data?.maxSteps}
setStepNumber={setStepNumber}
stepNumber={stepNumber}
@ -100,14 +111,16 @@ export const PingTimestamp = ({ label, ping, initialStepNo = 1 }: Props) => {
onMouseLeave={() => setIsImagePopoverOpen(false)}
ref={intersectionRef}
>
{imgSrc ? (
{(imgSrc || screenshotRef) && (
<StepImagePopover
captionContent={captionContent}
imageCaption={ImageCaption}
imgSrc={imgSrc}
imgRef={screenshotRef}
isImagePopoverOpen={isImagePopoverOpen}
/>
) : (
)}
{!imgSrc && !screenshotRef && (
<NoImageDisplay
imageCaption={ImageCaption}
isLoading={status === FETCH_STATUS.LOADING}

View file

@ -11,6 +11,7 @@ import { render } from '../../../../../lib/helper/rtl_helpers';
import { StepImageCaption, StepImageCaptionProps } from './step_image_caption';
import { getShortTimeStamp } from '../../../../overview/monitor_list/columns/monitor_status_column';
import moment from 'moment';
import { mockRef } from '../../../../../lib/__mocks__/screenshot_ref.mock';
describe('StepImageCaption', () => {
let defaultProps: StepImageCaptionProps;
@ -91,4 +92,12 @@ describe('StepImageCaption', () => {
getByText('test caption content');
});
it('renders caption content for screenshot ref data', async () => {
const { getByText } = render(
<StepImageCaption {...{ ...defaultProps, imgRef: mockRef, imgSrc: undefined }} />
);
getByText('test caption content');
});
});

View file

@ -9,10 +9,12 @@ import React, { MouseEvent, useEffect } from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { nextAriaLabel, prevAriaLabel } from './translations';
import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
import { ScreenshotRefImageData } from '../../../../../../common/runtime_types';
export interface StepImageCaptionProps {
captionContent: string;
imgSrc?: string;
imgRef?: ScreenshotRefImageData;
maxSteps?: number;
setStepNumber: React.Dispatch<React.SetStateAction<number>>;
stepNumber: number;
@ -30,6 +32,7 @@ const ImageCaption = euiStyled.div`
export const StepImageCaption: React.FC<StepImageCaptionProps> = ({
captionContent,
imgRef,
imgSrc,
maxSteps,
setStepNumber,
@ -54,7 +57,7 @@ export const StepImageCaption: React.FC<StepImageCaptionProps> = ({
}}
>
<div className="stepArrowsFullScreen">
{imgSrc && (
{(imgSrc || imgRef) && (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { EuiImage, EuiPopover } from '@elastic/eui';
import { EuiImage, EuiLoadingSpinner, EuiPopover } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { ScreenshotRefImageData } from '../../../../../../common/runtime_types/ping/synthetics';
import { fullSizeImageAlt } from './translations';
import { useCompositeImage } from '../../../../../hooks/use_composite_image';
const POPOVER_IMG_HEIGHT = 360;
const POPOVER_IMG_WIDTH = 640;
@ -24,40 +26,137 @@ const StepImage = styled(EuiImage)`
}
}
`;
interface ScreenshotImageProps {
captionContent: string;
imageCaption: JSX.Element;
}
const DefaultImage: React.FC<ScreenshotImageProps & { imageData?: string }> = ({
captionContent,
imageCaption,
imageData,
}) =>
imageData ? (
<StepImage
allowFullScreen={true}
alt={captionContent}
caption={imageCaption}
data-test-subj="pingTimestampImage"
hasShadow
url={imageData}
size="s"
className="syntheticsStepImage"
/>
) : (
<EuiLoadingSpinner size="l" />
);
/**
* This component provides an intermediate step for composite images. It causes a loading spinner to appear
* while the image is being re-assembled, then calls the default image component and provides a data URL for the image.
*/
const RecomposedScreenshotImage: React.FC<
ScreenshotImageProps & {
imgRef: ScreenshotRefImageData;
setImageData: React.Dispatch<string | undefined>;
imageData: string | undefined;
}
> = ({ captionContent, imageCaption, imageData, imgRef, setImageData }) => {
// initially an undefined URL value is passed to the image display, and a loading spinner is rendered.
// `useCompositeImage` will call `setUrl` when the image is composited, and the updated `url` will display.
useCompositeImage(imgRef, setImageData, imageData);
return (
<DefaultImage
captionContent={captionContent}
imageCaption={imageCaption}
imageData={imageData}
/>
);
};
export interface StepImagePopoverProps {
captionContent: string;
imageCaption: JSX.Element;
imgSrc: string;
imgSrc?: string;
imgRef?: ScreenshotRefImageData;
isImagePopoverOpen: boolean;
}
const StepImageComponent: React.FC<
Omit<StepImagePopoverProps, 'isImagePopoverOpen'> & {
setImageData: React.Dispatch<string | undefined>;
imageData: string | undefined;
}
> = ({ captionContent, imageCaption, imageData, imgRef, imgSrc, setImageData }) => {
if (imgSrc) {
return (
<DefaultImage
captionContent={captionContent}
imageCaption={imageCaption}
imageData={imageData}
/>
);
} else if (imgRef) {
return (
<RecomposedScreenshotImage
captionContent={captionContent}
imageCaption={imageCaption}
imageData={imageData}
imgRef={imgRef}
setImageData={setImageData}
/>
);
}
return null;
};
export const StepImagePopover: React.FC<StepImagePopoverProps> = ({
captionContent,
imageCaption,
imgRef,
imgSrc,
isImagePopoverOpen,
}) => (
<EuiPopover
anchorPosition="leftDown"
button={
<StepImage
allowFullScreen={true}
alt={captionContent}
caption={imageCaption}
data-test-subj="pingTimestampImage"
hasShadow
url={imgSrc}
size="s"
className="syntheticsStepImage"
/>
}) => {
const [imageData, setImageData] = React.useState<string | undefined>(imgSrc || undefined);
React.useEffect(() => {
// for legacy screenshots, when a new image arrives, we must overwrite it
if (imgSrc && imgSrc !== imageData) {
setImageData(imgSrc);
}
isOpen={isImagePopoverOpen}
closePopover={() => {}}
>
<EuiImage
alt={fullSizeImageAlt}
url={imgSrc}
style={{ height: POPOVER_IMG_HEIGHT, width: POPOVER_IMG_WIDTH, objectFit: 'contain' }}
/>
</EuiPopover>
);
}, [imgSrc, imageData]);
const setImageDataCallback = React.useCallback(
(newImageData: string | undefined) => setImageData(newImageData),
[setImageData]
);
return (
<EuiPopover
anchorPosition="leftDown"
button={
<StepImageComponent
captionContent={captionContent}
imageCaption={imageCaption}
imgRef={imgRef}
imgSrc={imgSrc}
setImageData={setImageDataCallback}
imageData={imageData}
/>
}
isOpen={isImagePopoverOpen}
closePopover={() => {}}
>
{imageData ? (
<EuiImage
alt={fullSizeImageAlt}
url={imageData}
style={{ height: POPOVER_IMG_HEIGHT, width: POPOVER_IMG_WIDTH, objectFit: 'contain' }}
/>
) : (
<EuiLoadingSpinner size="l" />
)}
</EuiPopover>
);
};

View file

@ -62,7 +62,7 @@ describe('PingList component', () => {
...response,
error: undefined,
loading: false,
failedSteps: { steps: [], checkGroup: '1-f-4d-4f' },
failedSteps: { steps: [], checkGroups: ['1-f-4d-4f'] },
});
});
@ -72,7 +72,7 @@ describe('PingList component', () => {
total: 0,
error: undefined,
loading: true,
failedSteps: { steps: [], checkGroup: '1-f-4d-4f' },
failedSteps: { steps: [], checkGroups: ['1-f-4d-4f'] },
});
const { getByText } = render(<PingList />);
expect(getByText('Loading history...')).toBeInTheDocument();
@ -84,7 +84,7 @@ describe('PingList component', () => {
total: 0,
error: undefined,
loading: false,
failedSteps: { steps: [], checkGroup: '1-f-4d-4f' },
failedSteps: { steps: [], checkGroups: ['1-f-4d-4f'] },
});
const { getByText } = render(<PingList />);
expect(getByText('No history found')).toBeInTheDocument();

View file

@ -145,7 +145,10 @@ export const PingList = () => {
field: 'timestamp',
name: TIMESTAMP_LABEL,
render: (timestamp: string, item: Ping) => (
<PingTimestamp label={getShortTimeStamp(moment(timestamp))} ping={item} />
<PingTimestamp
checkGroup={item.monitor.check_group}
label={getShortTimeStamp(moment(timestamp))}
/>
),
},
]
@ -185,8 +188,8 @@ export const PingList = () => {
name: i18n.translate('xpack.uptime.pingList.columns.failedStep', {
defaultMessage: 'Failed step',
}),
render: (timestamp: string, item: Ping) => (
<FailedStep ping={item} failedSteps={failedSteps} />
render: (_timestamp: string, item: Ping) => (
<FailedStep checkGroup={item.monitor?.check_group} failedSteps={failedSteps} />
),
},
]

View file

@ -10,13 +10,19 @@ import { i18n } from '@kbn/i18n';
import { useBreadcrumbs } from '../../../../hooks/use_breadcrumbs';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { JourneyState } from '../../../../state/reducers/journey';
import { Ping } from '../../../../../common/runtime_types/ping';
import { PLUGIN } from '../../../../../common/constants/plugin';
import { getShortTimeStamp } from '../../../overview/monitor_list/columns/monitor_status_column';
interface ActiveStep {
monitor: {
id: string;
name?: string;
};
}
interface Props {
details: JourneyState['details'];
activeStep?: Ping;
activeStep?: ActiveStep;
performanceBreakDownView?: boolean;
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { ReactRouterEuiLink } from '../../../common/react_router_helpers';
import { Ping } from '../../../../../common/runtime_types/ping';
import { JourneyStep } from '../../../../../common/runtime_types/ping/synthetics';
import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
const LabelLink = euiStyled.div`
@ -17,7 +17,7 @@ const LabelLink = euiStyled.div`
`;
interface Props {
lastSuccessfulStep: Ping;
lastSuccessfulStep: JourneyStep;
}
export const ScreenshotLink = ({ lastSuccessfulStep }: Props) => {

View file

@ -10,7 +10,7 @@ import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { StepScreenshotDisplay } from '../../step_screenshot_display';
import { Ping } from '../../../../../common/runtime_types/ping';
import { JourneyStep } from '../../../../../common/runtime_types/ping/synthetics';
import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
import { useFetcher } from '../../../../../../observability/public';
import { fetchLastSuccessfulStep } from '../../../../state/api/journey';
@ -24,21 +24,22 @@ const Label = euiStyled.div`
`;
interface Props {
step: Ping;
step: JourneyStep;
}
export const StepScreenshots = ({ step }: Props) => {
const isSucceeded = step.synthetics?.payload?.status === 'succeeded';
const { data: lastSuccessfulStep } = useFetcher(() => {
const { data } = useFetcher(() => {
if (!isSucceeded) {
return fetchLastSuccessfulStep({
timestamp: step.timestamp,
timestamp: step['@timestamp'],
monitorId: step.monitor.id,
stepIndex: step.synthetics?.step?.index!,
});
}
}, [step.docId, step.timestamp]);
}, [step._id, step['@timestamp']]);
const lastSuccessfulStep: JourneyStep | undefined = data;
return (
<EuiFlexGroup>
@ -59,26 +60,28 @@ export const StepScreenshots = ({ step }: Props) => {
</Label>
<StepScreenshotDisplay
checkGroup={step.monitor.check_group}
screenshotExists={step.synthetics?.screenshotExists}
isScreenshotRef={!!step.synthetics?.isScreenshotRef}
isScreenshotBlob={!!step.synthetics?.isFullScreenshot}
stepIndex={step.synthetics?.step?.index}
stepName={step.synthetics?.step?.name}
lazyLoad={false}
/>
<EuiSpacer size="xs" />
<Label>{getShortTimeStamp(moment(step.timestamp))}</Label>
<Label>{getShortTimeStamp(moment(step['@timestamp']))}</Label>
</EuiFlexItem>
{!isSucceeded && lastSuccessfulStep?.monitor && (
<EuiFlexItem>
<ScreenshotLink lastSuccessfulStep={lastSuccessfulStep} />
<StepScreenshotDisplay
checkGroup={lastSuccessfulStep.monitor.check_group}
screenshotExists={true}
isScreenshotRef={!!lastSuccessfulStep.synthetics?.isScreenshotRef}
isScreenshotBlob={!!lastSuccessfulStep.synthetics?.isFullScreenshot}
stepIndex={lastSuccessfulStep.synthetics?.step?.index}
stepName={lastSuccessfulStep.synthetics?.step?.name}
lazyLoad={false}
/>
<EuiSpacer size="xs" />
<Label>{getShortTimeStamp(moment(lastSuccessfulStep.timestamp))}</Label>
<Label>{getShortTimeStamp(moment(lastSuccessfulStep['@timestamp']))}</Label>
</EuiFlexItem>
)}
</EuiFlexGroup>

View file

@ -7,18 +7,21 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { Ping } from '../../../../common/runtime_types/ping';
import { JourneyStep } from '../../../../common/runtime_types/ping/synthetics';
import { PingTimestamp } from '../../monitor/ping_list/columns/ping_timestamp';
interface Props {
step: Ping;
step: JourneyStep;
}
export const StepImage = ({ step }: Props) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<PingTimestamp ping={step} initialStepNo={step.synthetics?.step?.index} />
<PingTimestamp
checkGroup={step.monitor.check_group}
initialStepNo={step.synthetics?.step?.index}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>{step.synthetics?.step?.name}</EuiText>

View file

@ -6,18 +6,18 @@
*/
import React from 'react';
import { Ping } from '../../../../common/runtime_types/ping';
import { JourneyStep } from '../../../../common/runtime_types/ping';
import { StepsList } from './steps_list';
import { render } from '../../../lib/helper/rtl_helpers';
describe('StepList component', () => {
let steps: Ping[];
let steps: JourneyStep[];
beforeEach(() => {
steps = [
{
docId: '1',
timestamp: '123',
_id: '1',
'@timestamp': '123',
monitor: {
id: 'MON_ID',
duration: {
@ -39,8 +39,8 @@ describe('StepList component', () => {
},
},
{
docId: '2',
timestamp: '124',
_id: '2',
'@timestamp': '124',
monitor: {
id: 'MON_ID',
duration: {
@ -112,8 +112,8 @@ describe('StepList component', () => {
it('uses appropriate count when non-step/end steps are included', () => {
steps[0].synthetics!.payload!.status = 'succeeded';
steps.push({
docId: '3',
timestamp: '125',
_id: '3',
'@timestamp': '125',
monitor: {
id: 'MON_ID',
duration: {

View file

@ -5,11 +5,17 @@
* 2.0.
*/
import { EuiBasicTable, EuiButtonIcon, EuiPanel, EuiTitle } from '@elastic/eui';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiButtonIcon,
EuiPanel,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { MouseEvent } from 'react';
import styled from 'styled-components';
import { Ping } from '../../../../common/runtime_types';
import { JourneyStep } from '../../../../common/runtime_types';
import { STATUS_LABEL } from '../../monitor/ping_list/translations';
import { COLLAPSE_LABEL, EXPAND_LABEL, STEP_NAME_LABEL } from '../translations';
import { StatusBadge } from '../status_badge';
@ -23,7 +29,7 @@ export const SpanWithMargin = styled.span`
`;
interface Props {
data: Ping[];
data: JourneyStep[];
error?: Error;
loading: boolean;
}
@ -34,7 +40,7 @@ interface StepStatusCount {
succeeded: number;
}
function isStepEnd(step: Ping) {
function isStepEnd(step: JourneyStep) {
return step.synthetics?.type === 'step/end';
}
@ -62,7 +68,7 @@ function statusMessage(count: StepStatusCount, loading?: boolean) {
});
}
function reduceStepStatus(prev: StepStatusCount, cur: Ping): StepStatusCount {
function reduceStepStatus(prev: StepStatusCount, cur: JourneyStep): StepStatusCount {
if (cur.synthetics?.payload?.status === 'succeeded') {
prev.succeeded += 1;
return prev;
@ -75,15 +81,15 @@ function reduceStepStatus(prev: StepStatusCount, cur: Ping): StepStatusCount {
}
export const StepsList = ({ data, error, loading }: Props) => {
const steps = data.filter(isStepEnd);
const steps: JourneyStep[] = data.filter(isStepEnd);
const { expandedRows, toggleExpand } = useExpandedRow({ steps, allPings: data, loading });
const { expandedRows, toggleExpand } = useExpandedRow({ steps, allSteps: data, loading });
const columns: any[] = [
const columns: Array<EuiBasicTableColumn<JourneyStep>> = [
{
field: 'synthetics.payload.status',
name: STATUS_LABEL,
render: (pingStatus: string, item: Ping) => (
render: (pingStatus: string, item) => (
<StatusBadge status={pingStatus} stepNo={item.synthetics?.step?.index!} />
),
},
@ -91,13 +97,13 @@ export const StepsList = ({ data, error, loading }: Props) => {
align: 'left',
field: 'timestamp',
name: STEP_NAME_LABEL,
render: (timestamp: string, item: Ping) => <StepImage step={item} />,
render: (_timestamp: string, item) => <StepImage step={item} />,
},
{
align: 'left',
field: 'timestamp',
name: '',
render: (val: string, item: Ping) => (
render: (_val: string, item) => (
<StepDetailLink
checkGroupId={item.monitor.check_group!}
stepIndex={item.synthetics?.step?.index!}
@ -110,20 +116,20 @@ export const StepsList = ({ data, error, loading }: Props) => {
align: 'right',
width: '24px',
isExpander: true,
render: (ping: Ping) => {
render: (journeyStep: JourneyStep) => {
return (
<EuiButtonIcon
data-test-subj="uptimeStepListExpandBtn"
onClick={() => toggleExpand({ ping })}
aria-label={expandedRows[ping.docId] ? COLLAPSE_LABEL : EXPAND_LABEL}
iconType={expandedRows[ping.docId] ? 'arrowUp' : 'arrowDown'}
onClick={() => toggleExpand({ journeyStep })}
aria-label={expandedRows[journeyStep._id] ? COLLAPSE_LABEL : EXPAND_LABEL}
iconType={expandedRows[journeyStep._id] ? 'arrowUp' : 'arrowDown'}
/>
);
},
},
];
const getRowProps = (item: Ping) => {
const getRowProps = (item: JourneyStep) => {
const { monitor } = item;
return {
@ -134,7 +140,7 @@ export const StepsList = ({ data, error, loading }: Props) => {
// we dont want to capture image click event
if (targetElem.tagName !== 'IMG' && targetElem.tagName !== 'BUTTON') {
toggleExpand({ ping: item });
toggleExpand({ journeyStep: item });
}
},
};

View file

@ -13,7 +13,7 @@ import { createMemoryHistory } from 'history';
import { useExpandedRow } from './use_expanded_row';
import { render } from '../../../lib/helper/rtl_helpers';
import { Ping } from '../../../../common/runtime_types/ping';
import { JourneyStep } from '../../../../common/runtime_types';
import { SYNTHETIC_CHECK_STEPS_ROUTE } from '../../../../common/constants';
import { COLLAPSE_LABEL, EXPAND_LABEL } from '../translations';
import { act } from 'react-dom/test-utils';
@ -25,17 +25,16 @@ describe('useExpandedROw', () => {
const history = createMemoryHistory({
initialEntries: ['/journey/fake-group/steps'],
});
const steps: Ping[] = [
const steps: JourneyStep[] = [
{
docId: '1',
timestamp: '123',
_id: '1',
'@timestamp': '123',
monitor: {
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
check_group: 'fake-group',
},
synthetics: {
@ -50,15 +49,14 @@ describe('useExpandedROw', () => {
},
},
{
docId: '2',
timestamp: '124',
_id: '2',
'@timestamp': '124',
monitor: {
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
check_group: 'fake-group',
},
synthetics: {
@ -77,7 +75,7 @@ describe('useExpandedROw', () => {
const Component = () => {
const { expandedRows, toggleExpand } = useExpandedRow({
steps,
allPings: steps,
allSteps: steps,
loading: false,
});
@ -86,13 +84,13 @@ describe('useExpandedROw', () => {
return (
<Route path={SYNTHETIC_CHECK_STEPS_ROUTE}>
Step list
{steps.map((ping, index) => (
{steps.map((journeyStep, index) => (
<EuiButtonIcon
key={index}
data-test-subj={TEST_ID + index}
onClick={() => toggleExpand({ ping })}
aria-label={expandedRows[ping.docId] ? COLLAPSE_LABEL : EXPAND_LABEL}
iconType={expandedRows[ping.docId] ? 'arrowUp' : 'arrowDown'}
onClick={() => toggleExpand({ journeyStep })}
aria-label={expandedRows[journeyStep._id] ? COLLAPSE_LABEL : EXPAND_LABEL}
iconType={expandedRows[journeyStep._id] ? 'arrowUp' : 'arrowDown'}
/>
))}
</Route>

View file

@ -8,17 +8,17 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { ExecutedStep } from '../executed_step';
import { Ping } from '../../../../common/runtime_types/ping';
import { JourneyStep } from '../../../../common/runtime_types/ping';
interface HookProps {
loading: boolean;
allPings: Ping[];
steps: Ping[];
allSteps: JourneyStep[];
steps: JourneyStep[];
}
type ExpandRowType = Record<string, JSX.Element>;
export const useExpandedRow = ({ loading, steps, allPings }: HookProps) => {
export const useExpandedRow = ({ loading, steps, allSteps }: HookProps) => {
const [expandedRows, setExpandedRows] = useState<ExpandRowType>({});
// eui table uses index from 0, synthetics uses 1
@ -26,13 +26,13 @@ export const useExpandedRow = ({ loading, steps, allPings }: HookProps) => {
const getBrowserConsole = useCallback(
(index: number) => {
return allPings.find(
return allSteps.find(
(stepF) =>
stepF.synthetics?.type === 'journey/browserconsole' &&
stepF.synthetics?.step?.index! === index
)?.synthetics?.payload?.text;
},
[allPings]
[allSteps]
);
useEffect(() => {
@ -60,9 +60,9 @@ export const useExpandedRow = ({ loading, steps, allPings }: HookProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [checkGroupId, loading]);
const toggleExpand = ({ ping }: { ping: Ping }) => {
const toggleExpand = ({ journeyStep }: { journeyStep: JourneyStep }) => {
// eui table uses index from 0, synthetics uses 1
const stepIndex = ping.synthetics?.step?.index! - 1;
const stepIndex = journeyStep.synthetics?.step?.index! - 1;
// If already expanded, collapse
if (expandedRows[stepIndex]) {
@ -74,9 +74,9 @@ export const useExpandedRow = ({ loading, steps, allPings }: HookProps) => {
...expandedRows,
[stepIndex]: (
<ExecutedStep
step={ping}
step={journeyStep}
browserConsole={getBrowserConsole(stepIndex)}
index={ping.synthetics?.step?.index!}
index={journeyStep.synthetics?.step?.index!}
loading={loading}
/>
),

View file

@ -15,9 +15,10 @@ describe('ConsoleEvent component', () => {
shallowWithIntl(
<ConsoleEvent
event={{
timestamp: '123',
docId: '1',
'@timestamp': '123',
_id: '1',
monitor: {
check_group: 'check_group',
id: 'MONITOR_ID',
duration: {
us: 123,
@ -63,9 +64,10 @@ describe('ConsoleEvent component', () => {
shallowWithIntl(
<ConsoleEvent
event={{
timestamp: '123',
docId: '1',
'@timestamp': '123',
_id: '1',
monitor: {
check_group: 'check_group',
id: 'MONITOR_ID',
duration: {
us: 123,

View file

@ -8,10 +8,10 @@
import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import React, { useContext, FC } from 'react';
import { UptimeThemeContext } from '../../contexts';
import { Ping } from '../../../common/runtime_types/ping';
import { JourneyStep } from '../../../common/runtime_types/ping';
interface Props {
event: Ping;
event: JourneyStep;
}
export const ConsoleEvent: FC<Props> = ({ event }) => {
@ -28,7 +28,7 @@ export const ConsoleEvent: FC<Props> = ({ event }) => {
return (
<EuiFlexGroup>
<EuiFlexItem grow={false}>{event.timestamp}</EuiFlexItem>
<EuiFlexItem grow={false}>{event['@timestamp']}</EuiFlexItem>
<EuiFlexItem grow={false} style={{ color: typeColor }}>
{event.synthetics?.type}
</EuiFlexItem>

View file

@ -5,147 +5,101 @@
* 2.0.
*/
import { shallowWithIntl } from '@kbn/test/jest';
import React from 'react';
import { JourneyStep } from '../../../common/runtime_types/ping/synthetics';
import { render } from '../../lib/helper/rtl_helpers';
import { ConsoleOutputEventList } from './console_output_event_list';
describe('ConsoleOutputEventList component', () => {
let steps: JourneyStep[];
beforeEach(() => {
steps = [
{
'@timestamp': '123',
_id: '1',
monitor: {
check_group: 'check_group',
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'stderr',
},
},
{
'@timestamp': '124',
_id: '2',
monitor: {
check_group: 'check_group',
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'cmd/status',
},
},
{
'@timestamp': '124',
_id: '2',
monitor: {
check_group: 'check_group',
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'step/end',
},
},
{
'@timestamp': '125',
_id: '3',
monitor: {
check_group: 'check_group',
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'stdout',
},
},
];
});
it('renders a component per console event', () => {
expect(
shallowWithIntl(
<ConsoleOutputEventList
journey={{
checkGroup: 'check_group',
loading: false,
// 4 steps, three console, one step/end
steps: [
{
timestamp: '123',
docId: '1',
monitor: {
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'stderr',
},
},
{
timestamp: '124',
docId: '2',
monitor: {
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'cmd/status',
},
},
{
timestamp: '124',
docId: '2',
monitor: {
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'step/end',
},
},
{
timestamp: '125',
docId: '3',
monitor: {
id: 'MON_ID',
duration: {
us: 10,
},
status: 'down',
type: 'browser',
},
synthetics: {
type: 'stdout',
},
},
],
}}
/>
).find('EuiCodeBlock')
).toMatchInlineSnapshot(`
<EuiCodeBlock>
<ConsoleEvent
event={
Object {
"docId": "1",
"monitor": Object {
"duration": Object {
"us": 10,
},
"id": "MON_ID",
"status": "down",
"type": "browser",
},
"synthetics": Object {
"type": "stderr",
},
"timestamp": "123",
}
}
key="1_console-event-row"
/>
<ConsoleEvent
event={
Object {
"docId": "2",
"monitor": Object {
"duration": Object {
"us": 10,
},
"id": "MON_ID",
"status": "down",
"type": "browser",
},
"synthetics": Object {
"type": "cmd/status",
},
"timestamp": "124",
}
}
key="2_console-event-row"
/>
<ConsoleEvent
event={
Object {
"docId": "3",
"monitor": Object {
"duration": Object {
"us": 10,
},
"id": "MON_ID",
"status": "down",
"type": "browser",
},
"synthetics": Object {
"type": "stdout",
},
"timestamp": "125",
}
}
key="3_console-event-row"
/>
</EuiCodeBlock>
`);
const { getByRole, getByText, queryByText } = render(
<ConsoleOutputEventList
journey={{
checkGroup: 'check_group',
loading: false,
// 4 steps, three console, one step/end
steps,
}}
/>
);
expect(getByRole('heading').innerHTML).toBe('No steps ran');
steps
.filter((step) => step.synthetics.type !== 'step/end')
.forEach((step) => {
expect(getByText(step['@timestamp']));
expect(getByText(step.synthetics.type));
});
expect(queryByText('step/end')).toBeNull();
});
});

View file

@ -9,17 +9,17 @@ import { EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { FC } from 'react';
import { ConsoleEvent } from './console_event';
import { Ping } from '../../../common/runtime_types/ping';
import { JourneyStep } from '../../../common/runtime_types/ping';
import { JourneyState } from '../../state/reducers/journey';
interface Props {
journey: JourneyState;
}
const isConsoleStep = (step: Ping) =>
step.synthetics?.type === 'stderr' ||
step.synthetics?.type === 'stdout' ||
step.synthetics?.type === 'cmd/status';
const CONSOLE_STEP_TYPES = ['stderr', 'stdout', 'cmd/status'];
const isConsoleStep = (step: JourneyStep) =>
CONSOLE_STEP_TYPES.some((type) => type === step.synthetics.type);
export const ConsoleOutputEventList: FC<Props> = ({ journey }) => (
<div>
@ -41,7 +41,7 @@ export const ConsoleOutputEventList: FC<Props> = ({ journey }) => (
<EuiSpacer />
<EuiCodeBlock>
{journey.steps.filter(isConsoleStep).map((consoleEvent) => (
<ConsoleEvent event={consoleEvent} key={consoleEvent.docId + '_console-event-row'} />
<ConsoleEvent event={consoleEvent} key={consoleEvent._id + '_console-event-row'} />
))}
</EuiCodeBlock>
</div>

View file

@ -8,15 +8,16 @@
import React from 'react';
import { ExecutedStep } from './executed_step';
import { render } from '../../lib/helper/rtl_helpers';
import { Ping } from '../../../common/runtime_types/ping';
import { JourneyStep } from '../../../common/runtime_types/ping';
describe('ExecutedStep', () => {
let step: Ping;
let step: JourneyStep;
beforeEach(() => {
step = {
docId: 'docID',
_id: 'docID',
monitor: {
check_group: 'check_group',
duration: {
us: 123,
},
@ -29,8 +30,9 @@ describe('ExecutedStep', () => {
index: 4,
name: 'STEP_NAME',
},
type: 'step/end',
},
timestamp: 'timestamp',
'@timestamp': 'timestamp',
};
});
@ -43,6 +45,7 @@ describe('ExecutedStep', () => {
index: 3,
name: 'STEP_NAME',
},
type: 'step/end',
};
const { getByText } = render(<ExecutedStep index={3} step={step} loading={false} />);
@ -57,6 +60,7 @@ describe('ExecutedStep', () => {
message: 'There was an error executing the step.',
stack: 'some.stack.trace.string',
},
type: 'an error type',
};
const { getByText } = render(<ExecutedStep index={3} step={step} loading={false} />);

View file

@ -9,14 +9,14 @@ import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { CodeBlockAccordion } from './code_block_accordion';
import { Ping } from '../../../common/runtime_types/ping';
import { JourneyStep } from '../../../common/runtime_types/ping';
import { euiStyled } from '../../../../../../src/plugins/kibana_react/common';
import { StepScreenshots } from './check_steps/step_expanded_row/step_screenshots';
const CODE_BLOCK_OVERFLOW_HEIGHT = 360;
interface ExecutedStepProps {
step: Ping;
step: JourneyStep;
index: number;
loading: boolean;
browserConsole?: string;

View file

@ -8,6 +8,18 @@
import React from 'react';
import { StepScreenshotDisplay } from './step_screenshot_display';
import { render } from '../../lib/helper/rtl_helpers';
import * as observabilityPublic from '../../../../observability/public';
import '../../lib/__mocks__/use_composite_image.mock';
import { mockRef } from '../../lib/__mocks__/screenshot_ref.mock';
jest.mock('../../../../observability/public', () => {
const originalModule = jest.requireActual('../../../../observability/public');
return {
...originalModule,
useFetcher: jest.fn().mockReturnValue({ data: null, status: 'success' }),
};
});
jest.mock('react-use/lib/useIntersection', () => () => ({
isIntersecting: true,
@ -18,7 +30,8 @@ describe('StepScreenshotDisplayProps', () => {
const { getByAltText } = render(
<StepScreenshotDisplay
checkGroup="check_group"
screenshotExists={true}
isScreenshotBlob={true}
isScreenshotRef={false}
stepIndex={1}
stepName="STEP_NAME"
/>
@ -29,7 +42,12 @@ describe('StepScreenshotDisplayProps', () => {
it('uses alternative text when step name not available', () => {
const { getByAltText } = render(
<StepScreenshotDisplay checkGroup="check_group" screenshotExists={true} stepIndex={1} />
<StepScreenshotDisplay
checkGroup="check_group"
isScreenshotBlob={true}
isScreenshotRef={false}
stepIndex={1}
/>
);
expect(getByAltText('Screenshot')).toBeInTheDocument();
@ -39,11 +57,32 @@ describe('StepScreenshotDisplayProps', () => {
const { getByTestId } = render(
<StepScreenshotDisplay
checkGroup="check_group"
isScreenshotBlob={false}
isScreenshotRef={false}
stepIndex={1}
stepName="STEP_NAME"
screenshotExists={false}
/>
);
expect(getByTestId('stepScreenshotImageUnavailable')).toBeInTheDocument();
});
it('displays screenshot thumbnail for ref', () => {
jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({
status: observabilityPublic.FETCH_STATUS.SUCCESS,
data: { ...mockRef },
refetch: () => null,
});
const { getByAltText } = render(
<StepScreenshotDisplay
checkGroup="check_group"
isScreenshotBlob={false}
isScreenshotRef={true}
stepIndex={1}
stepName="STEP_NAME"
/>
);
expect(getByAltText('Screenshot for step with name "STEP_NAME"')).toBeInTheDocument();
});
});

View file

@ -5,16 +5,31 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiImage, EuiText } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiImage,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useContext, useEffect, useRef, useState, FC } from 'react';
import useIntersection from 'react-use/lib/useIntersection';
import {
isScreenshotRef as isAScreenshotRef,
ScreenshotRefImageData,
} from '../../../common/runtime_types';
import { UptimeSettingsContext, UptimeThemeContext } from '../../contexts';
import { useFetcher } from '../../../../observability/public';
import { getJourneyScreenshot } from '../../state/api/journey';
import { useCompositeImage } from '../../hooks';
interface StepScreenshotDisplayProps {
screenshotExists?: boolean;
isScreenshotBlob: boolean;
isScreenshotRef: boolean;
checkGroup?: string;
stepIndex?: number;
stepName?: string;
@ -36,9 +51,56 @@ const StepImage = styled(EuiImage)`
}
`;
const BaseStepImage = ({
stepIndex,
stepName,
url,
}: Pick<StepScreenshotDisplayProps, 'stepIndex' | 'stepName'> & { url?: string }) => {
if (!url) return <EuiLoadingSpinner size="l" />;
return (
<StepImage
allowFullScreen={true}
alt={
stepName
? i18n.translate('xpack.uptime.synthetics.screenshotDisplay.altText', {
defaultMessage: 'Screenshot for step with name "{stepName}"',
values: {
stepName,
},
})
: i18n.translate('xpack.uptime.synthetics.screenshotDisplay.altTextWithoutName', {
defaultMessage: 'Screenshot',
})
}
caption={`Step:${stepIndex} ${stepName}`}
hasShadow
url={url}
/>
);
};
type ComposedStepImageProps = Pick<StepScreenshotDisplayProps, 'stepIndex' | 'stepName'> & {
url: string | undefined;
imgRef: ScreenshotRefImageData;
setUrl: React.Dispatch<string | undefined>;
};
const ComposedStepImage = ({
stepIndex,
stepName,
url,
imgRef,
setUrl,
}: ComposedStepImageProps) => {
useCompositeImage(imgRef, setUrl, url);
if (!url) return <EuiLoadingSpinner size="l" />;
return <BaseStepImage stepIndex={stepIndex} stepName={stepName} url={url} />;
};
export const StepScreenshotDisplay: FC<StepScreenshotDisplayProps> = ({
checkGroup,
screenshotExists,
isScreenshotBlob: isScreenshotBlob,
isScreenshotRef,
stepIndex,
stepName,
lazyLoad = true,
@ -64,60 +126,59 @@ export const StepScreenshotDisplay: FC<StepScreenshotDisplayProps> = ({
}
}, [hasIntersected, isIntersecting, setHasIntersected]);
let content: JSX.Element | null = null;
const imgSrc = basePath + `/api/uptime/journey/screenshot/${checkGroup}/${stepIndex}`;
if ((hasIntersected || !lazyLoad) && screenshotExists) {
content = (
<StepImage
allowFullScreen={true}
alt={
stepName
? i18n.translate('xpack.uptime.synthetics.screenshotDisplay.altText', {
defaultMessage: 'Screenshot for step with name "{stepName}"',
values: {
stepName,
},
})
: i18n.translate('xpack.uptime.synthetics.screenshotDisplay.altTextWithoutName', {
defaultMessage: 'Screenshot',
})
}
caption={`Step:${stepIndex} ${stepName}`}
hasShadow
url={imgSrc}
/>
);
} else if (screenshotExists === false) {
content = (
<EuiFlexGroup
alignItems="center"
direction="column"
style={{ paddingTop: '32px' }}
data-test-subj="stepScreenshotImageUnavailable"
>
<EuiFlexItem grow={false}>
<EuiIcon color="subdued" size="xxl" type="image" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<strong>
<FormattedMessage
id="xpack.uptime.synthetics.screenshot.noImageMessage"
defaultMessage="No image available"
/>
</strong>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
// When loading a legacy screenshot, set `url` to full-size screenshot path.
// Otherwise, we first need to composite the image.
const [url, setUrl] = useState<string | undefined>(isScreenshotBlob ? imgSrc : undefined);
// when the image is a composite, we need to fetch the data since we cannot specify a blob URL
const { data: screenshotRef } = useFetcher(() => {
if (isScreenshotRef) {
return getJourneyScreenshot(imgSrc);
}
}, [basePath, checkGroup, stepIndex, isScreenshotRef]);
const shouldRenderImage = hasIntersected || !lazyLoad;
return (
<div
ref={containerRef}
style={{ backgroundColor: pageBackground, height: IMAGE_HEIGHT, width: IMAGE_WIDTH }}
>
{content}
{shouldRenderImage && isScreenshotBlob && (
<BaseStepImage stepName={stepName} stepIndex={stepIndex} url={url} />
)}
{shouldRenderImage && isScreenshotRef && isAScreenshotRef(screenshotRef) && (
<ComposedStepImage
imgRef={screenshotRef}
stepName={stepName}
stepIndex={stepIndex}
setUrl={setUrl}
url={url}
/>
)}
{!isScreenshotBlob && !isScreenshotRef && (
<EuiFlexGroup
alignItems="center"
direction="column"
style={{ paddingTop: '32px' }}
data-test-subj="stepScreenshotImageUnavailable"
>
<EuiFlexItem grow={false}>
<EuiIcon color="subdued" size="xxl" type="image" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<strong>
<FormattedMessage
id="xpack.uptime.synthetics.screenshot.noImageMessage"
defaultMessage="No image available"
/>
</strong>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
);
};

View file

@ -5,9 +5,10 @@
* 2.0.
*/
export * from './use_monitor';
export * from './use_url_params';
export * from './use_telemetry';
export * from './use_composite_image';
export * from './update_kuery_string';
export * from './use_cert_status';
export * from './use_monitor';
export * from './use_search_text';
export * from './use_cert_status';
export * from './use_telemetry';
export * from './use_url_params';

View file

@ -0,0 +1,55 @@
/*
* 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 { composeScreenshotRef } from '../lib/helper/compose_screenshot_images';
import { ScreenshotRefImageData } from '../../common/runtime_types/ping/synthetics';
/**
* Checks if two refs are the same. If the ref is unchanged, there's no need
* to run the expensive draw procedure.
*/
function isNewRef(a: ScreenshotRefImageData, b: ScreenshotRefImageData): boolean {
if (typeof a === 'undefined' || typeof b === 'undefined') return false;
const stepA = a.ref.screenshotRef.synthetics.step;
const stepB = b.ref.screenshotRef.synthetics.step;
return stepA.index !== stepB.index || stepA.name !== stepB.name;
}
/**
* Assembles the data for a composite image and returns the composite to a callback.
* @param imgRef the data and dimensions for the composite image.
* @param onComposeImageSuccess sends the composited image to this callback.
* @param imageData this is the composited image value, if it is truthy the function will skip the compositing process
*/
export const useCompositeImage = (
imgRef: ScreenshotRefImageData,
onComposeImageSuccess: React.Dispatch<string | undefined>,
imageData?: string
): void => {
const [curRef, setCurRef] = React.useState<ScreenshotRefImageData>(imgRef);
React.useEffect(() => {
const canvas = document.createElement('canvas');
async function compose() {
await composeScreenshotRef(imgRef, canvas);
const imgData = canvas.toDataURL('image/png', 1.0);
onComposeImageSuccess(imgData);
}
// if the URL is truthy it means it's already been composed, so there
// is no need to call the function
if (typeof imageData === 'undefined' || isNewRef(imgRef, curRef)) {
compose();
setCurRef(imgRef);
}
return () => {
canvas.parentElement?.removeChild(canvas);
};
}, [imgRef, onComposeImageSuccess, curRef, imageData]);
};

View file

@ -0,0 +1,62 @@
/*
* 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 { ScreenshotRefImageData } from '../../../common/runtime_types';
export const mockRef: ScreenshotRefImageData = {
maxSteps: 1,
stepName: 'load homepage',
ref: {
screenshotRef: {
'@timestamp': '2021-06-08T19:42:30.257Z',
synthetics: {
package_version: '1.0.0-beta.2',
step: { name: 'load homepage', index: 1 },
type: 'step/screenshot_ref',
},
screenshot_ref: {
blocks: [
{
top: 0,
left: 0,
width: 160,
hash: 'd518801fc523cf02727cd520f556c4113b3098c7',
height: 90,
},
{
top: 0,
left: 160,
width: 160,
hash: 'fa90345d5d7b05b1601e9ee645e663bc358869e0',
height: 90,
},
],
width: 1280,
height: 720,
},
monitor: { check_group: 'a567cc7a-c891-11eb-bdf9-3e22fb19bf97' },
},
blocks: [
{
id: 'd518801fc523cf02727cd520f556c4113b3098c7',
synthetics: {
blob:
'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',
blob_mime: 'image/jpeg',
},
},
{
id: 'fa90345d5d7b05b1601e9ee645e663bc358869e0',
synthetics: {
blob:
'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',
blob_mime: 'image/jpeg',
},
},
],
},
};

View file

@ -0,0 +1,23 @@
/*
* 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 { ScreenshotRefImageData } from '../../../common/runtime_types/ping/synthetics';
import * as composeScreenshotImages from '../../hooks/use_composite_image';
jest
.spyOn(composeScreenshotImages, 'useCompositeImage')
.mockImplementation(
(
_imgRef: ScreenshotRefImageData,
callback: React.Dispatch<string | undefined>,
url?: string
) => {
if (!url) {
callback('img src');
}
}
);

View file

@ -0,0 +1,56 @@
/*
* 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 { ScreenshotRefImageData } from '../../../common/runtime_types';
/**
* Draws image fragments on a canvas.
* @param data Contains overall image size, fragment dimensions, and the blobs of image data to render.
* @param canvas A canvas to use for the rendering.
* @returns A promise that will resolve when the final draw operation completes.
*/
export async function composeScreenshotRef(
data: ScreenshotRefImageData,
canvas: HTMLCanvasElement
) {
const {
ref: { screenshotRef, blocks },
} = data;
canvas.width = screenshotRef.screenshot_ref.width;
canvas.height = screenshotRef.screenshot_ref.height;
const ctx = canvas.getContext('2d', { alpha: false });
/**
* We need to treat each operation as an async task, otherwise we will race between drawing image
* chunks and extracting the final data URL from the canvas; without this, the image could be blank or incomplete.
*/
const drawOperations: Array<Promise<void>> = [];
for (const block of screenshotRef.screenshot_ref.blocks) {
drawOperations.push(
new Promise<void>((resolve, reject) => {
const img = new Image();
const { top, left, width, height, hash } = block;
const blob = blocks.find((b) => b.id === hash);
if (!blob) {
reject(Error(`Error processing image. Expected image data with hash ${hash} is missing`));
} else {
img.onload = () => {
ctx?.drawImage(img, left, top, width, height);
resolve();
};
img.src = `data:image/jpg;base64,${blob.synthetics.blob}`;
}
})
);
}
// once all `draw` operations finish, caller can extract img string
return Promise.all(drawOperations);
}

View file

@ -8,7 +8,12 @@
import { apiService } from './utils';
import { FetchJourneyStepsParams } from '../actions/journey';
import {
Ping,
FailedStepsApiResponse,
FailedStepsApiResponseType,
JourneyStep,
JourneyStepType,
ScreenshotImageBlob,
ScreenshotRefImageData,
SyntheticsJourneyApiResponse,
SyntheticsJourneyApiResponseType,
} from '../../../common/runtime_types';
@ -16,23 +21,23 @@ import {
export async function fetchJourneySteps(
params: FetchJourneyStepsParams
): Promise<SyntheticsJourneyApiResponse> {
return (await apiService.get(
return apiService.get(
`/api/uptime/journey/${params.checkGroup}`,
{ syntheticEventTypes: params.syntheticEventTypes },
SyntheticsJourneyApiResponseType
)) as SyntheticsJourneyApiResponse;
);
}
export async function fetchJourneysFailedSteps({
checkGroups,
}: {
checkGroups: string[];
}): Promise<SyntheticsJourneyApiResponse> {
return (await apiService.get(
}): Promise<FailedStepsApiResponse> {
return apiService.get(
`/api/uptime/journeys/failed_steps`,
{ checkGroups },
SyntheticsJourneyApiResponseType
)) as SyntheticsJourneyApiResponse;
FailedStepsApiResponseType
);
}
export async function fetchLastSuccessfulStep({
@ -43,15 +48,21 @@ export async function fetchLastSuccessfulStep({
monitorId: string;
timestamp: string;
stepIndex: number;
}): Promise<Ping> {
return (await apiService.get(`/api/uptime/synthetics/step/success/`, {
monitorId,
timestamp,
stepIndex,
})) as Ping;
}): Promise<JourneyStep> {
return await apiService.get(
`/api/uptime/synthetics/step/success/`,
{
monitorId,
timestamp,
stepIndex,
},
JourneyStepType
);
}
export async function getJourneyScreenshot(imgSrc: string) {
export async function getJourneyScreenshot(
imgSrc: string
): Promise<ScreenshotImageBlob | ScreenshotRefImageData | null> {
try {
const imgRequest = new Request(imgSrc);
@ -61,16 +72,22 @@ export async function getJourneyScreenshot(imgSrc: string) {
return null;
}
const imgBlob = await response.blob();
const contentType = response.headers.get('content-type');
const stepName = response.headers.get('caption-name');
const maxSteps = response.headers.get('max-steps');
return {
stepName,
maxSteps: Number(maxSteps ?? 0),
src: URL.createObjectURL(imgBlob),
};
const maxSteps = Number(response.headers.get('max-steps') ?? 0);
if (contentType?.indexOf('application/json') !== -1) {
return {
stepName,
maxSteps,
ref: await response.json(),
};
} else {
return {
stepName,
maxSteps,
src: URL.createObjectURL(await response.blob()),
};
}
} catch (e) {
return null;
}

View file

@ -6,7 +6,7 @@
*/
import { handleActions, Action } from 'redux-actions';
import { Ping, SyntheticsJourneyApiResponse } from '../../../common/runtime_types';
import { JourneyStep, SyntheticsJourneyApiResponse } from '../../../common/runtime_types';
import { pruneJourneyState } from '../actions/journey';
import {
FetchJourneyStepsParams,
@ -18,7 +18,7 @@ import {
export interface JourneyState {
checkGroup: string;
steps: Ping[];
steps: JourneyStep[];
details?: SyntheticsJourneyApiResponse['details'];
loading: boolean;
error?: Error;

View file

@ -0,0 +1,104 @@
/*
* 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 { getJourneyDetails } from './get_journey_details';
import { mockSearchResult } from './helper';
describe('getJourneyDetails', () => {
let mockData: unknown;
beforeEach(() => {
mockData = {
_id: 'uTjtNHoBu1okBtVwOgMb',
_source: {
'@timestamp': '2021-06-22T18:13:19.013Z',
synthetics: {
package_version: '1.0.0-beta.2',
journey: {
name: 'inline',
id: 'inline',
},
payload: {
source:
'async ({ page, browser, params }) => {\n scriptFn.apply(null, [core_1.step, page, browser, params]);\n }',
params: {},
},
index: 0,
type: 'journey/start',
},
monitor: {
name: 'My Monitor - inline',
timespan: {
lt: '2021-06-22T18:14:19.013Z',
gte: '2021-06-22T18:13:19.013Z',
},
check_group: '85946468-d385-11eb-8848-acde48001122',
id: 'my-browser-monitor-inline',
type: 'browser',
status: 'up',
},
},
};
});
it('formats ref detail data', async () => {
expect(
await getJourneyDetails({
uptimeEsClient: mockSearchResult(mockData),
checkGroup: '85946468-d385-11eb-8848-acde48001122',
})
).toMatchInlineSnapshot(`
Object {
"journey": Object {
"@timestamp": "2021-06-22T18:13:19.013Z",
"_id": "uTjtNHoBu1okBtVwOgMb",
"monitor": Object {
"check_group": "85946468-d385-11eb-8848-acde48001122",
"id": "my-browser-monitor-inline",
"name": "My Monitor - inline",
"status": "up",
"timespan": Object {
"gte": "2021-06-22T18:13:19.013Z",
"lt": "2021-06-22T18:14:19.013Z",
},
"type": "browser",
},
"synthetics": Object {
"index": 0,
"journey": Object {
"id": "inline",
"name": "inline",
},
"package_version": "1.0.0-beta.2",
"payload": Object {
"params": Object {},
"source": "async ({ page, browser, params }) => {
scriptFn.apply(null, [core_1.step, page, browser, params]);
}",
},
"type": "journey/start",
},
},
"next": Object {
"checkGroup": "85946468-d385-11eb-8848-acde48001122",
"timestamp": "2021-06-22T18:13:19.013Z",
},
"previous": Object {
"checkGroup": "85946468-d385-11eb-8848-acde48001122",
"timestamp": "2021-06-22T18:13:19.013Z",
},
"timestamp": "2021-06-22T18:13:19.013Z",
}
`);
});
it('returns null for 0 hits', async () => {
expect(
await getJourneyDetails({ uptimeEsClient: mockSearchResult([]), checkGroup: 'check_group' })
).toBe(null);
});
});

View file

@ -7,7 +7,10 @@
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { UMElasticsearchQueryFn } from '../adapters/framework';
import { SyntheticsJourneyApiResponse } from '../../../common/runtime_types';
import {
JourneyStep,
SyntheticsJourneyApiResponse,
} from '../../../common/runtime_types/ping/synthetics';
export interface GetJourneyDetails {
checkGroup: string;
@ -39,8 +42,9 @@ export const getJourneyDetails: UMElasticsearchQueryFn<
const { body: thisJourney } = await uptimeEsClient.search({ body: baseParams });
if (thisJourney?.hits?.hits.length > 0) {
const thisJourneySource: any = thisJourney.hits.hits[0]._source;
if (thisJourney.hits.hits.length > 0) {
const { _id, _source } = thisJourney.hits.hits[0];
const thisJourneySource = Object.assign({ _id }, _source) as JourneyStep;
const baseSiblingParams = {
query: {

View file

@ -0,0 +1,90 @@
/*
* 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 { getJourneyFailedSteps } from './get_journey_failed_steps';
import { mockSearchResult } from './helper';
describe('getJourneyFailedSteps', () => {
let mockData: unknown;
beforeEach(() => {
mockData = {
_id: 'uTjtNHoBu1okBtVwOgMb',
_source: {
'@timestamp': '2021-06-22T18:13:19.013Z',
synthetics: {
package_version: '1.0.0-beta.2',
journey: {
name: 'inline',
id: 'inline',
},
payload: {
source:
'async ({ page, browser, params }) => {\n scriptFn.apply(null, [core_1.step, page, browser, params]);\n }',
params: {},
},
index: 0,
type: 'journey/start',
},
monitor: {
name: 'My Monitor - inline',
timespan: {
lt: '2021-06-22T18:14:19.013Z',
gte: '2021-06-22T18:13:19.013Z',
},
check_group: '85946468-d385-11eb-8848-acde48001122',
id: 'my-browser-monitor-inline',
type: 'browser',
status: 'up',
},
},
};
});
it('formats failed steps', async () => {
expect(
await getJourneyFailedSteps({
uptimeEsClient: mockSearchResult(mockData),
checkGroups: ['chg1', 'chg2'],
})
).toMatchInlineSnapshot(`
Array [
Object {
"@timestamp": "2021-06-22T18:13:19.013Z",
"_id": "uTjtNHoBu1okBtVwOgMb",
"monitor": Object {
"check_group": "85946468-d385-11eb-8848-acde48001122",
"id": "my-browser-monitor-inline",
"name": "My Monitor - inline",
"status": "up",
"timespan": Object {
"gte": "2021-06-22T18:13:19.013Z",
"lt": "2021-06-22T18:14:19.013Z",
},
"type": "browser",
},
"synthetics": Object {
"index": 0,
"journey": Object {
"id": "inline",
"name": "inline",
},
"package_version": "1.0.0-beta.2",
"payload": Object {
"params": Object {},
"source": "async ({ page, browser, params }) => {
scriptFn.apply(null, [core_1.step, page, browser, params]);
}",
},
"type": "journey/start",
},
"timestamp": "2021-06-22T18:13:19.013Z",
},
]
`);
});
});

View file

@ -6,19 +6,18 @@
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { SearchHit } from '../../../../../../src/core/types/elasticsearch';
import { asMutableArray } from '../../../common/utils/as_mutable_array';
import { UMElasticsearchQueryFn } from '../adapters/framework';
import { Ping } from '../../../common/runtime_types';
import { JourneyStep } from '../../../common/runtime_types/ping/synthetics';
export interface GetJourneyStepsParams {
checkGroups: string[];
}
export const getJourneyFailedSteps: UMElasticsearchQueryFn<GetJourneyStepsParams, Ping> = async ({
uptimeEsClient,
checkGroups,
}) => {
export const getJourneyFailedSteps: UMElasticsearchQueryFn<
GetJourneyStepsParams,
JourneyStep[]
> = async ({ uptimeEsClient, checkGroups }) => {
const params = {
query: {
bool: {
@ -53,11 +52,11 @@ export const getJourneyFailedSteps: UMElasticsearchQueryFn<GetJourneyStepsParams
const { body: result } = await uptimeEsClient.search({ body: params });
return ((result.hits.hits as Array<SearchHit<Ping>>).map((h) => {
const source = h._source as Ping & { '@timestamp': string };
return result.hits.hits.map(({ _id, _source }) => {
const step = Object.assign({ _id }, _source) as JourneyStep;
return {
...source,
timestamp: source['@timestamp'],
...step,
timestamp: step['@timestamp'],
};
}) as unknown) as Ping;
});
};

View file

@ -0,0 +1,133 @@
/*
* 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 { getJourneyScreenshot } from './get_journey_screenshot';
import { mockSearchResult } from './helper';
describe('getJourneyScreenshot', () => {
it('returns screenshot data', async () => {
const screenshotResult = {
_id: 'id',
_source: {
synthetics: {
blob_mime: 'image/jpeg',
blob: 'image data',
step: {
name: 'load homepage',
},
type: 'step/screenshot',
},
},
};
expect(
await getJourneyScreenshot({
uptimeEsClient: mockSearchResult([], {
// @ts-expect-error incomplete search result
step: { image: { hits: { hits: [screenshotResult] } } },
}),
checkGroup: 'checkGroup',
stepIndex: 0,
})
).toEqual({
synthetics: {
blob: 'image data',
blob_mime: 'image/jpeg',
step: {
name: 'load homepage',
},
type: 'step/screenshot',
},
totalSteps: 0,
});
});
it('returns ref data', async () => {
const screenshotRefResult = {
_id: 'id',
_source: {
'@timestamp': '123',
monitor: {
check_group: 'check_group',
},
screenshot_ref: {
width: 10,
height: 20,
blocks: [
{
hash: 'hash1',
top: 0,
left: 0,
height: 2,
width: 4,
},
{
hash: 'hash2',
top: 0,
left: 2,
height: 2,
width: 4,
},
],
},
synthetics: {
package_version: 'v1.0.0',
step: {
name: 'name',
index: 0,
},
type: 'step/screenshot_ref',
},
},
};
expect(
await getJourneyScreenshot({
uptimeEsClient: mockSearchResult([], {
// @ts-expect-error incomplete search result
step: { image: { hits: { hits: [screenshotRefResult] } } },
}),
checkGroup: 'checkGroup',
stepIndex: 0,
})
).toMatchInlineSnapshot(`
Object {
"@timestamp": "123",
"monitor": Object {
"check_group": "check_group",
},
"screenshot_ref": Object {
"blocks": Array [
Object {
"hash": "hash1",
"height": 2,
"left": 0,
"top": 0,
"width": 4,
},
Object {
"hash": "hash2",
"height": 2,
"left": 2,
"top": 0,
"width": 4,
},
],
"height": 20,
"width": 10,
},
"synthetics": Object {
"package_version": "v1.0.0",
"step": Object {
"index": 0,
"name": "name",
},
"type": "step/screenshot_ref",
},
"totalSteps": 0,
}
`);
});
});

View file

@ -6,30 +6,22 @@
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { UMElasticsearchQueryFn } from '../adapters/framework';
import { Ping } from '../../../common/runtime_types/ping';
import { UMElasticsearchQueryFn } from '../adapters';
import { RefResult, FullScreenshot } from '../../../common/runtime_types/ping/synthetics';
export interface GetJourneyScreenshotParams {
checkGroup: string;
stepIndex: number;
interface ResultType {
_source: RefResult | FullScreenshot;
}
export interface GetJourneyScreenshotResults {
blob: string | null;
mimeType: string | null;
stepName: string;
totalSteps: number;
}
export type ScreenshotReturnTypesUnion =
| ((FullScreenshot | RefResult) & { totalSteps: number })
| null;
export const getJourneyScreenshot: UMElasticsearchQueryFn<
GetJourneyScreenshotParams,
any
> = async ({
uptimeEsClient,
checkGroup,
stepIndex,
}): Promise<GetJourneyScreenshotResults | null> => {
const params = {
{ checkGroup: string; stepIndex: number },
ScreenshotReturnTypesUnion
> = async ({ checkGroup, stepIndex, uptimeEsClient }) => {
const body = {
track_total_hits: true,
size: 0,
query: {
@ -41,8 +33,8 @@ export const getJourneyScreenshot: UMElasticsearchQueryFn<
},
},
{
term: {
'synthetics.type': 'step/screenshot',
terms: {
'synthetics.type': ['step/screenshot', 'step/screenshot_ref'],
},
},
] as QueryDslQueryContainer[],
@ -59,25 +51,22 @@ export const getJourneyScreenshot: UMElasticsearchQueryFn<
image: {
top_hits: {
size: 1,
_source: ['synthetics.blob', 'synthetics.blob_mime', 'synthetics.step.name'],
},
},
},
},
},
};
const { body: result } = await uptimeEsClient.search({ body: params });
if (result?.hits?.total.value < 1) {
return null;
}
const result = await uptimeEsClient.search({ body });
const stepHit = result?.aggregations?.step.image.hits.hits[0]?._source as Ping;
const screenshotsOrRefs =
(result.body.aggregations?.step.image.hits.hits as ResultType[]) ?? null;
if (screenshotsOrRefs.length === 0) return null;
return {
blob: stepHit?.synthetics?.blob ?? null,
mimeType: stepHit?.synthetics?.blob_mime ?? null,
stepName: stepHit?.synthetics?.step?.name ?? '',
totalSteps: result?.hits?.total.value,
...screenshotsOrRefs[0]._source,
totalSteps: result.body.hits.total.value,
};
};

View file

@ -0,0 +1,56 @@
/*
* 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 { getJourneyScreenshotBlocks } from './get_journey_screenshot_blocks';
import { mockSearchResult } from './helper';
describe('getJourneyScreenshotBlocks', () => {
it('returns formatted blocks', async () => {
expect(
await getJourneyScreenshotBlocks({
uptimeEsClient: mockSearchResult([
{
_id: 'hash1',
_source: {
synthetics: {
blob: 'image data',
blob_mime: 'image/jpeg',
},
},
},
{
_id: 'hash2',
_source: {
synthetics: {
blob: 'image data',
blob_mime: 'image/jpeg',
},
},
},
]),
blockIds: ['hash1', 'hash2'],
})
).toMatchInlineSnapshot(`
Array [
Object {
"id": "hash1",
"synthetics": Object {
"blob": "image data",
"blob_mime": "image/jpeg",
},
},
Object {
"id": "hash2",
"synthetics": Object {
"blob": "image data",
"blob_mime": "image/jpeg",
},
},
]
`);
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { ScreenshotBlockDoc } from '../../../common/runtime_types/ping/synthetics';
import { UMElasticsearchQueryFn } from '../adapters/framework';
interface ScreenshotBlockResultType {
_id: string;
_source: {
synthetics: {
blob: string;
blob_mime: string;
};
};
}
export const getJourneyScreenshotBlocks: UMElasticsearchQueryFn<
{ blockIds: string[] },
ScreenshotBlockDoc[]
> = async ({ blockIds, uptimeEsClient }) => {
const body = {
query: {
bool: {
filter: [
{
ids: {
values: blockIds,
},
},
],
},
},
size: 1000,
};
const fetchScreenshotBlocksResult = await uptimeEsClient.search({ body });
return (fetchScreenshotBlocksResult.body.hits.hits as ScreenshotBlockResultType[]).map(
({ _id, _source }) => ({
id: _id,
synthetics: {
blob: _source.synthetics.blob,
blob_mime: _source.synthetics.blob_mime,
},
})
);
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { JourneyStep } from '../../../common/runtime_types/ping/synthetics';
import { getJourneySteps, formatSyntheticEvents } from './get_journey_steps';
import { getUptimeESMockClient } from './helper';
@ -13,10 +14,11 @@ describe('getJourneySteps request module', () => {
it('returns default steps if none are provided', () => {
expect(formatSyntheticEvents()).toMatchInlineSnapshot(`
Array [
"step/end",
"cmd/status",
"step/screenshot",
"journey/browserconsole",
"step/end",
"step/screenshot",
"step/screenshot_ref",
]
`);
});
@ -107,8 +109,8 @@ describe('getJourneySteps request module', () => {
it('formats ES result', async () => {
const { esClient: mockEsClient, uptimeEsClient } = getUptimeESMockClient();
mockEsClient.search.mockResolvedValueOnce(data as any);
const result: any = await getJourneySteps({
mockEsClient.search.mockResolvedValueOnce(data);
const result: JourneyStep[] = await getJourneySteps({
uptimeEsClient,
checkGroup: '2bf952dc-64b5-11eb-8b3b-42010a84000d',
});
@ -120,10 +122,11 @@ describe('getJourneySteps request module', () => {
Object {
"terms": Object {
"synthetics.type": Array [
"step/end",
"cmd/status",
"step/screenshot",
"journey/browserconsole",
"step/end",
"step/screenshot",
"step/screenshot_ref",
],
},
}
@ -156,10 +159,12 @@ describe('getJourneySteps request module', () => {
expect(result).toHaveLength(2);
// `getJourneySteps` is responsible for formatting these fields, so we need to check them
result.forEach((step: any) => {
expect(['2021-02-01T17:45:19.001Z', '2021-02-01T17:45:49.944Z']).toContain(step.timestamp);
expect(['o6myXncBFt2V8m6r6z-r', 'IjqzXncBn2sjqrYxYoCG']).toContain(step.docId);
expect(step.synthetics.screenshotExists).toBeDefined();
result.forEach((step: JourneyStep) => {
expect(['2021-02-01T17:45:19.001Z', '2021-02-01T17:45:49.944Z']).toContain(
step['@timestamp']
);
expect(['o6myXncBFt2V8m6r6z-r', 'IjqzXncBn2sjqrYxYoCG']).toContain(step._id);
expect(step.synthetics.isFullScreenshot).toBeDefined();
});
});
@ -168,9 +173,9 @@ describe('getJourneySteps request module', () => {
data.body.hits.hits[0]._source.synthetics.type = 'step/screenshot';
data.body.hits.hits[0]._source.synthetics.step.index = 2;
mockEsClient.search.mockResolvedValueOnce(data as any);
mockEsClient.search.mockResolvedValueOnce(data);
const result: any = await getJourneySteps({
const result: JourneyStep[] = await getJourneySteps({
uptimeEsClient,
checkGroup: '2bf952dc-64b5-11eb-8b3b-42010a84000d',
syntheticEventTypes: ['stderr', 'step/end'],
@ -191,7 +196,7 @@ describe('getJourneySteps request module', () => {
`);
expect(result).toHaveLength(1);
expect(result[0].synthetics.screenshotExists).toBe(true);
expect(result[0].synthetics.isFullScreenshot).toBe(true);
});
});
});

View file

@ -6,17 +6,22 @@
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { SearchHit } from 'src/core/types/elasticsearch/search';
import { asMutableArray } from '../../../common/utils/as_mutable_array';
import { UMElasticsearchQueryFn } from '../adapters/framework';
import { Ping } from '../../../common/runtime_types';
import { JourneyStep } from '../../../common/runtime_types/ping/synthetics';
export interface GetJourneyStepsParams {
checkGroup: string;
syntheticEventTypes?: string | string[];
}
const defaultEventTypes = ['step/end', 'cmd/status', 'step/screenshot', 'journey/browserconsole'];
const defaultEventTypes = [
'cmd/status',
'journey/browserconsole',
'step/end',
'step/screenshot',
'step/screenshot_ref',
];
export const formatSyntheticEvents = (eventTypes?: string | string[]) => {
if (!eventTypes) {
@ -26,11 +31,12 @@ export const formatSyntheticEvents = (eventTypes?: string | string[]) => {
}
};
export const getJourneySteps: UMElasticsearchQueryFn<GetJourneyStepsParams, Ping> = async ({
uptimeEsClient,
checkGroup,
syntheticEventTypes,
}) => {
type ResultType = JourneyStep & { '@timestamp': string };
export const getJourneySteps: UMElasticsearchQueryFn<
GetJourneyStepsParams,
JourneyStep[]
> = async ({ uptimeEsClient, checkGroup, syntheticEventTypes }) => {
const params = {
query: {
bool: {
@ -53,28 +59,43 @@ export const getJourneySteps: UMElasticsearchQueryFn<GetJourneyStepsParams, Ping
{ '@timestamp': { order: 'asc' } },
] as const),
_source: {
excludes: ['synthetics.blob'],
excludes: ['synthetics.blob', 'screenshot_ref'],
},
size: 500,
};
const { body: result } = await uptimeEsClient.search({ body: params });
const screenshotIndexes: number[] = (result.hits.hits as Array<SearchHit<Ping>>)
.filter((h) => h._source?.synthetics?.type === 'step/screenshot')
.map((h) => h._source?.synthetics?.step?.index as number);
const steps = result.hits.hits.map(
({ _id, _source }) => Object.assign({ _id }, _source) as ResultType
);
return ((result.hits.hits as Array<SearchHit<Ping>>)
.filter((h) => h._source?.synthetics?.type !== 'step/screenshot')
.map((h) => {
const source = h._source as Ping & { '@timestamp': string };
return {
...source,
timestamp: source['@timestamp'],
docId: h._id,
synthetics: {
...source.synthetics,
screenshotExists: screenshotIndexes.some((i) => i === source.synthetics?.step?.index),
},
};
}) as unknown) as Ping;
const screenshotIndexList: number[] = [];
const refIndexList: number[] = [];
const stepsWithoutImages: ResultType[] = [];
/**
* Store screenshot indexes, we use these to determine if a step has a screenshot below.
* Store steps that are not screenshots, we return these to the client.
*/
for (const step of steps) {
const { synthetics } = step;
if (synthetics.type === 'step/screenshot' && synthetics?.step?.index) {
screenshotIndexList.push(synthetics.step.index);
} else if (synthetics.type === 'step/screenshot_ref' && synthetics?.step?.index) {
refIndexList.push(synthetics.step.index);
} else {
stepsWithoutImages.push(step);
}
}
return stepsWithoutImages.map(({ _id, ...rest }) => ({
_id,
...rest,
timestamp: rest['@timestamp'],
synthetics: {
...rest.synthetics,
isFullScreenshot: screenshotIndexList.some((i) => i === rest?.synthetics?.step?.index),
isScreenshotRef: refIndexList.some((i) => i === rest?.synthetics?.step?.index),
},
}));
};

View file

@ -7,7 +7,7 @@
import { estypes } from '@elastic/elasticsearch';
import { UMElasticsearchQueryFn } from '../adapters/framework';
import { Ping } from '../../../common/runtime_types/ping';
import { JourneyStep } from '../../../common/runtime_types/ping';
export interface GetStepScreenshotParams {
monitorId: string;
@ -17,7 +17,7 @@ export interface GetStepScreenshotParams {
export const getStepLastSuccessfulStep: UMElasticsearchQueryFn<
GetStepScreenshotParams,
any
JourneyStep | null
> = async ({ uptimeEsClient, monitorId, stepIndex, timestamp }) => {
const lastSuccessCheckParams: estypes.SearchRequest['body'] = {
size: 1,
@ -65,11 +65,11 @@ export const getStepLastSuccessfulStep: UMElasticsearchQueryFn<
const { body: result } = await uptimeEsClient.search({ body: lastSuccessCheckParams });
if (result?.hits?.total.value < 1) {
if (result.hits.total.value < 1) {
return null;
}
const step = result?.hits.hits[0]._source as Ping & { '@timestamp': string };
const step = result.hits.hits[0]._source as JourneyStep & { '@timestamp': string };
return {
...step,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { SearchResponse } from '@elastic/elasticsearch/api/types';
import { AggregationsAggregate, SearchResponse } from '@elastic/elasticsearch/api/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ElasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
import {
@ -81,3 +81,34 @@ export const getUptimeESMockClient = (
}),
};
};
export function mockSearchResult(
data: unknown,
aggregations: Record<string, AggregationsAggregate> = {}
): UptimeESClient {
const { esClient: mockEsClient, uptimeEsClient } = getUptimeESMockClient();
// @ts-expect-error incomplete search response
mockEsClient.search.mockResolvedValue({
body: {
took: 18,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
hits: Array.isArray(data) ? data : [data],
max_score: 0.0,
total: {
value: Array.isArray(data) ? data.length : 0,
relation: 'gte',
},
},
aggregations,
},
});
return uptimeEsClient;
}

View file

@ -25,6 +25,7 @@ import { getJourneyDetails } from './get_journey_details';
import { getNetworkEvents } from './get_network_events';
import { getJourneyFailedSteps } from './get_journey_failed_steps';
import { getStepLastSuccessfulStep } from './get_last_successful_step';
import { getJourneyScreenshotBlocks } from './get_journey_screenshot_blocks';
export const requests = {
getCerts,
@ -45,6 +46,7 @@ export const requests = {
getJourneyFailedSteps,
getStepLastSuccessfulStep,
getJourneyScreenshot,
getJourneyScreenshotBlocks,
getJourneyDetails,
getNetworkEvents,
};

View file

@ -12,6 +12,7 @@ import {
createGetPingsRoute,
createJourneyRoute,
createJourneyScreenshotRoute,
createJourneyScreenshotBlockRoute,
} from './pings';
import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings';
import { createLogPageViewRoute } from './telemetry';
@ -51,6 +52,7 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [
createGetMonitorDurationRoute,
createJourneyRoute,
createJourneyScreenshotRoute,
createJourneyScreenshotBlockRoute,
createNetworkEventsRoute,
createJourneyFailedStepsRoute,
createLastSuccessfulStepRoute,

View file

@ -9,3 +9,4 @@ export { createGetPingsRoute } from './get_pings';
export { createGetPingHistogramRoute } from './get_ping_histogram';
export { createJourneyRoute } from './journeys';
export { createJourneyScreenshotRoute } from './journey_screenshots';
export { createJourneyScreenshotBlockRoute } from './journey_screenshot_blocks';

View file

@ -0,0 +1,53 @@
/*
* 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 * as t from 'io-ts';
import { isRight } from 'fp-ts/lib/Either';
import { schema } from '@kbn/config-schema';
import { UMServerLibs } from '../../lib/lib';
import { UMRestApiRouteFactory } from '../types';
import { ScreenshotBlockDoc } from '../../../common/runtime_types/ping/synthetics';
export const createJourneyScreenshotBlockRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
method: 'GET',
path: '/api/uptime/journey/screenshot/block',
validate: {
query: schema.object({
hash: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]),
_inspect: schema.maybe(schema.boolean()),
}),
},
handler: async ({ request, response, uptimeEsClient }) => {
const { hash } = request.query;
const decoded = t.union([t.string, t.array(t.string)]).decode(hash);
if (!isRight(decoded)) {
return response.badRequest();
}
const { right: data } = decoded;
let result: ScreenshotBlockDoc[];
try {
result = await libs.requests.getJourneyScreenshotBlocks({
blockIds: Array.isArray(data) ? data : [data],
uptimeEsClient,
});
} catch (e: unknown) {
return response.custom({ statusCode: 500, body: { message: e } });
}
if (result.length === 0) {
return response.notFound();
}
return response.ok({
body: result,
headers: {
// we can cache these blocks with extreme prejudice as they are inherently unchanging
// when queried by ID, since the ID is the hash of the data
'Cache-Control': 'max-age=604800',
},
});
},
});

View file

@ -6,9 +6,23 @@
*/
import { schema } from '@kbn/config-schema';
import {
isRefResult,
isFullScreenshot,
ScreenshotBlockDoc,
} from '../../../common/runtime_types/ping/synthetics';
import { UMServerLibs } from '../../lib/lib';
import { ScreenshotReturnTypesUnion } from '../../lib/requests/get_journey_screenshot';
import { UMRestApiRouteFactory } from '../types';
function getSharedHeaders(stepName: string, totalSteps: number) {
return {
'cache-control': 'max-age=600',
'caption-name': stepName,
'max-steps': String(totalSteps),
};
}
export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
method: 'GET',
path: '/api/uptime/journey/screenshot/{checkGroup}/{stepIndex}',
@ -25,23 +39,49 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ
handler: async ({ uptimeEsClient, request, response }) => {
const { checkGroup, stepIndex } = request.params;
const result = await libs.requests.getJourneyScreenshot({
uptimeEsClient,
checkGroup,
stepIndex,
});
if (result === null || !result.blob) {
return response.notFound();
let result: ScreenshotReturnTypesUnion | null = null;
try {
result = await libs.requests.getJourneyScreenshot({
uptimeEsClient,
checkGroup,
stepIndex,
});
} catch (e) {
return response.customError({ body: { message: e }, statusCode: 500 });
}
return response.ok({
body: Buffer.from(result.blob, 'base64'),
headers: {
'content-type': result.mimeType || 'image/png', // falls back to 'image/png' for earlier versions of synthetics
'cache-control': 'max-age=600',
'caption-name': result.stepName,
'max-steps': result.totalSteps,
},
});
if (isFullScreenshot(result)) {
if (!result.synthetics.blob) {
return response.notFound();
}
return response.ok({
body: Buffer.from(result.synthetics.blob, 'base64'),
headers: {
'content-type': result.synthetics.blob_mime || 'image/png', // falls back to 'image/png' for earlier versions of synthetics
...getSharedHeaders(result.synthetics.step.name, result.totalSteps),
},
});
} else if (isRefResult(result)) {
const blockIds = result.screenshot_ref.blocks.map(({ hash }) => hash);
let blocks: ScreenshotBlockDoc[];
try {
blocks = await libs.requests.getJourneyScreenshotBlocks({
uptimeEsClient,
blockIds,
});
} catch (e: unknown) {
return response.custom({ statusCode: 500, body: { message: e } });
}
return response.ok({
body: {
screenshotRef: result,
blocks,
},
headers: getSharedHeaders(result.synthetics.step.name, result.totalSteps ?? 0),
});
}
return response.notFound();
},
});

View file

@ -25,27 +25,31 @@ export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) =>
_inspect: schema.maybe(schema.boolean()),
}),
},
handler: async ({ uptimeEsClient, request }): Promise<any> => {
handler: async ({ uptimeEsClient, request, response }): Promise<any> => {
const { checkGroup } = request.params;
const { syntheticEventTypes } = request.query;
const [result, details] = await Promise.all([
await libs.requests.getJourneySteps({
uptimeEsClient,
checkGroup,
syntheticEventTypes,
}),
await libs.requests.getJourneyDetails({
uptimeEsClient,
checkGroup,
}),
]);
try {
const [result, details] = await Promise.all([
await libs.requests.getJourneySteps({
uptimeEsClient,
checkGroup,
syntheticEventTypes,
}),
await libs.requests.getJourneyDetails({
uptimeEsClient,
checkGroup,
}),
]);
return {
checkGroup,
steps: result,
details,
};
return {
checkGroup,
steps: result,
details,
};
} catch (e: unknown) {
return response.custom({ statusCode: 500, body: { message: e } });
}
},
});
@ -58,16 +62,19 @@ export const createJourneyFailedStepsRoute: UMRestApiRouteFactory = (libs: UMSer
_inspect: schema.maybe(schema.boolean()),
}),
},
handler: async ({ uptimeEsClient, request }): Promise<any> => {
handler: async ({ uptimeEsClient, request, response }): Promise<any> => {
const { checkGroups } = request.query;
const result = await libs.requests.getJourneyFailedSteps({
uptimeEsClient,
checkGroups,
});
return {
checkGroups,
steps: result,
};
try {
const result = await libs.requests.getJourneyFailedSteps({
uptimeEsClient,
checkGroups,
});
return {
checkGroups,
steps: result,
};
} catch (e) {
return response.customError({ statusCode: 500, body: e });
}
},
});

View file

@ -6,6 +6,11 @@
*/
import { schema } from '@kbn/config-schema';
import {
isRefResult,
isFullScreenshot,
JourneyStep,
} from '../../../common/runtime_types/ping/synthetics';
import { UMServerLibs } from '../../lib/lib';
import { UMRestApiRouteFactory } from '../types';
@ -23,11 +28,36 @@ export const createLastSuccessfulStepRoute: UMRestApiRouteFactory = (libs: UMSer
handler: async ({ uptimeEsClient, request, response }) => {
const { timestamp, monitorId, stepIndex } = request.query;
return await libs.requests.getStepLastSuccessfulStep({
const step: JourneyStep | null = await libs.requests.getStepLastSuccessfulStep({
uptimeEsClient,
monitorId,
stepIndex,
timestamp,
});
if (step === null) {
return response.notFound();
}
if (!step.synthetics?.step?.index) {
return response.ok({ body: step });
}
const screenshot = await libs.requests.getJourneyScreenshot({
uptimeEsClient,
checkGroup: step.monitor.check_group,
stepIndex: step.synthetics.step.index,
});
if (screenshot === null) {
return response.ok({ body: step });
}
step.synthetics.isScreenshotRef = isRefResult(screenshot);
step.synthetics.isFullScreenshot = isFullScreenshot(screenshot);
return response.ok({
body: step,
});
},
});