[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 <andrew@andrewvc.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Justin Kambic 2020-10-02 16:52:51 -04:00 committed by GitHub
parent 43cf97e1f2
commit 0ce802522c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1890 additions and 58 deletions

View file

@ -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<typeof SyntheticsJourneyApiResponseType>;
export type Ping = t.TypeOf<typeof PingType>;
// Convenience function for tests etc that makes an empty ping

View file

@ -40,6 +40,7 @@ export interface UptimeAppColors {
range: string;
mean: string;
warning: string;
lightestShade: string;
}
export interface UptimeAppProps {

View file

@ -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(<PingListExpandedRowComponent ping={ping} />)).toMatchInlineSnapshot(`
<EuiFlexGroup
direction="column"
>
<EuiFlexItem>
<EuiCallOut
iconType="beaker"
title="Experimental feature"
/>
</EuiFlexItem>
<EuiFlexItem>
<BrowserExpandedRow
checkGroup="check_group_id"
/>
</EuiFlexItem>
</EuiFlexGroup>
`);
});
});

View file

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

View file

@ -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 (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiCallOut
iconType="beaker"
title={i18n.translate('xpack.uptime.synthetics.experimentalCallout.title', {
defaultMessage: 'Experimental feature',
})}
/>
</EuiFlexItem>
<EuiFlexItem>
<BrowserExpandedRow checkGroup={ping.monitor.check_group} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
// Show the error block
if (ping.error) {
listItems.push({

View file

@ -5,4 +5,4 @@
*/
export { PingListComponent } from './ping_list';
export { PingList } from './ping_list_container';
export { PingList } from './ping_list';

View file

@ -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 (
<PingListComponent
dateRange={{
from: drs,
to: dre,
}}
error={error}
getPings={getPingsCallback}
pruneJourneysCallback={pruneJourneysCallback}
lastRefresh={lastRefresh}
loading={loading}
locations={locations}
pings={pings}
total={total}
{...props}
/>
);
};
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<string>('');
const [status, setStatus] = useState<string>('');
@ -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<Record<string, JSX.Element>>({});
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) => {
<EuiButtonIcon
data-test-subj="uptimePingListExpandBtn"
onClick={() => 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', {

View file

@ -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 (
<PingListComponent
dateRange={{
from: drs,
to: dre,
}}
error={error}
getPings={getPingsCallback}
lastRefresh={lastRefresh}
loading={loading}
locations={locations}
pings={pings}
total={total}
{...props}
/>
);
};

View file

@ -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(<BrowserExpandedRowComponent />)).toMatchInlineSnapshot(
`<EmptyStepState />`
);
});
it('returns empty step state when journey has no steps', () => {
expect(
shallowWithIntl(
<BrowserExpandedRowComponent
journey={{
checkGroup: 'check_group',
loading: false,
steps: [],
}}
/>
)
).toMatchInlineSnapshot(`<EmptyStepState />`);
});
it('displays loading spinner when loading', () => {
expect(
shallowWithIntl(
<BrowserExpandedRowComponent
journey={{
checkGroup: 'check_group',
loading: true,
steps: [],
}}
/>
)
).toMatchInlineSnapshot(`
<div>
<EuiLoadingSpinner />
</div>
`);
});
it('renders executed journey when step/end is present', () => {
expect(
shallowWithIntl(
<BrowserExpandedRowComponent
journey={{
checkGroup: 'check_group',
loading: false,
steps: [
{
...defStep,
synthetics: {
type: 'step/end',
},
},
],
}}
/>
)
).toMatchInlineSnapshot(`
<ExecutedJourney
journey={
Object {
"checkGroup": "check_group",
"loading": false,
"steps": Array [
Object {
"docId": "doc-id",
"monitor": Object {
"duration": Object {
"us": 100,
},
"id": "mon-id",
"status": "up",
"type": "browser",
},
"synthetics": Object {
"type": "step/end",
},
"timestamp": "123",
},
],
}
}
/>
`);
});
it('renders console output step list when only console steps are present', () => {
expect(
shallowWithIntl(
<BrowserExpandedRowComponent
journey={{
checkGroup: 'check_group',
loading: false,
steps: [
{
...defStep,
synthetics: {
type: 'stderr',
},
},
],
}}
/>
)
).toMatchInlineSnapshot(`
<ConsoleOutputEventList
journey={
Object {
"checkGroup": "check_group",
"loading": false,
"steps": Array [
Object {
"docId": "doc-id",
"monitor": Object {
"duration": Object {
"us": 100,
},
"id": "mon-id",
"status": "up",
"type": "browser",
},
"synthetics": Object {
"type": "stderr",
},
"timestamp": "123",
},
],
}
}
/>
`);
});
it('renders null when only unsupported steps are present', () => {
expect(
shallowWithIntl(
<BrowserExpandedRowComponent
journey={{
checkGroup: 'check_group',
loading: false,
steps: [
{
...defStep,
synthetics: {
type: 'some other type',
},
},
],
}}
/>
)
).toMatchInlineSnapshot(`""`);
});
});

