From ea8bc0b95acd03b3992613a47561d02412197a9d Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 2 Oct 2020 16:52:51 -0400 Subject: [PATCH] [Uptime] Synthetics UI (#77960) * Checkpoint * Various * Display synthetics steps. * Add loading state for snapshots. Add error and stack trace fields. * Lazy load screenshot images and cache screenshot GET endpoint. * Fix extra requests bug. * Improve screenshot empty state. * Switch to use of code block for stack and error. * Add onmouseenter and onmouseleave for image input/popover. * Add image overlay. * Support `skipped` state. * Add synthetics type for Ping. * Fix type references in reducer, api request, components. * Add required mapping logic to journey request function. * Modularize new components. * Delete obsolete code. * Delete unused code. * Test expanded row changes. * Add a test for ping list expand check. * Various fixes * Extract code accordion to new component * Subsume synthetics type into Ping type. * Add new journey viz for 0 steps case. * Use code block for console output. * Expand step count cap. * Improve formatting of console steps visualization. * Improve empty prompt. * Extract empty prompt to dedicated file. * Extract executed journey UI to dedicated file. * Extract console step list components to dedicated files. * Update empty journey prompt to accept only check_group. * Clean up script expanded row component. * Translate console output steps component. * Fix logic error. * Clean up console step component. * Translate empty journey component. * Translate status badge component. * Translate screenshot component. * Add experimental warning callout. * Re-introduce deleted code. * Simplify console output step list. * Support skipped step for executed journeys. * Simplify executed journey component. * Add translations for executed step. * Refresh outdated test snapshots. * Simplify journey reducer signature. * Repair types. * Fix broken i18n naming. * Add summary field to outdated ping test data. * Fix linting error. * Remove @ts-ignore comment. * Add tests for step screenshot display. * Add tests for status badge. * Rename test file. * Add tests for script expanded row. * Add tests for executed step. * Delete request and response fields from Ping's `synthetics` field. * Fix screenshot querying effect, add flag to journey step state. * Update screenshot api route to reply 404 when screenshot is null. * Simplify screenshot image fetching. * Delete obsolete code. * Rename BrowserExpandedRow component. * Remove all references to "suitejourney". * Add intentional var names. * Rename Console components to use "event" terminology instead of "step". * Employ better copy. * First names always bad names. * Rename CodeBlockAccordion component. * Add blob_mime field to Ping type. * Fix busted import path. * Update ping type for new position of errors field. * Repair broken types. * Fix summary querying * Type fixes. * Switch state object from list to KVP. * Checkpoint. * Fix screenshot display test. * Fix executed step test. * Refresh outdated test snapshots. * Repair broken types. * More typing fixes. * Fix console log and add a test. Co-authored-by: Andrew Cholakian Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> # Conflicts: # x-pack/test/functional/es_archives/uptime/pings/data.json.gz --- .../uptime/common/runtime_types/ping/ping.ts | 49 +++++ .../plugins/uptime/public/apps/uptime_app.tsx | 1 + .../ping_list/__tests__/expanded_row.test.tsx | 22 ++ .../ping_list/__tests__/ping_list.test.tsx | 12 +- .../monitor/ping_list/expanded_row.tsx | 20 ++ .../components/monitor/ping_list/index.tsx | 2 +- .../monitor/ping_list/ping_list.tsx | 85 +++++++- .../monitor/ping_list/ping_list_container.tsx | 51 ----- .../__tests__/browser_expanded_row.test.tsx | 181 ++++++++++++++++ .../__tests__/executed_step.test.tsx | 119 +++++++++++ .../__tests__/status_badge.test.tsx | 41 ++++ .../step_screenshot_display.test.tsx | 193 ++++++++++++++++++ .../synthetics/browser_expanded_row.tsx | 64 ++++++ .../synthetics/code_block_accordion.tsx | 35 ++++ .../monitor/synthetics/console_event.tsx | 37 ++++ .../synthetics/console_output_event_list.tsx | 41 ++++ .../monitor/synthetics/empty_journey.tsx | 52 +++++ .../monitor/synthetics/executed_journey.tsx | 80 ++++++++ .../monitor/synthetics/executed_step.tsx | 92 +++++++++ .../monitor/synthetics/status_badge.tsx | 52 +++++ .../synthetics/step_screenshot_display.tsx | 175 ++++++++++++++++ .../public/contexts/uptime_theme_context.tsx | 3 + .../uptime/public/state/actions/journey.ts | 24 +++ .../uptime/public/state/api/journey.ts | 22 ++ .../uptime/public/state/effects/index.ts | 2 + .../uptime/public/state/effects/journey.ts | 26 +++ .../uptime/public/state/reducers/index.ts | 2 + .../uptime/public/state/reducers/journey.ts | 98 +++++++++ .../state/selectors/__tests__/index.test.ts | 1 + .../uptime/public/state/selectors/index.ts | 2 + .../lib/requests/__tests__/get_pings.test.ts | 120 +++++++++++ .../lib/requests/get_journey_screenshot.ts | 50 +++++ .../server/lib/requests/get_journey_steps.ts | 61 ++++++ .../uptime/server/lib/requests/get_pings.ts | 44 +++- .../uptime/server/lib/requests/index.ts | 4 + .../plugins/uptime/server/rest_api/index.ts | 9 +- .../uptime/server/rest_api/pings/index.ts | 2 + .../rest_api/pings/journey_screenshots.ts | 40 ++++ .../uptime/server/rest_api/pings/journeys.ts | 34 +++ 39 files changed, 1890 insertions(+), 58 deletions(-) delete mode 100644 x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx create mode 100644 x-pack/plugins/uptime/public/state/actions/journey.ts create mode 100644 x-pack/plugins/uptime/public/state/api/journey.ts create mode 100644 x-pack/plugins/uptime/public/state/effects/journey.ts create mode 100644 x-pack/plugins/uptime/public/state/reducers/journey.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts create mode 100644 x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts create mode 100644 x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts create mode 100644 x-pack/plugins/uptime/server/rest_api/pings/journeys.ts 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 f954f8ba30849..775078a7e5df1 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -180,6 +180,48 @@ export const PingType = t.intersection([ down: t.number, up: t.number, }), + synthetics: t.partial({ + index: t.number, + journey: t.type({ + id: t.string, + name: t.string, + }), + error: t.partial({ + message: t.string, + name: t.string, + stack: t.string, + }), + package_version: t.string, + step: t.type({ + index: t.number, + 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({ + duration: t.number, + index: t.number, + is_navigation_request: t.boolean, + message: t.string, + method: t.string, + name: t.string, + params: t.partial({ + homepage: t.string, + }), + source: t.string, + start: t.number, + status: t.string, + ts: t.number, + type: t.string, + url: t.string, + end: t.number, + }), + }), tags: t.array(t.string), tcp: t.partial({ rtt: t.partial({ @@ -202,6 +244,13 @@ export const PingType = t.intersection([ }), ]); +export const SyntheticsJourneyApiResponseType = t.type({ + checkGroup: t.string, + steps: t.array(PingType), +}); + +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/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 4b58ba104314f..a5b8bc859ad94 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -40,6 +40,7 @@ export interface UptimeAppColors { range: string; mean: string; warning: string; + lightestShade: string; } export interface UptimeAppProps { diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx index 2c1434cfd64bd..85d1362ed3766 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx @@ -17,6 +17,7 @@ describe('PingListExpandedRow', () => { docId: 'fdeio12', timestamp: '19290310', monitor: { + check_group: 'check_group_id', duration: { us: 12345, }, @@ -87,4 +88,25 @@ describe('PingListExpandedRow', () => { expect(docLinkComponent).toHaveLength(1); }); + + it('renders a synthetics expanded row for synth monitor', () => { + ping.monitor.type = 'browser'; + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + + + + + + + + `); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx index cb8413ba08a81..8de4b89b7880d 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { PingListComponent, toggleDetails } from '../ping_list'; +import { PingListComponent, rowShouldExpand, toggleDetails } from '../ping_list'; import { Ping, PingsResponse } from '../../../../../common/runtime_types'; import { ExpandedRowMap } from '../../../overview/monitor_list/types'; @@ -182,6 +182,7 @@ describe('PingList component', () => { to: 'now', }} getPings={jest.fn()} + pruneJourneysCallback={jest.fn()} lastRefresh={123} loading={false} locations={[]} @@ -273,5 +274,14 @@ describe('PingList component', () => { /> `); }); + + describe('rowShouldExpand', () => { + // TODO: expand for all cases + it('returns true for browser monitors', () => { + const ping = pings[0]; + ping.monitor.type = 'browser'; + expect(rowShouldExpand(ping)).toBe(true); + }); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx index 201803ff3a951..6af38eca6b0e9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + // @ts-ignore formatNumber import { formatNumber } from '@elastic/eui/lib/services/format'; import { @@ -19,6 +20,7 @@ import { i18n } from '@kbn/i18n'; import { Ping, HttpResponseBody } from '../../../../common/runtime_types'; import { DocLinkForBody } from './doc_link_body'; import { PingRedirects } from './ping_redirects'; +import { BrowserExpandedRow } from '../synthetics/browser_expanded_row'; interface Props { ping: Ping; @@ -53,6 +55,24 @@ const BodyExcerpt = ({ content }: { content: string }) => export const PingListExpandedRowComponent = ({ ping }: Props) => { const listItems = []; + if (ping.monitor.type === 'browser') { + return ( + + + + + + + + + ); + } + // Show the error block if (ping.error) { listItems.push({ diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx index 7fc19bbc9622b..da82d025f478b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx @@ -5,4 +5,4 @@ */ export { PingListComponent } from './ping_list'; -export { PingList } from './ping_list_container'; +export { PingList } from './ping_list'; 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 09782c1b76edb..590b2f787bac4 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 @@ -21,14 +21,63 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useContext, useState, useEffect } from 'react'; import styled from 'styled-components'; +import { useDispatch, useSelector } from 'react-redux'; import { Ping, GetPingsParams, DateRange } from '../../../../common/runtime_types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; import { LocationName } from './location_name'; import { Pagination } from '../../overview/monitor_list'; import { PingListExpandedRowComponent } from './expanded_row'; -import { PingListProps } from './ping_list_container'; +// import { PingListProps } from './ping_list_container'; +import { pruneJourneyState } from '../../../state/actions/journey'; +import { selectPingList } from '../../../state/selectors'; +import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; +import { getPings as getPingsAction } from '../../../state/actions'; + +export interface PingListProps { + monitorId: string; +} + +export const PingList = (props: PingListProps) => { + const { + error, + loading, + pingList: { locations, pings, total }, + } = useSelector(selectPingList); + + const { lastRefresh } = useContext(UptimeRefreshContext); + + const { dateRangeStart: drs, dateRangeEnd: dre } = useContext(UptimeSettingsContext); + + const dispatch = useDispatch(); + const getPingsCallback = useCallback( + (params: GetPingsParams) => dispatch(getPingsAction(params)), + [dispatch] + ); + const pruneJourneysCallback = useCallback( + (checkGroups: string[]) => dispatch(pruneJourneyState(checkGroups)), + [dispatch] + ); + + return ( + + ); +}; export const AllLocationOption = { 'data-test-subj': 'xpack.uptime.pingList.locationOptions.all', @@ -63,6 +112,7 @@ interface Props extends PingListProps { dateRange: DateRange; error?: Error; getPings: (props: GetPingsParams) => void; + pruneJourneysCallback: (checkGroups: string[]) => void; lastRefresh: number; loading: boolean; locations: string[]; @@ -96,6 +146,13 @@ const statusOptions = [ }, ]; +export function rowShouldExpand(item: Ping) { + const errorPresent = !!item.error; + const httpBodyPresent = item.http?.response?.body?.bytes ?? 0 > 0; + const isBrowserMonitor = item.monitor.type === 'browser'; + return errorPresent || httpBodyPresent || isBrowserMonitor; +} + export const PingListComponent = (props: Props) => { const [selectedLocation, setSelectedLocation] = useState(''); const [status, setStatus] = useState(''); @@ -105,6 +162,7 @@ export const PingListComponent = (props: Props) => { dateRange: { from, to }, error, getPings, + pruneJourneysCallback, lastRefresh, loading, locations, @@ -129,6 +187,27 @@ export const PingListComponent = (props: Props) => { const [expandedRows, setExpandedRows] = useState>({}); + const expandedIdsToRemove = JSON.stringify( + Object.keys(expandedRows).filter((e) => !pings.some(({ docId }) => docId === e)) + ); + useEffect(() => { + const parsed = JSON.parse(expandedIdsToRemove); + if (parsed.length) { + parsed.forEach((docId: string) => { + delete expandedRows[docId]; + }); + setExpandedRows(expandedRows); + } + }, [expandedIdsToRemove, expandedRows]); + + const expandedCheckGroups = pings + .filter((p: Ping) => Object.keys(expandedRows).some((f) => p.docId === f)) + .map(({ monitor: { check_group: cg } }) => cg); + const expandedCheckGroupsStr = JSON.stringify(expandedCheckGroups); + useEffect(() => { + pruneJourneysCallback(JSON.parse(expandedCheckGroupsStr)); + }, [pruneJourneysCallback, expandedCheckGroupsStr]); + const locationOptions = !locations ? [AllLocationOption] : [AllLocationOption].concat( @@ -239,7 +318,7 @@ export const PingListComponent = (props: Props) => { toggleDetails(item, expandedRows, setExpandedRows)} - disabled={!item.error && !(item.http?.response?.body?.bytes ?? 0 > 0)} + disabled={!rowShouldExpand(item)} aria-label={ expandedRows[item.docId] ? i18n.translate('xpack.uptime.pingList.collapseRow', { diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx deleted file mode 100644 index 4d4bb175be9a8..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx +++ /dev/null @@ -1,51 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useSelector, useDispatch } from 'react-redux'; -import React, { useContext, useCallback } from 'react'; -import { selectPingList } from '../../../state/selectors'; -import { getPings } from '../../../state/actions'; -import { GetPingsParams } from '../../../../common/runtime_types'; -import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; -import { PingListComponent } from './index'; - -export interface PingListProps { - monitorId: string; -} - -export const PingList = (props: PingListProps) => { - const { - error, - loading, - pingList: { locations, pings, total }, - } = useSelector(selectPingList); - - const { lastRefresh } = useContext(UptimeRefreshContext); - - const { dateRangeStart: drs, dateRangeEnd: dre } = useContext(UptimeSettingsContext); - - const dispatch = useDispatch(); - const getPingsCallback = useCallback((params: GetPingsParams) => dispatch(getPings(params)), [ - dispatch, - ]); - - return ( - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx new file mode 100644 index 0000000000000..191632d6ab713 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { BrowserExpandedRowComponent } from '../browser_expanded_row'; +import { Ping } from '../../../../../common/runtime_types'; + +describe('BrowserExpandedRowComponent', () => { + let defStep: Ping; + beforeEach(() => { + defStep = { + docId: 'doc-id', + timestamp: '123', + monitor: { + duration: { + us: 100, + }, + id: 'mon-id', + status: 'up', + type: 'browser', + }, + }; + }); + + it('returns empty step state when no journey', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot( + `` + ); + }); + + it('returns empty step state when journey has no steps', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(``); + }); + + it('displays loading spinner when loading', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` +
+ +
+ `); + }); + + it('renders executed journey when step/end is present', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` + + `); + }); + + it('renders console output step list when only console steps are present', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` + + `); + }); + + it('renders null when only unsupported steps are present', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx new file mode 100644 index 0000000000000..e3a8430cfa888 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { ExecutedStep } from '../executed_step'; +import { Ping } from '../../../../../common/runtime_types'; + +describe('ExecutedStep', () => { + let step: Ping; + + beforeEach(() => { + step = { + docId: 'docID', + monitor: { + duration: { + us: 123, + }, + id: 'id', + status: 'up', + type: 'browser', + }, + synthetics: { + step: { + index: 4, + name: 'STEP_NAME', + }, + }, + timestamp: 'timestamp', + }; + }); + + it('renders correct step heading', () => { + expect(mountWithIntl().find('EuiText')) + .toMatchInlineSnapshot(` + +
+ + + 4. STEP_NAME + + +
+
+ `); + }); + + it('supplies status badge correct status', () => { + step.synthetics = { + payload: { status: 'THE_STATUS' }, + }; + expect(shallowWithIntl().find('StatusBadge')) + .toMatchInlineSnapshot(` + + `); + }); + + it('renders accordions for step, error message, and error stack script', () => { + step.synthetics = { + error: { + message: 'There was an error executing the step.', + stack: 'some.stack.trace.string', + }, + payload: { + source: 'const someVar = "the var"', + }, + step: { + index: 3, + name: 'STEP_NAME', + }, + }; + + expect(shallowWithIntl().find('CodeBlockAccordion')) + .toMatchInlineSnapshot(` + Array [ + + const someVar = "the var" + , + + There was an error executing the step. + , + + some.stack.trace.string + , + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx new file mode 100644 index 0000000000000..1171c24ad899c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { StatusBadge } from '../status_badge'; + +describe('StatusBadge', () => { + it('displays success message', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + Succeeded + + `); + }); + + it('displays failed message', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + Failed + + `); + }); + + it('displays skipped message', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + Skipped + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx new file mode 100644 index 0000000000000..16db430dbd73a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import * as reactUse from 'react-use'; +import { StepScreenshotDisplay } from '../step_screenshot_display'; + +describe('StepScreenshotDisplayProps', () => { + // @ts-ignore missing fields don't matter in this test, the component in question only relies on `isIntersecting` + jest.spyOn(reactUse, 'useIntersection').mockImplementation(() => ({ + isIntersecting: true, + })); + + it('displays screenshot thumbnail when present', () => { + const wrapper = mountWithIntl( + + ); + + wrapper.update(); + + expect(wrapper.find('img')).toMatchInlineSnapshot(`null`); + + expect(wrapper.find('EuiPopover')).toMatchInlineSnapshot(` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + +
+
+ +
+
+
+
+ `); + }); + + it('uses alternative text when step name not available', () => { + const wrapper = mountWithIntl( + + ); + + wrapper.update(); + + expect(wrapper.find('EuiPopover')).toMatchInlineSnapshot(` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + +
+
+ +
+
+
+
+ `); + }); + + it('displays No Image message when screenshot does not exist', () => { + expect( + shallowWithIntl( + + ).find('EuiText') + ).toMatchInlineSnapshot(` + + + + + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx new file mode 100644 index 0000000000000..2546c5fb9a5d8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLoadingSpinner } from '@elastic/eui'; +import React, { useEffect, FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Ping } from '../../../../common/runtime_types'; +import { getJourneySteps } from '../../../state/actions/journey'; +import { JourneyState } from '../../../state/reducers/journey'; +import { journeySelector } from '../../../state/selectors'; +import { EmptyStepState } from './empty_journey'; +import { ExecutedJourney } from './executed_journey'; +import { ConsoleOutputEventList } from './console_output_event_list'; + +interface BrowserExpandedRowProps { + checkGroup?: string; +} + +export const BrowserExpandedRow: React.FC = ({ checkGroup }) => { + const dispatch = useDispatch(); + useEffect(() => { + if (checkGroup) { + dispatch(getJourneySteps({ checkGroup })); + } + }, [dispatch, checkGroup]); + + const journeys = useSelector(journeySelector); + const journey = journeys[checkGroup ?? '']; + + return ; +}; + +type ComponentProps = BrowserExpandedRowProps & { + journey?: JourneyState; +}; + +const stepEnd = (step: Ping) => step.synthetics?.type === 'step/end'; +const stepConsole = (step: Ping) => + ['stderr', 'cmd/status'].indexOf(step.synthetics?.type ?? '') !== -1; + +export const BrowserExpandedRowComponent: FC = ({ checkGroup, journey }) => { + if (!!journey && journey.loading) { + return ( +
+ +
+ ); + } + + if (!journey || journey.steps.length === 0) { + return ; + } + + if (journey.steps.some(stepEnd)) return ; + + if (journey.steps.some(stepConsole)) return ; + + // TODO: should not happen, this means that the journey has no step/end and no console logs, but some other steps; filmstrip, screenshot, etc. + // we should probably create an error prompt letting the user know this step is not supported yet + return null; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx new file mode 100644 index 0000000000000..9f6559f0c2709 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiAccordion, EuiCodeBlock } from '@elastic/eui'; +import React, { FC } from 'react'; + +interface Props { + buttonContent: string; + id?: string; + language: 'html' | 'javascript'; + overflowHeight: number; +} + +/** + * Utility for showing `EuiAccordions` with code blocks which we use frequently in synthetics to display + * stack traces, long error messages, and synthetics journey code. + */ +export const CodeBlockAccordion: FC = ({ + buttonContent, + children, + id, + language, + overflowHeight, +}) => { + return children && id ? ( + + + {children} + + + ) : null; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx new file mode 100644 index 0000000000000..0f2930db96b54 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import React, { useContext, FC } from 'react'; +import { Ping } from '../../../../common/runtime_types'; +import { UptimeThemeContext } from '../../../contexts'; + +interface Props { + event: Ping; +} + +export const ConsoleEvent: FC = ({ event }) => { + const { + colors: { danger }, + } = useContext(UptimeThemeContext); + + let typeColor: string | undefined; + if (event.synthetics?.type === 'stderr') { + typeColor = danger; + } else { + typeColor = undefined; + } + + return ( + + {event.timestamp} + + {event.synthetics?.type} + + {event.synthetics?.payload?.message} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx new file mode 100644 index 0000000000000..9159c61532f15 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { JourneyState } from '../../../state/reducers/journey'; +import { ConsoleEvent } from './console_event'; + +interface Props { + journey: JourneyState; +} + +export const ConsoleOutputEventList: FC = ({ journey }) => ( +
+ +

+ +

+
+ +

+ +

+ + + {journey.steps.map((consoleEvent) => ( + + ))} + +
+); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx new file mode 100644 index 0000000000000..b6fead2bbbe09 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; + +interface EmptyStepStateProps { + checkGroup?: string; +} + +export const EmptyStepState: FC = ({ checkGroup }) => ( + + + + } + body={ + <> +

+ +

+ {!!checkGroup && ( +

+ {checkGroup} }} + /> +

+ )} +

+ +

+ + } + /> +); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx new file mode 100644 index 0000000000000..2c37f95428d0e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { Ping } from '../../../../common/runtime_types'; +import { JourneyState } from '../../../state/reducers/journey'; +import { ExecutedStep } from './executed_step'; + +interface StepStatusCount { + failed: number; + skipped: number; + succeeded: number; +} + +function statusMessage(count: StepStatusCount) { + const total = count.succeeded + count.failed + count.skipped; + if (count.failed + count.skipped === total) { + return i18n.translate('xpack.uptime.synthetics.journey.allFailedMessage', { + defaultMessage: '{total} Steps - all failed or skipped', + values: { total }, + }); + } else if (count.succeeded === total) { + return i18n.translate('xpack.uptime.synthetics.journey.allSucceededMessage', { + defaultMessage: '{total} Steps - all succeeded', + values: { total }, + }); + } + return i18n.translate('xpack.uptime.synthetics.journey.partialSuccessMessage', { + defaultMessage: '{total} Steps - {succeeded} succeeded', + values: { succeeded: count.succeeded, total }, + }); +} + +function reduceStepStatus(prev: StepStatusCount, cur: Ping): StepStatusCount { + if (cur.synthetics?.payload?.status === 'succeeded') { + prev.succeeded += 1; + return prev; + } else if (cur.synthetics?.payload?.status === 'skipped') { + prev.skipped += 1; + return prev; + } + prev.failed += 1; + return prev; +} + +interface ExecutedJourneyProps { + journey: JourneyState; +} + +export const ExecutedJourney: FC = ({ journey }) => ( +
+ +

+ +

+

+ {statusMessage( + journey.steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }) + )} +

