[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:
parent
43cf97e1f2
commit
0ce802522c
|
@ -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
|
||||
|
|
|
@ -40,6 +40,7 @@ export interface UptimeAppColors {
|
|||
range: string;
|
||||
mean: string;
|
||||
warning: string;
|
||||
lightestShade: string;
|
||||
}
|
||||
|
||||
export interface UptimeAppProps {
|
||||
|
|
|
@ -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>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -5,4 +5,4 @@
|
|||
*/
|
||||
|
||||
export { PingListComponent } from './ping_list';
|
||||
export { PingList } from './ping_list_container';
|
||||
export { PingList } from './ping_list';
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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(`""`);
|
||||
});
|
||||
});
|
|
@ -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>,
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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 />
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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(() => {
|
||||
|
|
24
x-pack/plugins/uptime/public/state/actions/journey.ts
Normal file
24
x-pack/plugins/uptime/public/state/actions/journey.ts
Normal 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');
|
22
x-pack/plugins/uptime/public/state/api/journey.ts
Normal file
22
x-pack/plugins/uptime/public/state/api/journey.ts
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
26
x-pack/plugins/uptime/public/state/effects/journey.ts
Normal file
26
x-pack/plugins/uptime/public/state/effects/journey.ts
Normal 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 }));
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
98
x-pack/plugins/uptime/public/state/reducers/journey.ts
Normal file
98
x-pack/plugins/uptime/public/state/reducers/journey.ts
Normal 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
|
||||
);
|
|
@ -117,6 +117,7 @@ describe('state selectors', () => {
|
|||
newAlert: { data: null, loading: false },
|
||||
anomalyAlertDeletion: { data: null, loading: false },
|
||||
},
|
||||
journeys: {},
|
||||
};
|
||||
|
||||
it('selects base path from state', () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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];
|
||||
};
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -6,3 +6,5 @@
|
|||
|
||||
export { createGetPingsRoute } from './get_pings';
|
||||
export { createGetPingHistogramRoute } from './get_ping_histogram';
|
||||
export { createJourneyRoute } from './journeys';
|
||||
export { createJourneyScreenshotRoute } from './journey_screenshots';
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
34
x-pack/plugins/uptime/server/rest_api/pings/journeys.ts
Normal file
34
x-pack/plugins/uptime/server/rest_api/pings/journeys.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
Binary file not shown.
Loading…
Reference in a new issue