View file

@ -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(<ExecutedStep index={3} step={step} />).find('EuiText'))
.toMatchInlineSnapshot(`
<EuiText>
<div
className="euiText euiText--medium"
>
<strong>
<FormattedMessage
defaultMessage="{stepNumber}. {stepName}"
id="xpack.uptime.synthetics.executedStep.stepName"
values={
Object {
"stepName": "STEP_NAME",
"stepNumber": 4,
}
}
>
4. STEP_NAME
</FormattedMessage>
</strong>
</div>
</EuiText>
`);
});
it('supplies status badge correct status', () => {
step.synthetics = {
payload: { status: 'THE_STATUS' },
};
expect(shallowWithIntl(<ExecutedStep index={3} step={step} />).find('StatusBadge'))
.toMatchInlineSnapshot(`
<StatusBadge
status="THE_STATUS"
/>
`);
});
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(<ExecutedStep index={3} step={step} />).find('CodeBlockAccordion'))
.toMatchInlineSnapshot(`
Array [
<CodeBlockAccordion
buttonContent="Step script"
id="STEP_NAME3"
language="javascript"
overflowHeight={360}
>
const someVar = "the var"
</CodeBlockAccordion>,
<CodeBlockAccordion
buttonContent="Error"
id="STEP_NAME_error"
language="html"
overflowHeight={360}
>
There was an error executing the step.
</CodeBlockAccordion>,
<CodeBlockAccordion
buttonContent="Stack trace"
id="STEP_NAME_stack"
language="html"
overflowHeight={360}
>
some.stack.trace.string
</CodeBlockAccordion>,
]
`);
});
});

View file

@ -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(<StatusBadge status="succeeded" />)).toMatchInlineSnapshot(`
<EuiBadge
color="#017d73"
>
Succeeded
</EuiBadge>
`);
});
it('displays failed message', () => {
expect(shallowWithIntl(<StatusBadge status="failed" />)).toMatchInlineSnapshot(`
<EuiBadge
color="#bd271e"
>
Failed
</EuiBadge>
`);
});
it('displays skipped message', () => {
expect(shallowWithIntl(<StatusBadge status="skipped" />)).toMatchInlineSnapshot(`
<EuiBadge
color="default"
>
Skipped
</EuiBadge>
`);
});
});

View file