+
+ + + {journey.steps + .filter((step) => step.synthetics?.type === 'step/end') + .map((step, index) => ( + + ))} + +
+); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx new file mode 100644 index 0000000000000..3c26ba12eea65 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { CodeBlockAccordion } from './code_block_accordion'; +import { StepScreenshotDisplay } from './step_screenshot_display'; +import { StatusBadge } from './status_badge'; +import { Ping } from '../../../../common/runtime_types'; + +const CODE_BLOCK_OVERFLOW_HEIGHT = 360; + +interface ExecutedStepProps { + step: Ping; + index: number; +} + +export const ExecutedStep: FC = ({ step, index }) => ( + <> +
+
+ + + + + +
+ +
+ +
+ +
+ + + + + + + {step.synthetics?.payload?.source} + + + {step.synthetics?.error?.message} + + + {step.synthetics?.error?.stack} + + + +
+
+ + +); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx new file mode 100644 index 0000000000000..f8fbeccaabf42 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useContext, FC } from 'react'; +import { UptimeAppColors } from '../../../apps/uptime_app'; +import { UptimeThemeContext } from '../../../contexts'; + +interface StatusBadgeProps { + status?: string; +} + +export function colorFromStatus(color: UptimeAppColors, status?: string) { + switch (status) { + case 'succeeded': + return color.success; + case 'failed': + return color.danger; + default: + return 'default'; + } +} + +export function textFromStatus(status?: string) { + switch (status) { + case 'succeeded': + return i18n.translate('xpack.uptime.synthetics.statusBadge.succeededMessage', { + defaultMessage: 'Succeeded', + }); + case 'failed': + return i18n.translate('xpack.uptime.synthetics.statusBadge.failedMessage', { + defaultMessage: 'Failed', + }); + case 'skipped': + return i18n.translate('xpack.uptime.synthetics.statusBadge.skippedMessage', { + defaultMessage: 'Skipped', + }); + default: + return null; + } +} + +export const StatusBadge: FC = ({ status }) => { + const theme = useContext(UptimeThemeContext); + return ( + {textFromStatus(status)} + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx new file mode 100644 index 0000000000000..2e8ad4bd0c9a8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiOverlayMask, + EuiPopover, + EuiText, +} from '@elastic/eui'; +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'; +import { UptimeThemeContext } from '../../../contexts'; + +interface StepScreenshotDisplayProps { + screenshotExists?: boolean; + checkGroup?: string; + stepIndex?: number; + stepName?: string; +} + +const THUMBNAIL_WIDTH = 320; +const THUMBNAIL_HEIGHT = 180; +const POPOVER_IMG_WIDTH = 640; +const POPOVER_IMG_HEIGHT = 360; + +export const StepScreenshotDisplay: FC = ({ + checkGroup, + screenshotExists, + stepIndex, + stepName, +}) => { + const containerRef = useRef(null); + const { + colors: { lightestShade: pageBackground }, + } = useContext(UptimeThemeContext); + + const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false); + const [isOverlayOpen, setIsOverlayOpen] = useState(false); + + const intersection = useIntersection(containerRef, { + root: null, + rootMargin: '0px', + threshold: 1, + }); + + const [hasIntersected, setHasIntersected] = useState(false); + const isIntersecting = intersection?.isIntersecting; + useEffect(() => { + if (hasIntersected === false && isIntersecting === true) { + setHasIntersected(true); + } + }, [hasIntersected, isIntersecting, setHasIntersected]); + + let content: JSX.Element | null = null; + const imgSrc = `/api/uptime/journey/screenshot/${checkGroup}/${stepIndex}`; + if (hasIntersected && screenshotExists) { + content = ( + <> + {isOverlayOpen && ( + setIsOverlayOpen(false)}> + setIsOverlayOpen(false)} + /> + + )} + setIsOverlayOpen(true)} + onMouseEnter={() => setIsImagePopoverOpen(true)} + onMouseLeave={() => setIsImagePopoverOpen(false)} + /> + } + closePopover={() => setIsImagePopoverOpen(false)} + isOpen={isImagePopoverOpen} + > + { + + + ); + } else if (screenshotExists === false) { + content = ( + + + + + + + + + + + + + ); + } + return ( +
+ {content} +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx index 51e8bcaed986f..f0a702b9c0b75 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx @@ -31,6 +31,7 @@ const defaultContext: UptimeThemeContextValues = { success: euiLightVars.euiColorSuccess, warning: euiLightVars.euiColorWarning, gray: euiLightVars.euiColorLightShade, + lightestShade: euiLightVars.euiColorLightestShade, }, chartTheme: { baseTheme: LIGHT_THEME, @@ -54,6 +55,7 @@ export const UptimeThemeContextProvider: React.FC = ({ darkMo range: euiDarkVars.euiFocusBackgroundColor, success: euiDarkVars.euiColorSuccess, warning: euiDarkVars.euiColorWarning, + lightestShade: euiDarkVars.euiColorLightestShade, }; } else { colors = { @@ -63,6 +65,7 @@ export const UptimeThemeContextProvider: React.FC = ({ darkMo range: euiLightVars.euiFocusBackgroundColor, success: euiLightVars.euiColorSuccess, warning: euiLightVars.euiColorWarning, + lightestShade: euiLightVars.euiColorLightestShade, }; } const value = useMemo(() => { diff --git a/x-pack/plugins/uptime/public/state/actions/journey.ts b/x-pack/plugins/uptime/public/state/actions/journey.ts new file mode 100644 index 0000000000000..0d35559d97fc3 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/actions/journey.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { SyntheticsJourneyApiResponse } from '../../../common/runtime_types'; + +export interface FetchJourneyStepsParams { + checkGroup: string; +} + +export interface GetJourneyFailPayload { + checkGroup: string; + error: Error; +} + +export const getJourneySteps = createAction('GET_JOURNEY_STEPS'); +export const getJourneyStepsSuccess = createAction( + 'GET_JOURNEY_STEPS_SUCCESS' +); +export const getJourneyStepsFail = createAction('GET_JOURNEY_STEPS_FAIL'); +export const pruneJourneyState = createAction('PRUNE_JOURNEY_STATE'); diff --git a/x-pack/plugins/uptime/public/state/api/journey.ts b/x-pack/plugins/uptime/public/state/api/journey.ts new file mode 100644 index 0000000000000..e9ea9d8744bc8 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/api/journey.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { apiService } from './utils'; +import { FetchJourneyStepsParams } from '../actions/journey'; +import { + SyntheticsJourneyApiResponse, + SyntheticsJourneyApiResponseType, +} from '../../../common/runtime_types'; + +export async function fetchJourneySteps( + params: FetchJourneyStepsParams +): Promise { + return (await apiService.get( + `/api/uptime/journey/${params.checkGroup}`, + undefined, + SyntheticsJourneyApiResponseType + )) as SyntheticsJourneyApiResponse; +} diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index a2a5dd58dcf4d..4951f2102c8a7 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -18,6 +18,7 @@ import { fetchMLJobEffect } from './ml_anomaly'; import { fetchIndexStatusEffect } from './index_status'; import { fetchCertificatesEffect } from '../certificates/certificates'; import { fetchAlertsEffect } from '../alerts/alerts'; +import { fetchJourneyStepsEffect } from './journey'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -35,4 +36,5 @@ export function* rootEffect() { yield fork(fetchIndexStatusEffect); yield fork(fetchCertificatesEffect); yield fork(fetchAlertsEffect); + yield fork(fetchJourneyStepsEffect); } diff --git a/x-pack/plugins/uptime/public/state/effects/journey.ts b/x-pack/plugins/uptime/public/state/effects/journey.ts new file mode 100644 index 0000000000000..2ba125535dea4 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/journey.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from 'redux-actions'; +import { call, put, takeLatest } from 'redux-saga/effects'; +import { + getJourneySteps, + getJourneyStepsSuccess, + getJourneyStepsFail, + FetchJourneyStepsParams, +} from '../actions/journey'; +import { fetchJourneySteps } from '../api/journey'; + +export function* fetchJourneyStepsEffect() { + yield takeLatest(getJourneySteps, function* (action: Action) { + try { + const response = yield call(fetchJourneySteps, action.payload); + yield put(getJourneyStepsSuccess(response)); + } catch (e) { + yield put(getJourneyStepsFail({ checkGroup: action.payload.checkGroup, error: e })); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index a1aa8b9fbaf5b..c0bab124d5f9d 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -21,6 +21,7 @@ import { mlJobsReducer } from './ml_anomaly'; import { certificatesReducer } from '../certificates/certificates'; import { selectedFiltersReducer } from './selected_filters'; import { alertsReducer } from '../alerts/alerts'; +import { journeyReducer } from './journey'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -39,4 +40,5 @@ export const rootReducer = combineReducers({ certificates: certificatesReducer, selectedFilters: selectedFiltersReducer, alerts: alertsReducer, + journeys: journeyReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/journey.ts b/x-pack/plugins/uptime/public/state/reducers/journey.ts new file mode 100644 index 0000000000000..e1c3dc808f1bf --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/journey.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleActions, Action } from 'redux-actions'; +import { Ping, SyntheticsJourneyApiResponse } from '../../../common/runtime_types'; +import { pruneJourneyState } from '../actions/journey'; +import { + FetchJourneyStepsParams, + GetJourneyFailPayload, + getJourneySteps, + getJourneyStepsFail, + getJourneyStepsSuccess, +} from '../actions/journey'; + +export interface JourneyState { + checkGroup: string; + steps: Ping[]; + loading: boolean; + error?: Error; +} + +interface JourneyKVP { + [checkGroup: string]: JourneyState; +} + +const initialState: JourneyKVP = {}; + +type Payload = FetchJourneyStepsParams & + SyntheticsJourneyApiResponse & + GetJourneyFailPayload & + string[]; + +export const journeyReducer = handleActions( + { + [String(getJourneySteps)]: ( + state: JourneyKVP, + { payload: { checkGroup } }: Action + ) => ({ + ...state, + // add an empty entry while fetching the check group, + // or update the previously-loaded entry to a new loading state + [checkGroup]: state[checkGroup] + ? { + ...state[checkGroup], + loading: true, + } + : { + checkGroup, + steps: [], + loading: true, + }, + }), + + [String(getJourneyStepsSuccess)]: ( + state: JourneyKVP, + { payload: { checkGroup, steps } }: Action + ) => ({ + ...state, + [checkGroup]: { + loading: false, + checkGroup, + steps, + }, + }), + + [String(getJourneyStepsFail)]: ( + state: JourneyKVP, + { payload: { checkGroup, error } }: Action + ) => ({ + ...state, + [checkGroup]: state[checkGroup] + ? { + ...state[checkGroup], + loading: false, + error, + } + : { + checkGroup, + loading: false, + steps: [], + error, + }, + }), + + [String(pruneJourneyState)]: (state: JourneyKVP, action: Action) => + action.payload.reduce( + (prev, cur) => ({ + ...prev, + [cur]: state[cur], + }), + {} + ), + }, + initialState +); diff --git a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts index 1a8929990ab55..a59e0be5cdf3f 100644 --- a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -117,6 +117,7 @@ describe('state selectors', () => { newAlert: { data: null, loading: false }, anomalyAlertDeletion: { data: null, loading: false }, }, + journeys: {}, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 6b3b46cc15137..6bfe67468aae5 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -94,3 +94,5 @@ export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchTe export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters; export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; + +export const journeySelector = ({ journeys }: AppState) => journeys; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts index eb1a8418141d7..ae3d2ebbfc9e0 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts @@ -144,6 +144,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 12, @@ -198,6 +222,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 12, @@ -252,6 +300,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 25, @@ -311,6 +383,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 25, @@ -370,6 +466,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 25, 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 new file mode 100644 index 0000000000000..f726ef47915b8 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters/framework'; + +interface GetJourneyScreenshotParams { + checkGroup: string; + stepIndex: number; +} + +export const getJourneyScreenshot: UMElasticsearchQueryFn< + GetJourneyScreenshotParams, + any +> = async ({ callES, dynamicSettings, checkGroup, stepIndex }) => { + const params: any = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + { + term: { + 'synthetics.type': 'step/screenshot', + }, + }, + { + term: { + 'synthetics.step.index': stepIndex, + }, + }, + ], + }, + }, + _source: ['synthetics.blob'], + }, + }; + const result = await callES('search', params); + if (!Array.isArray(result?.hits?.hits) || result.hits.hits.length < 1) { + return null; + } + return result.hits.hits.map(({ _source }: any) => _source?.synthetics?.blob ?? null)[0]; +}; 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 new file mode 100644 index 0000000000000..e4392480f5b72 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters/framework'; +import { Ping } from '../../../common/runtime_types'; + +interface GetJourneyStepsParams { + checkGroup: string; +} + +export const getJourneySteps: UMElasticsearchQueryFn = async ({ + callES, + dynamicSettings, + checkGroup, +}) => { + const params: any = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + filter: [ + { + terms: { + 'synthetics.type': ['step/end', 'stderr', 'cmd/status', 'step/screenshot'], + }, + }, + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + ], + }, + }, + _source: { + excludes: ['synthetics.blob'], + }, + }, + size: 500, + }; + const result = await callES('search', params); + const screenshotIndexes: number[] = result.hits.hits + .filter((h: any) => h?._source?.synthetics?.type === 'step/screenshot') + .map((h: any) => h?._source?.synthetics?.step?.index); + return result.hits.hits + .filter((h: any) => h?._source?.synthetics?.type !== 'step/screenshot') + .map( + ({ _id, _source, _source: { synthetics } }: any): Ping => ({ + ..._source, + timestamp: _source['@timestamp'], + docId: _id, + synthetics: { + ...synthetics, + screenshotExists: screenshotIndexes.some((i) => i === synthetics?.step?.index), + }, + }) + ); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts index a6a0e3c3d6542..03ec2d7343c9a 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts @@ -14,6 +14,43 @@ import { const DEFAULT_PAGE_SIZE = 25; +/** + * This branch of filtering is used for monitors of type `browser`. This monitor + * type represents an unbounded set of steps, with each `check_group` representing + * a distinct journey. The document containing the `summary` field is indexed last, and + * contains the data necessary for querying a journey. + * + * Because of this, when querying for "pings", it is important that we treat `browser` summary + * checks as the "ping" we want. Without this filtering, we will receive >= N pings for a journey + * of N steps, because an individual step may also contain multiple documents. + */ +const REMOVE_NON_SUMMARY_BROWSER_CHECKS = { + must_not: [ + { + bool: { + filter: [ + { + term: { + 'monitor.type': 'browser', + }, + }, + { + bool: { + must_not: [ + { + exists: { + field: 'summary', + }, + }, + ], + }, + }, + ], + }, + }, + ], +}; + export const getPings: UMElasticsearchQueryFn = async ({ callES, dynamicSettings, @@ -39,7 +76,12 @@ export const getPings: UMElasticsearchQueryFn = a if (location) { postFilterClause = { post_filter: { term: { 'observer.geo.name': location } } }; } - const queryContext = { bool: { filter } }; + const queryContext = { + bool: { + filter, + ...REMOVE_NON_SUMMARY_BROWSER_CHECKS, + }, + }; const params: any = { index: dynamicSettings.heartbeatIndices, body: { diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 8fa4561268e8f..1806495d14cc4 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -18,6 +18,8 @@ import { getPings } from './get_pings'; import { getPingHistogram } from './get_ping_histogram'; import { getSnapshotCount } from './get_snapshot_counts'; import { getIndexStatus } from './get_index_status'; +import { getJourneySteps } from './get_journey_steps'; +import { getJourneyScreenshot } from './get_journey_screenshot'; export const requests = { getCerts, @@ -34,6 +36,8 @@ export const requests = { getPingHistogram, getSnapshotCount, getIndexStatus, + getJourneySteps, + getJourneyScreenshot, }; export type UptimeRequests = typeof requests; diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 2b598be284e1c..de44b2565a2f8 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -6,7 +6,12 @@ import { createGetCertsRoute } from './certs/certs'; import { createGetOverviewFilters } from './overview_filters'; -import { createGetPingHistogramRoute, createGetPingsRoute } from './pings'; +import { + createGetPingHistogramRoute, + createGetPingsRoute, + createJourneyRoute, + createJourneyScreenshotRoute, +} from './pings'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings'; import { createLogPageViewRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; @@ -40,4 +45,6 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createLogPageViewRoute, createGetPingHistogramRoute, createGetMonitorDurationRoute, + createJourneyRoute, + createJourneyScreenshotRoute, ]; 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 a10ab435e4b0a..88fc0a84c4621 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/index.ts @@ -6,3 +6,5 @@ export { createGetPingsRoute } from './get_pings'; export { createGetPingHistogramRoute } from './get_ping_histogram'; +export { createJourneyRoute } from './journeys'; +export { createJourneyScreenshotRoute } from './journey_screenshots'; 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 new file mode 100644 index 0000000000000..1fc52dd24f9d0 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/journey/screenshot/{checkGroup}/{stepIndex}', + validate: { + params: schema.object({ + checkGroup: schema.string(), + stepIndex: schema.number(), + }), + }, + handler: async ({ callES, dynamicSettings }, _context, request, response) => { + const { checkGroup, stepIndex } = request.params; + const result = await libs.requests.getJourneyScreenshot({ + callES, + dynamicSettings, + checkGroup, + stepIndex, + }); + + if (result === null) { + return response.notFound(); + } + return response.ok({ + body: Buffer.from(result, 'base64'), + headers: { + 'content-type': 'image/png', + 'cache-control': 'max-age=600', + }, + }); + }, +}); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts new file mode 100644 index 0000000000000..b6e06850ad3b6 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/journey/{checkGroup}', + validate: { + params: schema.object({ + checkGroup: schema.string(), + }), + }, + handler: async ({ callES, dynamicSettings }, _context, request, response) => { + const { checkGroup } = request.params; + const result = await libs.requests.getJourneySteps({ + callES, + dynamicSettings, + checkGroup, + }); + + return response.ok({ + body: { + checkGroup, + steps: result, + }, + }); + }, +});