From 0c8d5e8f89df135f42ab82079c07e02c7f171d49 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Tue, 29 Jun 2021 08:08:52 -0400 Subject: [PATCH] [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`. --- .../common/runtime_types/monitor/details.ts | 31 --- .../common/runtime_types/monitor/index.ts | 1 - .../uptime/common/runtime_types/ping/index.ts | 1 + .../uptime/common/runtime_types/ping/ping.ts | 58 ++--- .../runtime_types/ping/synthetics.test.ts | 150 ++++++++++++ .../common/runtime_types/ping/synthetics.ts | 207 ++++++++++++++++ .../monitor/ping_list/columns/failed_step.tsx | 10 +- .../ping_timestamp/nav_buttons.test.tsx | 89 ------- .../columns/ping_timestamp/nav_buttons.tsx | 59 ----- .../ping_timestamp/no_image_display.tsx | 2 +- .../ping_timestamp/ping_timestamp.test.tsx | 76 +++--- .../columns/ping_timestamp/ping_timestamp.tsx | 29 ++- .../step_image_caption.test.tsx | 9 + .../ping_timestamp/step_image_caption.tsx | 5 +- .../ping_timestamp/step_image_popover.tsx | 151 ++++++++++-- .../monitor/ping_list/ping_list.test.tsx | 6 +- .../monitor/ping_list/ping_list.tsx | 9 +- .../step_detail/use_monitor_breadcrumb.tsx | 10 +- .../step_expanded_row/screenshot_link.tsx | 4 +- .../step_expanded_row/step_screenshots.tsx | 21 +- .../synthetics/check_steps/step_image.tsx | 9 +- ...step_list.test.tsx => steps_list.test.tsx} | 16 +- .../synthetics/check_steps/steps_list.tsx | 40 +-- .../check_steps/use_expanded_row.test.tsx | 24 +- .../check_steps/use_expanded_row.tsx | 20 +- .../synthetics/console_event.test.tsx | 10 +- .../components/synthetics/console_event.tsx | 6 +- .../console_output_event_list.test.tsx | 228 +++++++----------- .../synthetics/console_output_event_list.tsx | 12 +- .../synthetics/executed_step.test.tsx | 12 +- .../components/synthetics/executed_step.tsx | 4 +- .../step_screenshot_display.test.tsx | 45 +++- .../synthetics/step_screenshot_display.tsx | 161 +++++++++---- x-pack/plugins/uptime/public/hooks/index.ts | 9 +- .../public/hooks/use_composite_image.ts | 55 +++++ .../lib/__mocks__/screenshot_ref.mock.ts | 62 +++++ .../lib/__mocks__/use_composite_image.mock.ts | 23 ++ .../lib/helper/compose_screenshot_images.ts | 56 +++++ .../uptime/public/state/api/journey.ts | 63 +++-- .../uptime/public/state/reducers/journey.ts | 4 +- .../lib/requests/get_journey_details.test.ts | 104 ++++++++ .../lib/requests/get_journey_details.ts | 10 +- .../requests/get_journey_failed_steps.test.ts | 90 +++++++ .../lib/requests/get_journey_failed_steps.ts | 21 +- .../requests/get_journey_screenshot.test.ts | 133 ++++++++++ .../lib/requests/get_journey_screenshot.ts | 51 ++-- .../get_journey_screenshot_blocks.test.ts | 56 +++++ .../requests/get_journey_screenshot_blocks.ts | 51 ++++ .../lib/requests/get_journey_steps.test.ts | 31 ++- .../server/lib/requests/get_journey_steps.ts | 73 ++++-- .../lib/requests/get_last_successful_step.ts | 8 +- .../uptime/server/lib/requests/helper.ts | 33 ++- .../uptime/server/lib/requests/index.ts | 2 + .../plugins/uptime/server/rest_api/index.ts | 2 + .../uptime/server/rest_api/pings/index.ts | 1 + .../pings/journey_screenshot_blocks.ts | 53 ++++ .../rest_api/pings/journey_screenshots.ts | 74 ++++-- .../uptime/server/rest_api/pings/journeys.ts | 61 ++--- .../synthetics/last_successful_step.ts | 32 ++- 59 files changed, 1937 insertions(+), 736 deletions(-) delete mode 100644 x-pack/plugins/uptime/common/runtime_types/monitor/details.ts create mode 100644 x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts create mode 100644 x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts delete mode 100644 x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.test.tsx delete mode 100644 x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx rename x-pack/plugins/uptime/public/components/synthetics/check_steps/{step_list.test.tsx => steps_list.test.tsx} (94%) create mode 100644 x-pack/plugins/uptime/public/hooks/use_composite_image.ts create mode 100644 x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts create mode 100644 x-pack/plugins/uptime/public/lib/__mocks__/use_composite_image.mock.ts create mode 100644 x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_details.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.ts create mode 100644 x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts deleted file mode 100644 index 6910a3091b67..000000000000 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/details.ts +++ /dev/null @@ -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; - -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; diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/index.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/index.ts index b8c6b2910bd0..41daa9d2148a 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/index.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -export * from './details'; export * from './locations'; export * from './state'; diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/index.ts b/x-pack/plugins/uptime/common/runtime_types/ping/index.ts index 51addd76bee1..38ef0c7a835a 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/index.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/index.ts @@ -7,3 +7,4 @@ export * from './histogram'; export * from './ping'; +export * from './synthetics'; diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index 77b9473f2912..d6875840a138 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -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; + +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; 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; - export type Ping = t.TypeOf; // Convenience function for tests etc that makes an empty ping diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts new file mode 100644 index 000000000000..de92cfeb29e0 --- /dev/null +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts @@ -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); + }); + }); +}); diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts new file mode 100644 index 000000000000..cd6be645c7a6 --- /dev/null +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts @@ -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; + +export const FailedStepsApiResponseType = t.type({ + checkGroups: t.array(t.string), + steps: t.array(JourneyStepType), +}); + +export type FailedStepsApiResponse = t.TypeOf; + +/** + * 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; + +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; + +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; + +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; + +/** + * 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; + +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; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx index 7de3a09c5c16..38f51a2bafeb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/failed_step.tsx @@ -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) { diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.test.tsx deleted file mode 100644 index 30e46b1a3d63..000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.test.tsx +++ /dev/null @@ -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(); - - expect(getByLabelText('Previous step')); - expect(getByLabelText('Next step')); - }); - - it('increments step number on next click', async () => { - const { getByLabelText } = render(); - - 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(); - - 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(); - - // 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(); - - expect(getByLabelText('Next step')).not.toHaveAttribute('disabled'); - expect(getByLabelText('Previous step')).toHaveAttribute('disabled'); - }); - - it('opens popover when mouse enters', async () => { - const { getByLabelText } = render(); - - const nextButton = getByLabelText('Next step'); - - fireEvent.mouseEnter(nextButton); - - await waitFor(() => { - expect(defaultProps.setIsImagePopoverOpen).toHaveBeenCalledTimes(1); - expect(defaultProps.setIsImagePopoverOpen).toHaveBeenCalledWith(true); - }); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx deleted file mode 100644 index 3b0aad721be8..000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/nav_buttons.tsx +++ /dev/null @@ -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>; - setStepNumber: React.Dispatch>; - stepNumber: number; -} - -export const NavButtons: React.FC = ({ - maxSteps, - setIsImagePopoverOpen, - setStepNumber, - stepNumber, -}) => ( - setIsImagePopoverOpen(true)} - style={{ position: 'absolute', bottom: 0, left: 30 }} - > - - ) => { - setStepNumber(stepNumber - 1); - evt.stopPropagation(); - }} - iconType="arrowLeft" - aria-label={prevAriaLabel} - /> - - - ) => { - setStepNumber(stepNumber + 1); - evt.stopPropagation(); - }} - iconType="arrowRight" - aria-label={nextAriaLabel} - /> - - -); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/no_image_display.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/no_image_display.tsx index 827131d64f50..6c341e7cb25a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/no_image_display.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/no_image_display.tsx @@ -27,7 +27,7 @@ export const NoImageDisplay: React.FC = ({ {isLoading || isPending ? ( ) : ( diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx index d628b2d8388f..ed74b502add1 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.test.tsx @@ -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( - + ); 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( - + ); 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( - + ); 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( - + ); + 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( + + ); + + 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')); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx index 16553e9de860..8e2dc1b4c24e 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx @@ -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(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) => { { onMouseLeave={() => setIsImagePopoverOpen(false)} ref={intersectionRef} > - {imgSrc ? ( + {(imgSrc || screenshotRef) && ( - ) : ( + )} + {!imgSrc && !screenshotRef && ( { 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( + + ); + + getByText('test caption content'); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx index 80d41ccc23dc..a2858348ed59 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_caption.tsx @@ -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>; stepNumber: number; @@ -30,6 +32,7 @@ const ImageCaption = euiStyled.div` export const StepImageCaption: React.FC = ({ captionContent, + imgRef, imgSrc, maxSteps, setStepNumber, @@ -54,7 +57,7 @@ export const StepImageCaption: React.FC = ({ }} >
- {imgSrc && ( + {(imgSrc || imgRef) && ( = ({ + captionContent, + imageCaption, + imageData, +}) => + imageData ? ( + + ) : ( + + ); + +/** + * 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; + 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 ( + + ); +}; + export interface StepImagePopoverProps { captionContent: string; imageCaption: JSX.Element; - imgSrc: string; + imgSrc?: string; + imgRef?: ScreenshotRefImageData; isImagePopoverOpen: boolean; } +const StepImageComponent: React.FC< + Omit & { + setImageData: React.Dispatch; + imageData: string | undefined; + } +> = ({ captionContent, imageCaption, imageData, imgRef, imgSrc, setImageData }) => { + if (imgSrc) { + return ( + + ); + } else if (imgRef) { + return ( + + ); + } + return null; +}; + export const StepImagePopover: React.FC = ({ captionContent, imageCaption, + imgRef, imgSrc, isImagePopoverOpen, -}) => ( - +}) => { + const [imageData, setImageData] = React.useState(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={() => {}} - > - - -); + }, [imgSrc, imageData]); + + const setImageDataCallback = React.useCallback( + (newImageData: string | undefined) => setImageData(newImageData), + [setImageData] + ); + return ( + + } + isOpen={isImagePopoverOpen} + closePopover={() => {}} + > + {imageData ? ( + + ) : ( + + )} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx index bf5b0215e7d7..c18530344785 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx @@ -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(); 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(); expect(getByText('No history found')).toBeInTheDocument(); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 65644ce49390..b9ad176b8ed7 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -145,7 +145,10 @@ export const PingList = () => { field: 'timestamp', name: TIMESTAMP_LABEL, render: (timestamp: string, item: Ping) => ( - + ), }, ] @@ -185,8 +188,8 @@ export const PingList = () => { name: i18n.translate('xpack.uptime.pingList.columns.failedStep', { defaultMessage: 'Failed step', }), - render: (timestamp: string, item: Ping) => ( - + render: (_timestamp: string, item: Ping) => ( + ), }, ] diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx index 8b85f05130d0..f64d36fa0f78 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumb.tsx @@ -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; } diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.tsx index 16068e0d72b4..1ba10da6ceac 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/screenshot_link.tsx @@ -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) => { diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx index eb7bc9575155..316154929320 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx @@ -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 ( @@ -59,26 +60,28 @@ export const StepScreenshots = ({ step }: Props) => { - + {!isSucceeded && lastSuccessfulStep?.monitor && ( - + )} diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx index 69a5ef91a592..8fbc26ac2569 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx @@ -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 ( - + {step.synthetics?.step?.name} diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_list.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.test.tsx similarity index 94% rename from x-pack/plugins/uptime/public/components/synthetics/check_steps/step_list.test.tsx rename to x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.test.tsx index 959bf0f64458..738960eb2af3 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.test.tsx @@ -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: { diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx index 47bf3ae0a178..47b89e82dc5c 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -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> = [ { field: 'synthetics.payload.status', name: STATUS_LABEL, - render: (pingStatus: string, item: Ping) => ( + render: (pingStatus: string, item) => ( ), }, @@ -91,13 +97,13 @@ export const StepsList = ({ data, error, loading }: Props) => { align: 'left', field: 'timestamp', name: STEP_NAME_LABEL, - render: (timestamp: string, item: Ping) => , + render: (_timestamp: string, item) => , }, { align: 'left', field: 'timestamp', name: '', - render: (val: string, item: Ping) => ( + render: (_val: string, item) => ( { align: 'right', width: '24px', isExpander: true, - render: (ping: Ping) => { + render: (journeyStep: JourneyStep) => { return ( 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 }); } }, }; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx index d94122a7311c..a195001903f6 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.test.tsx @@ -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 ( Step list - {steps.map((ping, index) => ( + {steps.map((journeyStep, index) => ( 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'} /> ))} diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx index bb56b237dfbd..24ef0d7fb7e8 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx @@ -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; -export const useExpandedRow = ({ loading, steps, allPings }: HookProps) => { +export const useExpandedRow = ({ loading, steps, allSteps }: HookProps) => { const [expandedRows, setExpandedRows] = useState({}); // 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]: ( ), diff --git a/x-pack/plugins/uptime/public/components/synthetics/console_event.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_event.test.tsx index 35cad2b8d6b9..b80613dbfece 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/console_event.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/console_event.test.tsx @@ -15,9 +15,10 @@ describe('ConsoleEvent component', () => { shallowWithIntl( { shallowWithIntl( = ({ event }) => { @@ -28,7 +28,7 @@ export const ConsoleEvent: FC = ({ event }) => { return ( - {event.timestamp} + {event['@timestamp']} {event.synthetics?.type} diff --git a/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.test.tsx index 3821748d4e92..d35e526208ae 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.test.tsx @@ -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( - - ).find('EuiCodeBlock') - ).toMatchInlineSnapshot(` - - - - - - `); + const { getByRole, getByText, queryByText } = render( + + ); + 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(); }); }); diff --git a/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx index df4314e5ccf1..c34344717e3b 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/console_output_event_list.tsx @@ -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 = ({ journey }) => (
@@ -41,7 +41,7 @@ export const ConsoleOutputEventList: FC = ({ journey }) => ( {journey.steps.filter(isConsoleStep).map((consoleEvent) => ( - + ))}
diff --git a/x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx index 24b52e09adbf..04fcf382fd86 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/executed_step.test.tsx @@ -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(); @@ -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(); diff --git a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx index a77b3dfe3ba2..c3016864c72a 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx @@ -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; diff --git a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx index 52d2eacaf0e5..8d35df51c242 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx @@ -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( @@ -29,7 +42,12 @@ describe('StepScreenshotDisplayProps', () => { it('uses alternative text when step name not available', () => { const { getByAltText } = render( - + ); expect(getByAltText('Screenshot')).toBeInTheDocument(); @@ -39,11 +57,32 @@ describe('StepScreenshotDisplayProps', () => { const { getByTestId } = render( ); 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( + + ); + + expect(getByAltText('Screenshot for step with name "STEP_NAME"')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx index 78c65b7d4080..1224a31cfabb 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx @@ -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 & { url?: string }) => { + if (!url) return ; + return ( + + ); +}; + +type ComposedStepImageProps = Pick & { + url: string | undefined; + imgRef: ScreenshotRefImageData; + setUrl: React.Dispatch; +}; + +const ComposedStepImage = ({ + stepIndex, + stepName, + url, + imgRef, + setUrl, +}: ComposedStepImageProps) => { + useCompositeImage(imgRef, setUrl, url); + if (!url) return ; + return ; +}; + export const StepScreenshotDisplay: FC = ({ checkGroup, - screenshotExists, + isScreenshotBlob: isScreenshotBlob, + isScreenshotRef, stepIndex, stepName, lazyLoad = true, @@ -64,60 +126,59 @@ export const StepScreenshotDisplay: FC = ({ } }, [hasIntersected, isIntersecting, setHasIntersected]); - let content: JSX.Element | null = null; const imgSrc = basePath + `/api/uptime/journey/screenshot/${checkGroup}/${stepIndex}`; - if ((hasIntersected || !lazyLoad) && screenshotExists) { - content = ( - - ); - } else if (screenshotExists === false) { - content = ( - - - - - - - - - - - - - ); - } + // When loading a legacy screenshot, set `url` to full-size screenshot path. + // Otherwise, we first need to composite the image. + const [url, setUrl] = useState(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 (
- {content} + {shouldRenderImage && isScreenshotBlob && ( + + )} + {shouldRenderImage && isScreenshotRef && isAScreenshotRef(screenshotRef) && ( + + )} + {!isScreenshotBlob && !isScreenshotRef && ( + + + + + + + + + + + + + )}
); }; diff --git a/x-pack/plugins/uptime/public/hooks/index.ts b/x-pack/plugins/uptime/public/hooks/index.ts index 2850af51bb1e..06b525a94600 100644 --- a/x-pack/plugins/uptime/public/hooks/index.ts +++ b/x-pack/plugins/uptime/public/hooks/index.ts @@ -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'; diff --git a/x-pack/plugins/uptime/public/hooks/use_composite_image.ts b/x-pack/plugins/uptime/public/hooks/use_composite_image.ts new file mode 100644 index 000000000000..6db3d05b8c96 --- /dev/null +++ b/x-pack/plugins/uptime/public/hooks/use_composite_image.ts @@ -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, + imageData?: string +): void => { + const [curRef, setCurRef] = React.useState(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]); +}; diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts b/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts new file mode 100644 index 000000000000..d3a005d98216 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts @@ -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', + }, + }, + ], + }, +}; diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/use_composite_image.mock.ts b/x-pack/plugins/uptime/public/lib/__mocks__/use_composite_image.mock.ts new file mode 100644 index 000000000000..c4ab83ae6ee5 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/__mocks__/use_composite_image.mock.ts @@ -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, + url?: string + ) => { + if (!url) { + callback('img src'); + } + } + ); diff --git a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts new file mode 100644 index 000000000000..7481a517d3c9 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts @@ -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> = []; + + for (const block of screenshotRef.screenshot_ref.blocks) { + drawOperations.push( + new Promise((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); +} diff --git a/x-pack/plugins/uptime/public/state/api/journey.ts b/x-pack/plugins/uptime/public/state/api/journey.ts index 63796a66d1c5..4e71a07c70b6 100644 --- a/x-pack/plugins/uptime/public/state/api/journey.ts +++ b/x-pack/plugins/uptime/public/state/api/journey.ts @@ -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 { - 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 { - return (await apiService.get( +}): Promise { + 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 { - return (await apiService.get(`/api/uptime/synthetics/step/success/`, { - monitorId, - timestamp, - stepIndex, - })) as Ping; +}): Promise { + 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 { 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; } diff --git a/x-pack/plugins/uptime/public/state/reducers/journey.ts b/x-pack/plugins/uptime/public/state/reducers/journey.ts index 361454e1b3fa..98bbd93a24e0 100644 --- a/x-pack/plugins/uptime/public/state/reducers/journey.ts +++ b/x-pack/plugins/uptime/public/state/reducers/journey.ts @@ -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; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_details.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.test.ts new file mode 100644 index 000000000000..4d678021ce78 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.test.ts @@ -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); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts index 6081cc3a7b7c..b389699e2074 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_details.ts @@ -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: { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.test.ts new file mode 100644 index 000000000000..58e5202c2bca --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.test.ts @@ -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", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.ts index d98e23546016..51e4fc671f04 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_failed_steps.ts @@ -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 = async ({ - uptimeEsClient, - checkGroups, -}) => { +export const getJourneyFailedSteps: UMElasticsearchQueryFn< + GetJourneyStepsParams, + JourneyStep[] +> = async ({ uptimeEsClient, checkGroups }) => { const params = { query: { bool: { @@ -53,11 +52,11 @@ export const getJourneyFailedSteps: UMElasticsearchQueryFn>).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; + }); }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts new file mode 100644 index 000000000000..3d8bc04a1056 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts @@ -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, + } + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts index 2c56d4150716..3d95d35aa90d 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts @@ -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 => { - 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, }; }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.test.ts new file mode 100644 index 000000000000..32e4b730e80a --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.test.ts @@ -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", + }, + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.ts new file mode 100644 index 000000000000..3d904ef47e60 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot_blocks.ts @@ -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, + }, + }) + ); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts index af7752b05997..b14a13b50432 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.test.ts @@ -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); }); }); }); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts index 95aadc776fa7..fe77e2d63d2f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts @@ -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 = 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>) - .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>) - .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), + }, + })); }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts b/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts index 6f88e7e37e55..6d0f72052e58 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_last_successful_step.ts @@ -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, diff --git a/x-pack/plugins/uptime/server/lib/requests/helper.ts b/x-pack/plugins/uptime/server/lib/requests/helper.ts index c637c0509466..c33b15200a78 100644 --- a/x-pack/plugins/uptime/server/lib/requests/helper.ts +++ b/x-pack/plugins/uptime/server/lib/requests/helper.ts @@ -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 = {} +): 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; +} diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 24109245c290..587a4301ba9e 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -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, }; diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 91b5597321ed..d4d0e13bd23d 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -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, diff --git a/x-pack/plugins/uptime/server/rest_api/pings/index.ts b/x-pack/plugins/uptime/server/rest_api/pings/index.ts index 61e3cea6d025..45cd23dea42e 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/index.ts @@ -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'; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts new file mode 100644 index 000000000000..63c2cfe7e2d4 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts @@ -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', + }, + }); + }, +}); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts index 4d8c8f86ddf2..bd7cf6af4f84 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts @@ -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(); }, }); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts index 31555be25b2f..284feda2c662 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts @@ -25,27 +25,31 @@ export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => _inspect: schema.maybe(schema.boolean()), }), }, - handler: async ({ uptimeEsClient, request }): Promise => { + handler: async ({ uptimeEsClient, request, response }): Promise => { 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 => { + handler: async ({ uptimeEsClient, request, response }): Promise => { 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 }); + } }, }); diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts b/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts index c326037b9ecb..cb90de50e251 100644 --- a/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts +++ b/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts @@ -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, + }); }, });