@ -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(
<StepScreenshotDisplay
checkGroup="check_group"
screenshotExists={true}
stepIndex={1}
stepName="STEP_NAME"
/>
);
wrapper.update();
expect(wrapper.find('img')).toMatchInlineSnapshot(`null`);
expect(wrapper.find('EuiPopover')).toMatchInlineSnapshot(`
<EuiPopover
anchorPosition="rightCenter"
button={
<input
alt="Screenshot for step with name \\"STEP_NAME\\""
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
src="/api/uptime/journey/screenshot/check_group/1"
style={
Object {
"height": 180,
"objectFit": "cover",
"objectPosition": "center top",
"width": 320,
}
}
type="image"
/>
}
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
isOpen={false}
ownFocus={false}
panelPaddingSize="m"
>
<EuiOutsideClickDetector
isDisabled={true}
onOutsideClick={[Function]}
>
<div
className="euiPopover euiPopover--anchorRightCenter"
onKeyDown={[Function]}
onMouseDown={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className="euiPopover__anchor"
>
<input
alt="Screenshot for step with name \\"STEP_NAME\\""
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
src="/api/uptime/journey/screenshot/check_group/1"
style={
Object {
"height": 180,
"objectFit": "cover",
"objectPosition": "center top",
"width": 320,
}
}
type="image"
/>
</div>
</div>
</EuiOutsideClickDetector>
</EuiPopover>
`);
});
it('uses alternative text when step name not available', () => {
const wrapper = mountWithIntl(
<StepScreenshotDisplay checkGroup="check_group" screenshotExists={true} stepIndex={1} />
);
wrapper.update();
expect(wrapper.find('EuiPopover')).toMatchInlineSnapshot(`
<EuiPopover
anchorPosition="rightCenter"
button={
<input
alt="Screenshot"
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
src="/api/uptime/journey/screenshot/check_group/1"
style={
Object {
"height": 180,
"objectFit": "cover",
"objectPosition": "center top",
"width": 320,
}
}
type="image"
/>
}
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
isOpen={false}
ownFocus={false}
panelPaddingSize="m"
>
<EuiOutsideClickDetector
isDisabled={true}
onOutsideClick={[Function]}
>
<div
className="euiPopover euiPopover--anchorRightCenter"
onKeyDown={[Function]}
onMouseDown={[Function]}
onMouseUp={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
>
<div
className="euiPopover__anchor"
>
<input
alt="Screenshot"
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
src="/api/uptime/journey/screenshot/check_group/1"
style={
Object {
"height": 180,
"objectFit": "cover",
"objectPosition": "center top",
"width": 320,
}
}
type="image"
/>
</div>
</div>
</EuiOutsideClickDetector>
</EuiPopover>
`);
});
it('displays No Image message when screenshot does not exist', () => {
expect(
shallowWithIntl(
<StepScreenshotDisplay
checkGroup="check_group"
stepIndex={1}
stepName="STEP_NAME"
screenshotExists={false}
/>
).find('EuiText')
).toMatchInlineSnapshot(`
<EuiText>
<strong>
<FormattedMessage
defaultMessage="No image available"
id="xpack.uptime.synthetics.screenshot.noImageMessage"
values={Object {}}
/>
</strong>
</EuiText>
`);
});
});

View file

@ -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<BrowserExpandedRowProps> = ({ checkGroup }) => {
const dispatch = useDispatch();
useEffect(() => {
if (checkGroup) {
dispatch(getJourneySteps({ checkGroup }));
}
}, [dispatch, checkGroup]);
const journeys = useSelector(journeySelector);
const journey = journeys[checkGroup ?? ''];
return <BrowserExpandedRowComponent checkGroup={checkGroup} journey={journey} />;
};
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<ComponentProps> = ({ checkGroup, journey }) => {
if (!!journey && journey.loading) {
return (
<div>
<EuiLoadingSpinner />
</div>
);
}
if (!journey || journey.steps.length === 0) {
return <EmptyStepState checkGroup={checkGroup} />;
}
if (journey.steps.some(stepEnd)) return <ExecutedJourney journey={journey} />;
if (journey.steps.some(stepConsole)) return <ConsoleOutputEventList journey={journey} />;
// 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;
};

View file

@ -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<Props> = ({
buttonContent,
children,
id,
language,
overflowHeight,
}) => {
return children && id ? (
<EuiAccordion id={id} buttonContent={buttonContent}>
<EuiCodeBlock isCopyable={true} overflowHeight={overflowHeight} language={language}>
{children}
</EuiCodeBlock>
</EuiAccordion>
) : null;
};

View file

@ -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<Props> = ({ event }) => {
const {
colors: { danger },
} = useContext(UptimeThemeContext);
let typeColor: string | undefined;
if (event.synthetics?.type === 'stderr') {
typeColor = danger;
} else {
typeColor = undefined;
}
return (
<EuiFlexGroup>
<EuiFlexItem grow={false}>{event.timestamp}</EuiFlexItem>
<EuiFlexItem grow={false} style={{ color: typeColor }}>
{event.synthetics?.type}
</EuiFlexItem>
<EuiFlexItem>{event.synthetics?.payload?.message}</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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<Props> = ({ journey }) => (
<div>
<EuiTitle>
<h4>
<FormattedMessage
id="xpack.uptime.synthetics.consoleStepList.title"
defaultMessage="No steps ran"
/>
</h4>
</EuiTitle>
<EuiSpacer />
<p>
<FormattedMessage
id="xpack.uptime.synthetics.consoleStepList.message"
defaultMessage="This journey failed to run, recorded console output is shown below:"
/>
</p>
<EuiSpacer />
<EuiCodeBlock>
{journey.steps.map((consoleEvent) => (
<ConsoleEvent event={consoleEvent} />
))}
</EuiCodeBlock>
</div>
);

View file

@ -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<EmptyStepStateProps> = ({ checkGroup }) => (
<EuiEmptyPrompt
iconType="cross"
title={
<h2>
<FormattedMessage
id="xpack.uptime.synthetics.emptyJourney.title"
defaultMessage="There are no steps for this journey"
/>
</h2>
}
body={
<>
<p>
<FormattedMessage
id="xpack.uptime.synthetics.emptyJourney.message.heading"
defaultMessage="This journey did not contain any steps."
/>
</p>
{!!checkGroup && (
<p>
<FormattedMessage
id="xpack.uptime.synthetics.emptyJourney.message.checkGroupField"
defaultMessage="The journey's check group is {codeBlock}."
values={{ codeBlock: <code>{checkGroup}</code> }}
/>
</p>
)}
<p>
<FormattedMessage
id="xpack.uptime.synthetics.emptyJourney.message.footer"
defaultMessage="There is no further information to display."
/>
</p>
</>
}
/>
);

View file

@ -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<ExecutedJourneyProps> = ({ journey }) => (
<div>
<EuiText>
<h3>
<FormattedMessage
id="xpack.uptime.synthetics.executedJourney.heading"
defaultMessage="Summary information"
/>
</h3>
<p>
{statusMessage(
journey.steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 })
)}
</p>
</EuiText>
<EuiSpacer />
<EuiFlexGroup direction="column">
{journey.steps
.filter((step) => step.synthetics?.type === 'step/end')
.map((step, index) => (
<ExecutedStep key={index} index={index} step={step} />
))}
</EuiFlexGroup>
</div>
);

View file

@ -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<ExecutedStepProps> = ({ step, index }) => (
<>
<div style={{ padding: '8px' }}>
<div>
<EuiText>
<strong>
<FormattedMessage
id="xpack.uptime.synthetics.executedStep.stepName"
defaultMessage="{stepNumber}. {stepName}"
values={{
stepNumber: index + 1,
stepName: step.synthetics?.step?.name,
}}
/>
</strong>
</EuiText>
</div>
<EuiSpacer size="s" />
<div>
<StatusBadge status={step.synthetics?.payload?.status} />
</div>
<EuiSpacer />
<div>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<StepScreenshotDisplay
checkGroup={step.monitor.check_group}
screenshotExists={step.synthetics?.screenshotExists}
stepIndex={step.synthetics?.step?.index}
stepName={step.synthetics?.step?.name}
/>
</EuiFlexItem>
<EuiFlexItem>
<CodeBlockAccordion
id={step.synthetics?.step?.name + String(index)}
buttonContent={i18n.translate('xpack.uptime.synthetics.executedStep.scriptHeading', {
defaultMessage: 'Step script',
})}
overflowHeight={CODE_BLOCK_OVERFLOW_HEIGHT}
language="javascript"
>
{step.synthetics?.payload?.source}
</CodeBlockAccordion>
<CodeBlockAccordion
id={`${step.synthetics?.step?.name}_error`}
buttonContent={i18n.translate('xpack.uptime.synthetics.executedStep.errorHeading', {
defaultMessage: 'Error',
})}
language="html"
overflowHeight={CODE_BLOCK_OVERFLOW_HEIGHT}
>
{step.synthetics?.error?.message}
</CodeBlockAccordion>
<CodeBlockAccordion
id={`${step.synthetics?.step?.name}_stack`}
buttonContent={i18n.translate('xpack.uptime.synthetics.executedStep.stackTrace', {
defaultMessage: 'Stack trace',
})}
language="html"
overflowHeight={CODE_BLOCK_OVERFLOW_HEIGHT}
>
{step.synthetics?.error?.stack}
</CodeBlockAccordion>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
<EuiSpacer />
</>
);

View file

@ -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<StatusBadgeProps> = ({ status }) => {
const theme = useContext(UptimeThemeContext);
return (
<EuiBadge color={colorFromStatus(theme.colors, status)}>{textFromStatus(status)}</EuiBadge>
);
};

View file

@ -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<StepScreenshotDisplayProps> = ({
checkGroup,
screenshotExists,
stepIndex,
stepName,
}) => {
const containerRef = useRef(null);
const {
colors: { lightestShade: pageBackground },
} = useContext(UptimeThemeContext);
const [isImagePopoverOpen, setIsImagePopoverOpen] = useState<boolean>(false);
const [isOverlayOpen, setIsOverlayOpen] = useState<boolean>(false);
const intersection = useIntersection(containerRef, {
root: null,
rootMargin: '0px',
threshold: 1,
});
const [hasIntersected, setHasIntersected] = useState<boolean>(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 && (
<EuiOverlayMask onClick={() => setIsOverlayOpen(false)}>
<input
type="image"
src={imgSrc}
alt={
stepName
? i18n.translate(
'xpack.uptime.synthetics.screenshotDisplay.fullScreenshotAltText',
{
defaultMessage: 'Full screenshot for step with name "{stepName}"',
values: {
stepName,
},
}
)
: i18n.translate(
'xpack.uptime.synthetics.screenshotDisplay.fullScreenshotAltTextWithoutName',
{
defaultMessage: 'Full screenshot',
}
)
}
style={{ objectFit: 'contain' }}
onClick={() => setIsOverlayOpen(false)}
/>
</EuiOverlayMask>
)}
<EuiPopover
anchorPosition="rightCenter"
button={
<input
type="image"
style={{
width: THUMBNAIL_WIDTH,
height: THUMBNAIL_HEIGHT,
objectFit: 'cover',
objectPosition: 'center top',
}}
src={imgSrc}
alt={
stepName
? i18n.translate('xpack.uptime.synthetics.screenshotDisplay.altText', {
defaultMessage: 'Screenshot for step with name "{stepName}"',
values: {
stepName,
},
})
: i18n.translate('xpack.uptime.synthetics.screenshotDisplay.altTextWithoutName', {
defaultMessage: 'Screenshot',
})
}
onClick={() => setIsOverlayOpen(true)}
onMouseEnter={() => setIsImagePopoverOpen(true)}
onMouseLeave={() => setIsImagePopoverOpen(false)}
/>
}
closePopover={() => setIsImagePopoverOpen(false)}
isOpen={isImagePopoverOpen}
>
<img
alt={
stepName
? i18n.translate('xpack.uptime.synthetics.screenshotDisplay.thumbnailAltText', {
defaultMessage: 'Thumbnail screenshot for step with name "{stepName}"',
values: {
stepName,
},
})
: i18n.translate(
'xpack.uptime.synthetics.screenshotDisplay.thumbnailAltTextWithoutName',
{
defaultMessage: 'Thumbnail screenshot',
}
)
}
src={imgSrc}
style={{ width: POPOVER_IMG_WIDTH, height: POPOVER_IMG_HEIGHT, objectFit: 'contain' }}
/>
</EuiPopover>
</>
);
} else if (screenshotExists === false) {
content = (
<EuiFlexGroup alignItems="center" direction="column" style={{ paddingTop: '32px' }}>
<EuiFlexItem grow={false}>
<EuiIcon color="subdued" size="xxl" type="image" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<strong>
<FormattedMessage
id="xpack.uptime.synthetics.screenshot.noImageMessage"
defaultMessage="No image available"
/>
</strong>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<div
ref={containerRef}
style={{ backgroundColor: pageBackground, height: THUMBNAIL_HEIGHT, width: THUMBNAIL_WIDTH }}
>
{content}
</div>
);
};

View file

@ -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<ThemeContextProps> = ({ darkMo
range: euiDarkVars.euiFocusBackgroundColor,
success: euiDarkVars.euiColorSuccess,
warning: euiDarkVars.euiColorWarning,
lightestShade: euiDarkVars.euiColorLightestShade,
};
} else {
colors = {
@ -63,6 +65,7 @@ export const UptimeThemeContextProvider: React.FC<ThemeContextProps> = ({ darkMo
range: euiLightVars.euiFocusBackgroundColor,
success: euiLightVars.euiColorSuccess,
warning: euiLightVars.euiColorWarning,
lightestShade: euiLightVars.euiColorLightestShade,
};
}
const value = useMemo(() => {

View file

@ -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<FetchJourneyStepsParams>('GET_JOURNEY_STEPS');
export const getJourneyStepsSuccess = createAction<SyntheticsJourneyApiResponse>(
'GET_JOURNEY_STEPS_SUCCESS'
);
export const getJourneyStepsFail = createAction<GetJourneyFailPayload>('GET_JOURNEY_STEPS_FAIL');
export const pruneJourneyState = createAction<string[]>('PRUNE_JOURNEY_STATE');

View file

@ -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<SyntheticsJourneyApiResponse> {
return (await apiService.get(
`/api/uptime/journey/${params.checkGroup}`,
undefined,
SyntheticsJourneyApiResponseType
)) as SyntheticsJourneyApiResponse;
}

View file

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

View file

@ -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<FetchJourneyStepsParams>) {
try {
const response = yield call(fetchJourneySteps, action.payload);
yield put(getJourneyStepsSuccess(response));
} catch (e) {
yield put(getJourneyStepsFail({ checkGroup: action.payload.checkGroup, error: e }));
}
});
}

View file

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

View file

@ -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<JourneyKVP, Payload>(
{
[String(getJourneySteps)]: (
state: JourneyKVP,
{ payload: { checkGroup } }: Action<FetchJourneyStepsParams>
) => ({
...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<SyntheticsJourneyApiResponse>
) => ({
...state,
[checkGroup]: {
loading: false,
checkGroup,
steps,
},
}),
[String(getJourneyStepsFail)]: (
state: JourneyKVP,
{ payload: { checkGroup, error } }: Action<GetJourneyFailPayload>
) => ({
...state,
[checkGroup]: state[checkGroup]
? {
...state[checkGroup],
loading: false,
error,
}
: {
checkGroup,
loading: false,
steps: [],
error,
},
}),
[String(pruneJourneyState)]: (state: JourneyKVP, action: Action<string[]>) =>
action.payload.reduce(
(prev, cur) => ({
...prev,
[cur]: state[cur],
}),
{}
),
},
initialState
);

View file

@ -117,6 +117,7 @@ describe('state selectors', () => {
newAlert: { data: null, loading: false },
anomalyAlertDeletion: { data: null, loading: false },
},
journeys: {},
};
it('selects base path from state', () => {

View file

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

View file

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

View file

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

View file

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

View file

@ -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<GetPingsParams, PingsResponse> = async ({
callES,
dynamicSettings,
@ -39,7 +76,12 @@ export const getPings: UMElasticsearchQueryFn<GetPingsParams, PingsResponse> = 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: {

View file

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

View file

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

View file

@ -6,3 +6,5 @@
export { createGetPingsRoute } from './get_pings';
export { createGetPingHistogramRoute } from './get_ping_histogram';
export { createJourneyRoute } from './journeys';
export { createJourneyScreenshotRoute } from './journey_screenshots';

View file

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

View file

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