[Synthetics] Run now and Test Now mode (#148160)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Fixes https://github.com/elastic/kibana/issues/147677
This commit is contained in:
Shahzad 2023-01-11 16:17:51 +01:00 committed by GitHub
parent f1ede739a3
commit bc78a13d8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 3847 additions and 259 deletions

View file

@ -14,12 +14,15 @@ const SYNTHETICS_RUNNER = Symbol.for('SYNTHETICS_RUNNER');
// @ts-ignore
export const runner: Runner = global[SYNTHETICS_RUNNER];
export const recordVideo = (page: Page) => {
export const recordVideo = (page: Page, postfix = '') => {
after(async () => {
try {
const videoFilePath = await page.video()?.path();
const pathToVideo = videoFilePath?.replace('.journeys/videos/', '').replace('.webm', '');
const newVideoPath = videoFilePath?.replace(pathToVideo!, runner.currentJourney!.name);
const newVideoPath = videoFilePath?.replace(
pathToVideo!,
runner.currentJourney!.name + `-${postfix}`
);
fs.renameSync(videoFilePath!, newVideoPath!);
} catch (e) {
// eslint-disable-next-line no-console

View file

@ -139,7 +139,9 @@ export class TestReporter implements Reporter {
fs.unlinkSync('.journeys/videos/' + journeyName + '.webm');
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
console.log(
'Failed to delete video file for path ' + '.journeys/videos/' + journeyName + '.webm'
);
}
});

View file

@ -9,7 +9,7 @@ import { expect, Page } from '@elastic/synthetics';
export async function waitForLoadingToFinish({ page }: { page: Page }) {
while (true) {
if ((await page.$(byTestId('kbnLoadingMessage'))) === null) break;
if (!(await page.isVisible(byTestId('kbnLoadingMessage'), { timeout: 5000 }))) break;
await page.waitForTimeout(5 * 1000);
}
}

View file

@ -55,6 +55,10 @@ export function useFetcher<TReturn>(
const promise = fn({ signal });
if (!promise) {
setResult((prevResult) => ({
...prevResult,
status: FETCH_STATUS.NOT_INITIATED,
}));
return;
}
@ -74,6 +78,7 @@ export function useFetcher<TReturn>(
if (!signal.aborted) {
setResult({
data,
loading: false,
status: FETCH_STATUS.SUCCESS,
error: undefined,
} as FetcherResult<InferResponseType<TReturn>>);

View file

@ -12,6 +12,7 @@ export const OverviewStatusMetaDataCodec = t.interface({
monitorQueryId: t.string,
configId: t.string,
location: t.string,
timestamp: t.string,
status: t.string,
ping: PingType,
});

View file

@ -6,7 +6,14 @@
*/
import type { SimpleSavedObject } from '@kbn/core/public';
import { EncryptedSyntheticsMonitor, SyntheticsMonitor } from '../runtime_types';
import {
EncryptedSyntheticsMonitor,
Locations,
MonitorFields,
ServiceLocationErrors,
SyntheticsMonitor,
SyntheticsMonitorSchedule,
} from '../runtime_types';
export interface MonitorIdParam {
monitorId: string;
@ -24,3 +31,12 @@ export interface SyntheticsServiceAllowed {
serviceAllowed: boolean;
signupUrl: string;
}
export interface TestNowResponse {
schedule: SyntheticsMonitorSchedule;
locations: Locations;
errors?: ServiceLocationErrors;
testRunId: string;
configId: string;
monitor: MonitorFields;
}

View file

@ -1,32 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import fs from 'fs';
import Runner from '@elastic/synthetics/src/core/runner';
import { after, Page } from '@elastic/synthetics';
const SYNTHETICS_RUNNER = Symbol.for('SYNTHETICS_RUNNER');
// @ts-ignore
export const runner: Runner = global[SYNTHETICS_RUNNER];
export const recordVideo = (page: Page, postfix = '') => {
after(async () => {
try {
const videoFilePath = await page.video()?.path();
const pathToVideo = videoFilePath?.replace('.journeys/videos/', '').replace('.webm', '');
const newVideoPath = videoFilePath?.replace(
pathToVideo!,
runner.currentJourney!.name + `-${postfix}`
);
fs.renameSync(videoFilePath!, newVideoPath!);
} catch (e) {
// eslint-disable-next-line no-console
console.log('Error while renaming video file', e);
}
});
};

View file

@ -153,6 +153,7 @@ const createMonitorJourney = ({
step('Go to monitor management', async () => {
await syntheticsApp.navigateToMonitorManagement(true);
await syntheticsApp.enableMonitorManagement();
});
step('Ensure all monitors are deleted', async () => {
@ -178,7 +179,6 @@ const createMonitorJourney = ({
});
step(`view ${monitorName} details in Monitor Management UI`, async () => {
await syntheticsApp.navigateToMonitorManagement();
const hasFailure = await syntheticsApp.findMonitorConfiguration(monitorListDetails);
expect(hasFailure).toBeFalsy();
});
@ -191,6 +191,8 @@ const createMonitorJourney = ({
monitorType
);
expect(hasFailure).toBeFalsy();
await page.click('text=Update monitor');
await page.waitForSelector('text=Monitor updated successfully.');
});
step('cannot save monitor with the same name', async () => {
@ -198,12 +200,10 @@ const createMonitorJourney = ({
await syntheticsApp.createMonitor({ monitorConfig, monitorType });
await page.waitForSelector('text=Monitor name already exists');
await syntheticsApp.clickByTestSubj('syntheticsMonitorConfigSubmitButton');
const success = page.locator('text=Monitor added successfully.');
expect(await success.count()).toBe(0);
await page.waitForSelector('text=Cancel');
});
step('delete monitor', async () => {
await syntheticsApp.navigateToMonitorManagement();
await syntheticsApp.findByText('Monitor');
const isSuccessful = await syntheticsApp.deleteMonitors();
expect(isSuccessful).toBeTruthy();

View file

@ -29,9 +29,7 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
let downCheckTime = new Date(Date.now()).toISOString();
before(async () => {
await services.cleaUpRules();
await services.cleaUpAlerts();
await services.cleanTestMonitors();
await services.cleaUp();
await services.enableMonitorManagedViaApi();
await services.addTestMonitor('Test Monitor', {
type: 'http',
@ -45,9 +43,7 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
});
after(async () => {
await services.cleaUpRules();
await services.cleaUpAlerts();
await services.cleanTestMonitors();
await services.cleaUp();
});
step('Go to monitors page', async () => {
@ -86,7 +82,7 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
step('set the monitor status as down', async () => {
downCheckTime = new Date(Date.now()).toISOString();
await services.addTestSummaryDocument({
isDown: true,
docType: 'summaryDown',
timestamp: downCheckTime,
});
await page.waitForTimeout(5 * 1000);
@ -135,7 +131,7 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
});
step('set the status down again to generate another alert', async () => {
await services.addTestSummaryDocument({ isDown: true });
await services.addTestSummaryDocument({ docType: 'summaryDown' });
await retry.tryForTime(2 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
@ -161,7 +157,7 @@ journey(`DefaultStatusAlert`, async ({ page, params }) => {
await services.addTestSummaryDocument({
timestamp: downCheckTime,
monitorId,
isDown: true,
docType: 'summaryDown',
name,
});

View file

@ -8,7 +8,7 @@
import { journey, step, expect, Page } from '@elastic/synthetics';
import { assertText, byTestId } from '@kbn/observability-plugin/e2e/utils';
import { RetryService } from '@kbn/ftr-common-functional-services';
import { recordVideo } from '../../helpers/record_video';
import { recordVideo } from '@kbn/observability-plugin/e2e/record_video';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics/synthetics_app';
let page1: Page;

View file

@ -5,17 +5,16 @@
* 2.0.
*/
import { before, expect, journey, step } from '@elastic/synthetics';
import { expect, journey, step } from '@elastic/synthetics';
import { recordVideo } from '@kbn/observability-plugin/e2e/record_video';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics/synthetics_app';
journey('Test Monitor Detail Flyout', async ({ page, params }) => {
journey('TestMonitorDetailFlyout', async ({ page, params }) => {
recordVideo(page);
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
const monitorName = 'test-flyout-http-monitor';
before(async () => {
await syntheticsApp.waitForLoadingToFinish();
});
step('Go to monitor-management', async () => {
await syntheticsApp.navigateToAddMonitor();
});

View file

@ -18,4 +18,5 @@ export * from './alerting_default.journey';
export * from './global_parameters.journey';
export * from './detail_flyout';
export * from './alert_rules/default_status_alert.journey';
export * from './test_now_mode.journey';
export * from './data_retention.journey';

View file

@ -0,0 +1,319 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DocOverrides } from './sample_docs';
export const journeySummary = ({ name, timestamp, monitorId, testRunId }: DocOverrides = {}) => ({
summary: {
up: 1,
down: 0,
},
test_run_id: testRunId ?? '07e339f4-4d56-4cdb-b314-96faacaee645',
agent: {
name: 'job-88fe737c53c39aea-lp69x',
id: '049c1703-b4bd-45fa-8715-61812add68c0',
type: 'heartbeat',
ephemeral_id: 'e5a562a7-0fff-4684-bb5d-93abf7a754be',
version: '8.6.0',
},
synthetics: {
journey: {
name: 'inline',
id: 'inline',
tags: null,
},
type: 'heartbeat/summary',
},
monitor: {
duration: {
us: 2218379,
},
origin: 'ui',
name: name ?? 'https://www.google.com',
check_group: testRunId ?? '6e7ce5d2-8756-11ed-98ca-6a95015d8678',
id: monitorId ?? '07e339f4-4d56-4cdb-b314-96faacaee645',
timespan: {
lt: '2022-12-29T09:04:45.789Z',
gte: '2022-12-29T08:54:45.789Z',
},
type: 'browser',
status: 'up',
},
url: {
path: '/',
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com/',
},
observer: {
geo: {
name: 'US Central QA',
location: '41.8780, 93.0977',
},
name: 'US Central QA',
},
'@timestamp': timestamp ?? '2022-12-29T08:54:44.502Z',
ecs: {
version: '8.0.0',
},
config_id: '07e339f4-4d56-4cdb-b314-96faacaee645',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'browser',
},
run_once: true,
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-29T08:54:45.845773057Z',
id: 'default-1855d174b55-0',
up: 1,
flap_history: [],
down: 0,
status: 'up',
},
event: {
agent_id_status: 'auth_metadata_missing',
ingested: '2022-12-29T08:54:47Z',
type: 'heartbeat/summary',
dataset: 'browser',
},
});
export const journeyStart = ({ name, timestamp, monitorId, testRunId }: DocOverrides = {}) => ({
test_run_id: testRunId ?? '07e339f4-4d56-4cdb-b314-96faacaee645',
agent: {
name: 'job-88fe737c53c39aea-lp69x',
id: '049c1703-b4bd-45fa-8715-61812add68c0',
ephemeral_id: 'e5a562a7-0fff-4684-bb5d-93abf7a754be',
type: 'heartbeat',
version: '8.6.0',
},
package: {
name: '@elastic/synthetics',
version: '1.0.0-beta.38',
},
os: {
platform: 'linux',
},
synthetics: {
package_version: '1.0.0-beta.38',
journey: {
name: 'inline',
id: 'inline',
},
payload: {
source:
'({ page, context, browser, params, request }) => {\n scriptFn.apply(null, [\n core_1.step,\n page,\n context,\n browser,\n params,\n expect_1.expect,\n request,\n ]);\n }',
params: {},
},
index: 0,
type: 'journey/start',
},
monitor: {
origin: 'ui',
name: name ?? 'https://www.google.com',
check_group: testRunId ?? '6e7ce5d2-8756-11ed-98ca-6a95015d8678',
id: monitorId ?? '07e339f4-4d56-4cdb-b314-96faacaee645',
timespan: {
lt: '2022-12-29T09:04:42.284Z',
gte: '2022-12-29T08:54:42.284Z',
},
type: 'browser',
},
observer: {
geo: {
name: 'Test private location',
location: '41.8780, 93.0977',
},
name: 'Test private location',
},
'@timestamp': timestamp ?? '2022-12-29T08:54:42.284Z',
ecs: {
version: '8.0.0',
},
config_id: monitorId ?? '07e339f4-4d56-4cdb-b314-96faacaee645',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'browser',
},
run_once: true,
event: {
agent_id_status: 'auth_metadata_missing',
ingested: '2022-12-29T08:54:43Z',
type: 'journey/start',
dataset: 'browser',
},
});
export const step1 = ({ name, timestamp, monitorId, testRunId }: DocOverrides = {}) => ({
test_run_id: testRunId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
agent: {
name: 'job-76905d93798e6fff-z6nsb',
id: '3dcc8177-b2ca-4b52-8e79-66332f6dbc72',
type: 'heartbeat',
ephemeral_id: '9b6b4f26-2a70-417a-8732-22ae95a406fa',
version: '8.6.0',
},
package: {
name: '@elastic/synthetics',
version: '1.0.0-beta.38',
},
os: {
platform: 'linux',
},
synthetics: {
package_version: '1.0.0-beta.38',
journey: {
name: 'inline',
id: 'inline',
},
payload: {
source: "async () => {\n await page.goto('https://www.google.com');\n}",
url: 'https://www.google.com/',
status: 'succeeded',
},
index: 7,
step: {
duration: {
us: 1419377,
},
name: 'Go to https://www.google.com',
index: 1,
status: 'succeeded',
},
type: 'step/end',
},
monitor: {
origin: 'ui',
name: name ?? 'https://www.google.com',
check_group: testRunId ?? 'e81de0da-875e-11ed-8f2a-7eb894226a99',
id: monitorId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
timespan: {
lt: '2022-12-29T10:05:23.730Z',
gte: '2022-12-29T09:55:23.730Z',
},
type: 'browser',
},
url: {
path: '/',
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com/',
},
observer: {
geo: {
name: 'US Central QA',
location: '41.8780, 93.0977',
},
name: 'US Central QA',
},
'@timestamp': timestamp ?? '2022-12-29T09:55:23.729Z',
ecs: {
version: '8.0.0',
},
config_id: monitorId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'browser',
},
run_once: true,
event: {
agent_id_status: 'auth_metadata_missing',
ingested: '2022-12-29T09:55:24Z',
type: 'step/end',
dataset: 'browser',
},
});
export const step2 = ({ name, timestamp, monitorId, testRunId }: DocOverrides = {}) => ({
test_run_id: testRunId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
agent: {
name: 'job-76905d93798e6fff-z6nsb',
id: '3dcc8177-b2ca-4b52-8e79-66332f6dbc72',
ephemeral_id: '9b6b4f26-2a70-417a-8732-22ae95a406fa',
type: 'heartbeat',
version: '8.6.0',
},
package: {
name: '@elastic/synthetics',
version: '1.0.0-beta.38',
},
os: {
platform: 'linux',
},
synthetics: {
package_version: '1.0.0-beta.38',
journey: {
name: 'inline',
id: 'inline',
},
payload: {
source: "async () => {\n await page.goto('https://www.google.com');\n}",
url: 'https://www.google.com/',
status: 'succeeded',
},
index: 15,
step: {
duration: {
us: 788024,
},
name: 'Go to step 2',
index: 2,
status: 'succeeded',
},
type: 'step/end',
},
monitor: {
origin: 'ui',
name: name ?? 'https://www.google.com',
check_group: testRunId ?? 'e81de0da-875e-11ed-8f2a-7eb894226a99',
id: monitorId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
timespan: {
lt: '2022-12-29T10:05:24.550Z',
gte: '2022-12-29T09:55:24.550Z',
},
type: 'browser',
},
url: {
path: '/',
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com/',
},
observer: {
geo: {
name: 'US Central QA',
location: '41.8780, 93.0977',
},
name: 'US Central QA',
},
'@timestamp': timestamp ?? '2022-12-29T09:55:24.520Z',
ecs: {
version: '8.0.0',
},
config_id: monitorId ?? 'c16b1614-7f48-4791-8f46-9ccf3a896e20',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'browser',
},
run_once: true,
event: {
agent_id_status: 'auth_metadata_missing',
ingested: '2022-12-29T09:55:24Z',
type: 'step/end',
dataset: 'browser',
},
});

View file

@ -0,0 +1,344 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import uuid from 'uuid';
export interface DocOverrides {
timestamp?: string;
monitorId?: string;
name?: string;
testRunId?: string;
}
export const getUpHit = ({ name, timestamp, monitorId, testRunId }: DocOverrides = {}) => ({
summary: {
up: 1,
down: 0,
},
tcp: {
rtt: {
connect: {
us: 22245,
},
},
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
type: 'heartbeat',
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
version: '8.7.0',
},
resolve: {
rtt: {
us: 3101,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 155239,
},
ip: '142.250.181.196',
origin: 'ui',
name: name ?? 'Test Monitor',
timespan: {
lt: '2022-12-18T09:55:04.211Z',
gte: '2022-12-18T09:52:04.211Z',
},
fleet_managed: true,
id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
check_group: 'a039fd21-7eb9-11ed-8949-0242ac120006',
type: 'http',
status: 'up',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
observer: {
geo: {
name: 'Test private location',
},
name: 'Test private location',
},
'@timestamp': timestamp ?? '2022-12-18T09:52:04.056Z',
ecs: {
version: '8.0.0',
},
config_id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
tls: {
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2022-11-28T08:19:01.000Z',
established: true,
server: {
x509: {
not_after: '2023-02-20T08:19:00.000Z',
subject: {
distinguished_name: 'CN=www.google.com',
common_name: 'www.google.com',
},
not_before: '2022-11-28T08:19:01.000Z',
public_key_curve: 'P-256',
public_key_algorithm: 'ECDSA',
signature_algorithm: 'SHA256-RSA',
serial_number: '173037077033925240295268439311466214245',
issuer: {
distinguished_name: 'CN=GTS CA 1C3,O=Google Trust Services LLC,C=US',
common_name: 'GTS CA 1C3',
},
},
hash: {
sha1: 'ea1b44061b864526c45619230b3299117d11bf4e',
sha256: 'a5686448de09cc82b9cdad1e96357f919552ab14244da7948dd412ec0fc37d2b',
},
},
rtt: {
handshake: {
us: 35023,
},
},
version: '1.3',
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:52:10.30502451Z',
up: 1,
id: 'Test private location-18524a5e641-0',
down: 0,
flap_history: [],
status: 'up',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:52:11Z',
dataset: 'http',
},
...(testRunId && { test_run_id: testRunId }),
http: {
rtt: {
response_header: {
us: 144758,
},
total: {
us: 149191,
},
write_request: {
us: 48,
},
content: {
us: 401,
},
validate: {
us: 145160,
},
},
response: {
headers: {
Server: 'gws',
P3p: 'CP="This is not a P3P policy! See g.co/p3phelp for more info."',
Date: 'Thu, 29 Dec 2022 08:17:09 GMT',
'X-Frame-Options': 'SAMEORIGIN',
'Accept-Ranges': 'none',
'Cache-Control': 'private, max-age=0',
'X-Xss-Protection': '0',
'Cross-Origin-Opener-Policy-Report-Only': 'same-origin-allow-popups; report-to="gws"',
Vary: 'Accept-Encoding',
Expires: '-1',
'Content-Type': 'text/html; charset=ISO-8859-1',
},
status_code: 200,
mime_type: 'text/html; charset=utf-8',
body: {
bytes: 13963,
hash: 'a4c2cf7dead9fb9329fc3727fc152b6a12072410926430491d02a0c6dc3a70ff',
},
},
},
});
export const firstDownHit = ({ name, timestamp, monitorId }: DocOverrides = {}) => ({
summary: {
up: 0,
down: 1,
},
tcp: {
rtt: {
connect: {
us: 20482,
},
},
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
type: 'heartbeat',
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
version: '8.7.0',
},
resolve: {
rtt: {
us: 3234,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 152459,
},
origin: 'ui',
ip: '142.250.181.196',
name: name ?? 'Test Monitor',
fleet_managed: true,
check_group: uuid.v4(),
timespan: {
lt: '2022-12-18T09:52:50.128Z',
gte: '2022-12-18T09:49:50.128Z',
},
id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
type: 'http',
status: 'down',
},
error: {
message: 'received status code 200 expecting [500]',
type: 'validate',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
observer: {
geo: {
name: 'Test private location',
},
name: 'Test private location',
},
'@timestamp': timestamp ?? '2022-12-18T09:49:49.976Z',
ecs: {
version: '8.0.0',
},
config_id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
tls: {
established: true,
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2022-11-28T08:19:01.000Z',
server: {
x509: {
not_after: '2023-02-20T08:19:00.000Z',
subject: {
distinguished_name: 'CN=www.google.com',
common_name: 'www.google.com',
},
not_before: '2022-11-28T08:19:01.000Z',
public_key_algorithm: 'ECDSA',
public_key_curve: 'P-256',
signature_algorithm: 'SHA256-RSA',
serial_number: '173037077033925240295268439311466214245',
issuer: {
distinguished_name: 'CN=GTS CA 1C3,O=Google Trust Services LLC,C=US',
common_name: 'GTS CA 1C3',
},
},
hash: {
sha1: 'ea1b44061b864526c45619230b3299117d11bf4e',
sha256: 'a5686448de09cc82b9cdad1e96357f919552ab14244da7948dd412ec0fc37d2b',
},
},
rtt: {
handshake: {
us: 28468,
},
},
version: '1.3',
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:49:56.007551998Z',
id: 'Test private location-18524a3d9a7-0',
up: 0,
down: 1,
flap_history: [],
status: 'down',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:49:57Z',
dataset: 'http',
},
http: {
rtt: {
response_header: {
us: 144758,
},
total: {
us: 149191,
},
write_request: {
us: 48,
},
content: {
us: 401,
},
validate: {
us: 145160,
},
},
response: {
headers: {
Server: 'gws',
P3p: 'CP="This is not a P3P policy! See g.co/p3phelp for more info."',
Date: 'Thu, 29 Dec 2022 08:17:09 GMT',
'X-Frame-Options': 'SAMEORIGIN',
'Accept-Ranges': 'none',
'Cache-Control': 'private, max-age=0',
'X-Xss-Protection': '0',
'Cross-Origin-Opener-Policy-Report-Only': 'same-origin-allow-popups; report-to="gws"',
Vary: 'Accept-Encoding',
Expires: '-1',
'Content-Type': 'text/html; charset=ISO-8859-1',
},
status_code: 200,
mime_type: 'text/html; charset=utf-8',
body: {
bytes: 13963,
hash: 'a4c2cf7dead9fb9329fc3727fc152b6a12072410926430491d02a0c6dc3a70ff',
},
},
},
});

View file

@ -10,7 +10,8 @@ import type { Client } from '@elastic/elasticsearch';
import { KbnClient, uriencode } from '@kbn/test';
import pMap from 'p-map';
import { SYNTHETICS_API_URLS } from '../../../../common/constants';
import { firstDownHit, firstUpHit } from '../alert_rules/sample_docs/sample_docs';
import { journeyStart, journeySummary, step1, step2 } from './data/browser_docs';
import { firstDownHit, getUpHit } from './data/sample_docs';
export class SyntheticsServices {
kibanaUrl: string;
@ -98,22 +99,80 @@ export class SyntheticsServices {
}
async addTestSummaryDocument({
isDown = false,
docType = 'summaryUp',
timestamp = new Date(Date.now()).toISOString(),
monitorId,
name,
}: { monitorId?: string; isDown?: boolean; timestamp?: string; name?: string } = {}) {
testRunId,
stepIndex = 1,
}: {
monitorId?: string;
docType?: 'summaryUp' | 'summaryDown' | 'journeyStart' | 'journeyEnd' | 'stepEnd';
timestamp?: string;
name?: string;
testRunId?: string;
stepIndex?: number;
} = {}) {
const getService = this.params.getService;
const es: Client = getService('es');
let document = {
'@timestamp': timestamp,
};
let index = 'synthetics-http-default';
switch (docType) {
case 'stepEnd':
index = 'synthetics-browser-default';
const stepDoc =
stepIndex === 1
? step1({ timestamp, monitorId, name, testRunId })
: step2({ timestamp, monitorId, name, testRunId });
document = { ...stepDoc, ...document };
break;
case 'journeyEnd':
index = 'synthetics-browser-default';
document = { ...journeySummary({ timestamp, monitorId, name, testRunId }), ...document };
break;
case 'journeyStart':
index = 'synthetics-browser-default';
document = { ...journeyStart({ timestamp, monitorId, name, testRunId }), ...document };
break;
case 'summaryDown':
document = { ...firstDownHit({ timestamp, monitorId, name, testRunId }), ...document };
break;
case 'summaryUp':
document = { ...getUpHit({ timestamp, monitorId, name, testRunId }), ...document };
break;
default:
document = { ...getUpHit({ timestamp, monitorId, name, testRunId }), ...document };
}
await es.index({
index: 'synthetics-http-default',
document: {
...(isDown ? firstDownHit({ timestamp, monitorId, name }) : firstUpHit),
'@timestamp': timestamp,
},
index,
document,
});
}
async cleaUp(things: Array<'monitors' | 'alerts' | 'rules'> = ['monitors', 'alerts', 'rules']) {
const promises = [];
if (things.includes('monitors')) {
promises.push(this.cleanTestMonitors());
}
if (things.includes('alerts')) {
promises.push(this.cleaUpAlerts());
}
if (things.includes('rules')) {
promises.push(this.cleaUpRules());
}
await Promise.all(promises);
}
async cleaUpAlerts() {
const getService = this.params.getService;
const es: Client = getService('es');

View file

@ -0,0 +1,164 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { journey, step, before, after, expect } from '@elastic/synthetics';
import { RetryService } from '@kbn/ftr-common-functional-services';
import { recordVideo } from '@kbn/observability-plugin/e2e/record_video';
import { byTestId } from '@kbn/observability-plugin/e2e/utils';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics/synthetics_app';
import { SyntheticsServices } from './services/synthetics_services';
journey(`TestNowMode`, async ({ page, params }) => {
page.setDefaultTimeout(60 * 1000);
recordVideo(page);
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
const services = new SyntheticsServices(params);
const getService = params.getService;
const retry: RetryService = getService('retry');
const firstCheckTime = new Date(Date.now()).toISOString();
let testRunId: string | undefined;
before(async () => {
page.on('request', (evt) => {
if (
evt.resourceType() === 'fetch' &&
(evt.url().includes('service/monitors/trigger/') ||
evt.url().includes('uptime/service/monitors/run_once'))
) {
evt
.response()
?.then((res) => res?.json())
.then((res) => {
if (res.testRunId) {
testRunId = res.testRunId;
} else {
try {
testRunId = evt.url().split('/run_once/').pop();
} catch (e) {
// eee
}
}
});
}
});
await services.cleaUp();
await services.enableMonitorManagedViaApi();
await services.addTestMonitor('Test Monitor', {
type: 'http',
urls: 'https://www.google.com',
custom_heartbeat_id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
locations: [
{ id: 'Test private location', label: 'Test private location', isServiceManaged: true },
],
});
await services.addTestSummaryDocument({ timestamp: firstCheckTime });
});
after(async () => {
await services.cleaUp();
});
step('Go to monitors page', async () => {
await syntheticsApp.navigateToOverview(true);
});
step('Opens flyout when run manually', async () => {
await page.hover('text=Test Monitor');
await page.click('[aria-label="Open actions menu"]');
await page.click('text=Run test manually');
await page.waitForSelector('text=Test results');
});
step('Displays results when successful', async () => {
await retry.tryForTime(30 * 1000, async () => {
expect(testRunId?.length).toBe(36);
});
await services.addTestSummaryDocument({ testRunId });
await page.waitForSelector('text=Completed');
await page.waitForSelector('text=Took 155 ms');
await page.waitForSelector('text=Test private location');
});
step('Displays data in expanded row', async () => {
await page.click(byTestId('uptimePingListExpandBtn'));
await page.waitForSelector('text=Body size is 13KB.');
await page.click('text=Response headers');
await page.waitForSelector('text=Accept-Ranges');
await page.waitForSelector('text=Cache-Control');
await page.waitForSelector('text=private, max-age=0');
await page.click(byTestId('euiFlyoutCloseButton'));
});
step('Add a browser monitor', async () => {
await services.addTestMonitor('Browser Monitor', {
type: 'browser',
'source.inline.script':
"step('Go to https://www.google.com', async () => {\n await page.goto('https://www.google.com');\n});\n\nstep('Go to https://www.google.com', async () => {\n await page.goto('https://www.google.com');\n});",
urls: 'https://www.google.com',
locations: [
{ id: 'Test private location', label: 'Test private location', isServiceManaged: true },
],
});
await page.click(byTestId('syntheticsMonitorManagementTab'));
await page.click(byTestId('syntheticsMonitorOverviewTab'));
await page.hover('text=Browser Monitor');
await page.click('[aria-label="Open actions menu"]');
await page.click('text=Edit monitor');
});
step('Opens flyout when in edit mode', async () => {
testRunId = '';
await page.click(byTestId('syntheticsRunTestBtn'));
await page.waitForSelector('text=Test results');
await page.waitForSelector('text=0 steps completed');
await page.waitForSelector('text=PENDING');
});
step('Displays results when successful in edit mode', async () => {
await retry.tryForTime(30 * 1000, async () => {
expect(testRunId?.length).toBe(36);
});
await page.waitForTimeout(1000);
await services.addTestSummaryDocument({ testRunId, docType: 'journeyStart' });
await page.waitForTimeout(1000);
await page.waitForSelector('text=Test private location');
await page.waitForSelector('text=IN PROGRESS');
});
step('Verifies that first step is populated', async () => {
await services.addTestSummaryDocument({ testRunId, docType: 'stepEnd', stepIndex: 1 });
await page.waitForSelector('text=1 step completed');
await page.waitForSelector('text=Go to https://www.google.com');
await page.waitForSelector('text=1.4 s');
await page.waitForSelector('text=Complete');
});
step('Verifies that second step is populated', async () => {
await services.addTestSummaryDocument({ testRunId, docType: 'stepEnd', stepIndex: 2 });
await retry.tryForTime(90 * 1000, async () => {
await page.waitForSelector('text=2 steps completed');
await page.waitForSelector('text="Go to step 2"');
await page.waitForSelector('text=788 ms');
await page.waitForSelector('text=IN PROGRESS');
});
});
step('Complete it with summary document', async () => {
await services.addTestSummaryDocument({ testRunId, docType: 'journeyEnd' });
await page.waitForSelector('text=COMPLETED');
await page.waitForSelector('text=took 2 s');
});
});

View file

@ -59,12 +59,18 @@ journey('StatusFlyoutInAlertingApp', async ({ page, params }) => {
await waitForLoadingToFinish({ page });
await page.click(byTestId('"xpack.synthetics.alerts.monitorStatus.filterBar"'));
await assertText({ page, text: 'browser' });
await assertText({ page, text: 'http' });
await retry.tryForTime(30 * 1000, async () => {
const suggestionItem = await page.$(byTestId('autoCompleteSuggestionText'));
expect(await suggestionItem?.textContent()).toBe('"browser" ');
await page.click('text=browser');
const element = await page.waitForSelector(
byTestId('"xpack.synthetics.alerts.monitorStatus.filterBar"')
);
expect((await element?.textContent())?.trim()).toBe('monitor.type : "browser"');
});
await page.click(byTestId('euiFlyoutCloseButton'));

View file

@ -151,6 +151,7 @@ Object.keys(configuration).forEach((type) => {
});
journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page; params: any }) => {
recordVideo(page);
const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl });
const defaultMonitorDetails = {
name: `Sample monitor ${uuid.v4()}`,
@ -212,6 +213,7 @@ journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page;
journey(
'MonitorManagement-case-insensitive sort',
async ({ page, params }: { page: Page; params: any }) => {
recordVideo(page);
const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl });
const sortedMonitors = [

View file

@ -6,7 +6,7 @@
*/
import { journey, step, expect } from '@elastic/synthetics';
import { byTestId, TIMEOUT_60_SEC } from '@kbn/observability-plugin/e2e/utils';
import { recordVideo } from '../../../helpers/record_video';
import { recordVideo } from '@kbn/observability-plugin/e2e/record_video';
import { monitorManagementPageProvider } from '../../../page_objects/uptime/monitor_management';
journey('ManagePrivateLocation', async ({ page, params: { kibanaUrl } }) => {
@ -14,8 +14,6 @@ journey('ManagePrivateLocation', async ({ page, params: { kibanaUrl } }) => {
const uptime = monitorManagementPageProvider({ page, kibanaUrl });
recordVideo(page);
step('Go to monitor-management', async () => {
await uptime.navigateToMonitorManagement();
});

View file

@ -71,9 +71,15 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
},
async navigateToAddMonitor() {
await page.goto(addMonitor, {
waitUntil: 'networkidle',
});
if (await page.isVisible('text=select a different monitor type', { timeout: 0 })) {
await page.click('text=select a different monitor type');
} else if (await page.isVisible('text=Create monitor', { timeout: 0 })) {
await page.click('text=Create monitor');
} else {
await page.goto(addMonitor, {
waitUntil: 'networkidle',
});
}
},
async ensureIsOnMonitorConfigPage() {
@ -89,7 +95,9 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
async deleteMonitors() {
let isSuccessful: boolean = false;
while (true) {
if ((await page.$(this.byTestId('euiCollapsedItemActionsButton'))) === null) {
if (
!(await page.isVisible(this.byTestId('euiCollapsedItemActionsButton'), { timeout: 0 }))
) {
isSuccessful = true;
break;
}

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiBasicTableColumn,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiHighlight,
EuiLink,
EuiSpacer,
EuiTitle,
formatDate,
} from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiInMemoryTable } from '@elastic/eui';
import moment from 'moment';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-plugin/public';
import { useStdErrorLogs } from './use_std_error_logs';
import { SYNTHETICS_INDEX_PATTERN } from '../../../../../../common/constants';
import { Ping } from '../../../../../../common/runtime_types';
import { ClientPluginsStart } from '../../../../../plugin';
export const StdErrorLogs = ({
monitorId,
checkGroup,
timestamp,
title,
summaryMessage,
hideTitle = false,
}: {
monitorId?: string;
checkGroup?: string;
timestamp?: string;
title?: string;
summaryMessage?: string;
hideTitle?: boolean;
}) => {
const columns = [
{
field: '@timestamp',
name: TIMESTAMP_LABEL,
sortable: true,
render: (date: string) => formatDate(date, 'dateTime'),
},
{
field: 'synthetics.payload.message',
name: 'Message',
render: (message: string) => (
<EuiHighlight
search={message.includes('SyntaxError:') ? 'SyntaxError:' : 'ReferenceError:'}
>
{message}
</EuiHighlight>
),
},
] as Array<EuiBasicTableColumn<Ping>>;
const { items, loading } = useStdErrorLogs({ monitorId, checkGroup });
const { discover, observability } = useKibana<ClientPluginsStart>().services;
const { data: discoverLink } = useFetcher(async () => {
const dataView = await observability.getAppDataView('synthetics', SYNTHETICS_INDEX_PATTERN);
return discover.locator?.getUrl({
query: { language: 'kuery', query: `monitor.check_group: ${checkGroup}` },
indexPatternId: dataView?.id,
columns: ['synthetics.payload.message', 'error.message'],
timeRange: timestamp
? {
from: moment(timestamp).subtract(10, 'minutes').toISOString(),
to: moment(timestamp).add(5, 'minutes').toISOString(),
}
: undefined,
});
}, [checkGroup, timestamp]);
const search = {
box: {
incremental: true,
},
};
return (
<>
{!hideTitle && (
<>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h3>{title ?? TEST_RUN_LOGS_LABEL}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink>
<EuiButtonEmpty
href={discoverLink}
iconType="discoverApp"
isDisabled={!discoverLink}
>
{VIEW_IN_DISCOVER_LABEL}
</EuiButtonEmpty>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiCallOut title={ERROR_SUMMARY_LABEL} color="danger" iconType="alert">
<p>{summaryMessage}</p>
</EuiCallOut>
</>
)}
<EuiSpacer />
<EuiInMemoryTable
items={items}
columns={columns}
tableLayout="auto"
loading={loading}
search={search}
itemId="id"
executeQueryOptions={{
defaultFields: ['@timestamp', 'synthetics.payload.message'],
}}
/>
</>
);
};
export const TIMESTAMP_LABEL = i18n.translate('xpack.synthetics.monitorList.timestamp', {
defaultMessage: 'Timestamp',
});
export const ERROR_SUMMARY_LABEL = i18n.translate('xpack.synthetics.monitorList.errorSummary', {
defaultMessage: 'Error summary',
});
export const VIEW_IN_DISCOVER_LABEL = i18n.translate(
'xpack.synthetics.monitorList.viewInDiscover',
{
defaultMessage: 'View in discover',
}
);
export const TEST_RUN_LOGS_LABEL = i18n.translate('xpack.synthetics.monitorList.testRunLogs', {
defaultMessage: 'Test run logs',
});

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createEsParams, useEsSearch } from '@kbn/observability-plugin/public';
import { Ping } from '../../../../../../common/runtime_types';
import { SYNTHETICS_INDEX_PATTERN } from '../../../../../../common/constants';
export const useStdErrorLogs = ({
monitorId,
checkGroup,
}: {
monitorId?: string;
checkGroup?: string;
}) => {
const { data, loading } = useEsSearch(
createEsParams({
index: !monitorId && !checkGroup ? '' : SYNTHETICS_INDEX_PATTERN,
body: {
size: 1000,
query: {
bool: {
filter: [
{
term: {
'synthetics.type': 'stderr',
},
},
...(monitorId
? [
{
term: {
'monitor.id': monitorId,
},
},
]
: []),
...(checkGroup
? [
{
term: {
'monitor.check_group': checkGroup,
},
},
]
: []),
],
},
},
},
}),
[],
{ name: 'getStdErrLogs' }
);
return {
items: data?.hits.hits.map((hit) => ({ ...(hit._source as Ping), id: hit._id })) ?? [],
loading,
};
};

View file

@ -9,6 +9,7 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescribedFormGroup, EuiPanel, EuiSpacer } from '@elastic/eui';
import { useFormContext, FieldError } from 'react-hook-form';
import styled from 'styled-components';
import { FORM_CONFIG } from '../form/form_config';
import { Field } from '../form/field';
import { ConfigKey, FormMonitorType } from '../types';
@ -31,11 +32,14 @@ export const AdvancedConfig = () => {
<EuiSpacer />
{FORM_CONFIG[type].advanced?.map((configGroup) => {
return (
<EuiDescribedFormGroup
<DescripedFormGroup
description={configGroup.description}
title={<h4>{configGroup.title}</h4>}
fullWidth
key={configGroup.title}
descriptionFlexItemProps={{ style: { minWidth: 200 } }}
fieldFlexItemProps={{ style: { minWidth: 500 } }}
style={{ flexWrap: 'wrap' }}
>
{configGroup.components.map((field) => {
return (
@ -46,10 +50,16 @@ export const AdvancedConfig = () => {
/>
);
})}
</EuiDescribedFormGroup>
</DescripedFormGroup>
);
})}
</EuiAccordion>
</EuiPanel>
) : null;
};
const DescripedFormGroup = styled(EuiDescribedFormGroup)`
> div.euiFlexGroup {
flex-wrap: wrap;
}
`;

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiButton, EuiToolTip } from '@elastic/eui';
import { useFormContext } from 'react-hook-form';
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import { useFetcher } from '@kbn/observability-plugin/public';
import { TestNowModeFlyout, TestRun } from '../../test_now_mode/test_now_mode_flyout';
import { format } from './formatter';
import {
Locations,
MonitorFields as MonitorFieldsType,
} from '../../../../../../common/runtime_types';
import { runOnceMonitor } from '../../../state/manual_test_runs/api';
export const RunTestButton = () => {
const { watch, formState, getValues } = useFormContext();
const [inProgress, setInProgress] = useState(false);
const [testRun, setTestRun] = useState<TestRun>();
const handleTestNow = () => {
const config = getValues() as MonitorFieldsType;
if (config) {
setInProgress(true);
setTestRun({
id: uuidv4(),
monitor: format(config) as MonitorFieldsType,
});
}
};
const {
data,
loading: isPushing,
error: serviceError,
} = useFetcher(() => {
// in case of test now mode outside of form add/edit, we don't need to trigger since it's already triggered
if (testRun?.id) {
return runOnceMonitor({
monitor: testRun.monitor,
id: testRun.id,
});
}
}, [testRun?.id]);
const locations = watch('locations') as Locations;
const { tooltipContent, isDisabled } = useTooltipContent(
locations,
formState.isValid,
inProgress
);
return (
<>
<EuiToolTip key={tooltipContent} content={tooltipContent}>
<EuiButton
data-test-subj="syntheticsRunTestBtn"
color="success"
disabled={isDisabled}
aria-label={TEST_NOW_ARIA_LABEL}
iconType="play"
onClick={() => {
handleTestNow();
}}
>
{RUN_TEST}
</EuiButton>
</EuiToolTip>
{testRun && (
<TestNowModeFlyout
serviceError={serviceError}
errors={data?.errors ?? []}
isPushing={Boolean(isPushing)}
testRun={testRun}
inProgress={inProgress}
onClose={() => {
setTestRun(undefined);
setInProgress(false);
}}
onDone={() => {
setInProgress(false);
}}
/>
)}
</>
);
};
const useTooltipContent = (
locations: Locations,
isValid: boolean,
isTestRunInProgress?: boolean
) => {
const isAnyPublicLocationSelected = locations?.some((loc) => loc.isServiceManaged);
const isOnlyPrivateLocations = (locations?.length ?? 0) > 0 && !isAnyPublicLocationSelected;
let tooltipContent =
isOnlyPrivateLocations || (isValid && !isAnyPublicLocationSelected)
? PRIVATE_AVAILABLE_LABEL
: TEST_NOW_DESCRIPTION;
tooltipContent = isTestRunInProgress ? TEST_SCHEDULED_LABEL : tooltipContent;
const isDisabled = !isValid || isTestRunInProgress || !isAnyPublicLocationSelected;
return { tooltipContent, isDisabled };
};
const TEST_NOW_DESCRIPTION = i18n.translate('xpack.synthetics.testRun.description', {
defaultMessage: 'Test your monitor and verify the results before saving',
});
const TEST_SCHEDULED_LABEL = i18n.translate('xpack.synthetics.monitorList.testNow.scheduled', {
defaultMessage: 'Test is already scheduled',
});
const PRIVATE_AVAILABLE_LABEL = i18n.translate(
'xpack.synthetics.monitorList.testNow.available.private',
{
defaultMessage: `You can't currently test monitors running on private locations on demand.`,
}
);
const TEST_NOW_ARIA_LABEL = i18n.translate('xpack.synthetics.monitorList.testNow.AriaLabel', {
defaultMessage: 'Click to run test now',
});
const RUN_TEST = i18n.translate('xpack.synthetics.monitorList.runTest.label', {
defaultMessage: 'Run test',
});

View file

@ -5,32 +5,26 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { Redirect, useParams, useHistory, useRouteMatch } from 'react-router-dom';
import React, { useState } from 'react';
import { Redirect, useParams, useHistory } from 'react-router-dom';
import { EuiButton, EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFormContext } from 'react-hook-form';
import { useFetcher, FETCH_STATUS } from '@kbn/observability-plugin/public';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { RunTestButton } from './run_test_btn';
import { useMonitorSave } from '../hooks/use_monitor_save';
import { DeleteMonitor } from '../../monitors_page/management/monitor_list_table/delete_monitor';
import { SyntheticsMonitor } from '../types';
import { ConfigKey, SourceType, SyntheticsMonitor } from '../types';
import { format } from './formatter';
import {
createMonitorAPI,
getMonitorAPI,
updateMonitorAPI,
} from '../../../state/monitor_management/api';
import { kibanaService } from '../../../../../utils/kibana_service';
import { MONITORS_ROUTE, MONITOR_EDIT_ROUTE } from '../../../../../../common/constants';
import { MONITORS_ROUTE } from '../../../../../../common/constants';
export const ActionBar = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const history = useHistory();
const editRouteMatch = useRouteMatch({ path: MONITOR_EDIT_ROUTE });
const isEdit = editRouteMatch?.isExact;
const {
handleSubmit,
formState: { errors },
formState: { errors, defaultValues },
} = useFormContext();
const [monitorPendingDeletion, setMonitorPendingDeletion] = useState<SyntheticsMonitor | null>(
@ -39,44 +33,7 @@ export const ActionBar = () => {
const [monitorData, setMonitorData] = useState<SyntheticsMonitor | undefined>(undefined);
const { data: monitorObject } = useFetcher(() => {
if (isEdit) {
return getMonitorAPI({ id: monitorId });
}
return undefined;
}, []);
const { data, status } = useFetcher(() => {
if (!monitorData) {
return null;
}
if (isEdit) {
return updateMonitorAPI({
id: monitorId,
monitor: monitorData,
});
} else {
return createMonitorAPI({
monitor: monitorData,
});
}
}, [monitorData]);
const loading = status === FETCH_STATUS.LOADING;
useEffect(() => {
if (status === FETCH_STATUS.FAILURE) {
kibanaService.toasts.addDanger({
title: MONITOR_FAILURE_LABEL,
toastLifeTimeMs: 3000,
});
} else if (status === FETCH_STATUS.SUCCESS && !loading) {
kibanaService.toasts.addSuccess({
title: monitorId ? MONITOR_UPDATED_SUCCESS_LABEL : MONITOR_SUCCESS_LABEL,
toastLifeTimeMs: 3000,
});
}
}, [data, status, monitorId, loading]);
const { status, loading, isEdit } = useMonitorSave({ monitorData });
const formSubmitter = (formData: Record<string, any>) => {
if (!Object.keys(errors).length) {
@ -90,12 +47,12 @@ export const ActionBar = () => {
<>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
{isEdit && monitorObject && (
{isEdit && defaultValues && (
<div>
<EuiButton
color="danger"
onClick={() => {
setMonitorPendingDeletion(monitorObject?.attributes);
setMonitorPendingDeletion(defaultValues as SyntheticsMonitor);
}}
>
{DELETE_MONITOR_LABEL}
@ -103,9 +60,13 @@ export const ActionBar = () => {
</div>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href={history.createHref({ pathname: MONITORS_ROUTE })}>{CANCEL_LABEL}</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RunTestButton />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
@ -118,13 +79,15 @@ export const ActionBar = () => {
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{monitorPendingDeletion && monitorObject && (
{monitorPendingDeletion && (
<DeleteMonitor
fields={monitorObject?.attributes}
configId={monitorId}
name={defaultValues?.[ConfigKey.NAME] ?? ''}
reloadPage={() => {
history.push(MONITORS_ROUTE);
}}
setMonitorPendingDeletion={setMonitorPendingDeletion}
isProjectMonitor={defaultValues?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT}
/>
)}
</>
@ -155,24 +118,3 @@ const UPDATE_MONITOR_LABEL = i18n.translate(
defaultMessage: 'Update monitor',
}
);
const MONITOR_SUCCESS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorAddedSuccessMessage',
{
defaultMessage: 'Monitor added successfully.',
}
);
const MONITOR_UPDATED_SUCCESS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorEditedSuccessMessage',
{
defaultMessage: 'Monitor updated successfully.',
}
);
const MONITOR_FAILURE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorFailureMessage',
{
defaultMessage: 'Monitor was unable to be saved. Please try again later.',
}
);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public';
import { useParams, useRouteMatch } from 'react-router-dom';
import { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { MONITOR_EDIT_ROUTE } from '../../../../../../common/constants';
import { SyntheticsMonitor } from '../../../../../../common/runtime_types';
import { createMonitorAPI, updateMonitorAPI } from '../../../state/monitor_management/api';
import { kibanaService } from '../../../../../utils/kibana_service';
export const useMonitorSave = ({ monitorData }: { monitorData?: SyntheticsMonitor }) => {
const { monitorId } = useParams<{ monitorId: string }>();
const editRouteMatch = useRouteMatch({ path: MONITOR_EDIT_ROUTE });
const isEdit = editRouteMatch?.isExact;
const { data, status, loading } = useFetcher(() => {
if (monitorData) {
if (isEdit) {
return updateMonitorAPI({
id: monitorId,
monitor: monitorData,
});
} else {
return createMonitorAPI({
monitor: monitorData,
});
}
}
}, [monitorData]);
useEffect(() => {
if (status === FETCH_STATUS.FAILURE) {
kibanaService.toasts.addDanger({
title: MONITOR_FAILURE_LABEL,
toastLifeTimeMs: 3000,
});
} else if (status === FETCH_STATUS.SUCCESS && !loading) {
kibanaService.toasts.addSuccess({
title: monitorId ? MONITOR_UPDATED_SUCCESS_LABEL : MONITOR_SUCCESS_LABEL,
toastLifeTimeMs: 3000,
});
}
}, [data, status, monitorId, loading]);
return { status, loading, isEdit };
};
const MONITOR_SUCCESS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorAddedSuccessMessage',
{
defaultMessage: 'Monitor added successfully.',
}
);
const MONITOR_UPDATED_SUCCESS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorEditedSuccessMessage',
{
defaultMessage: 'Monitor updated successfully.',
}
);
const MONITOR_FAILURE_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.monitorFailureMessage',
{
defaultMessage: 'Monitor was unable to be saved. Please try again later.',
}
);

View file

@ -6,7 +6,6 @@
*/
import React, { useEffect } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { useTrackPageview } from '@kbn/observability-plugin/public';
@ -21,7 +20,7 @@ import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs';
const MonitorAddPage = () => {
useTrackPageview({ app: 'synthetics', path: 'add-monitor' });
const { space, loading, error } = useKibanaSpace();
const { space } = useKibanaSpace();
useTrackPageview({ app: 'synthetics', path: 'add-monitor', delay: 15000 });
useMonitorAddEditBreadcrumbs();
const dispatch = useDispatch();
@ -30,12 +29,10 @@ const MonitorAddPage = () => {
dispatch(getServiceLocations());
}, [dispatch]);
return !loading && !error ? (
return (
<MonitorForm space={space?.id}>
<MonitorSteps stepMap={ADD_MONITOR_STEPS} />
</MonitorForm>
) : (
<EuiLoadingSpinner />
);
};

View file

@ -6,10 +6,10 @@
*/
import React, { useEffect } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { useParams } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useTrackPageview, useFetcher } from '@kbn/observability-plugin/public';
import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_flyout';
import { ConfigKey } from '../../../../../common/runtime_types';
import { getServiceLocations } from '../../state';
import { ServiceAllowedWrapper } from '../common/wrappers/service_allowed_wrapper';
@ -44,7 +44,7 @@ const MonitorEditPage: React.FC = () => {
/>
</MonitorForm>
) : (
<EuiLoadingSpinner />
<LoadingState />
);
};

View file

@ -15,11 +15,11 @@ interface Props {
export const Step = ({ description, children }: Props) => {
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" wrap>
<EuiFlexItem style={{ minWidth: 200 }}>
<EuiText>{description}</EuiText>
</EuiFlexItem>
<EuiFlexItem>{children}</EuiFlexItem>
<EuiFlexItem style={{ minWidth: 500 }}>{children}</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -12,29 +12,23 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
ConfigKey,
EncryptedSyntheticsSavedMonitor,
SourceType,
SyntheticsMonitor,
} from '../../../../../../../common/runtime_types';
import { fetchDeleteMonitor } from '../../../../state';
import { kibanaService } from '../../../../../../utils/kibana_service';
import * as labels from './labels';
export const DeleteMonitor = ({
fields,
name,
reloadPage,
configId,
isProjectMonitor,
setMonitorPendingDeletion,
}: {
fields: SyntheticsMonitor | EncryptedSyntheticsSavedMonitor;
configId: string;
name: string;
isProjectMonitor: boolean;
reloadPage: () => void;
setMonitorPendingDeletion: (val: null) => void;
}) => {
const configId = fields[ConfigKey.CONFIG_ID];
const name = fields[ConfigKey.NAME];
const isProjectMonitor = fields[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT;
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const handleConfirmDelete = () => {

View file

@ -24,6 +24,7 @@ import {
ConfigKey,
EncryptedSyntheticsSavedMonitor,
OverviewStatusState,
SourceType,
} from '../../../../../../../common/runtime_types';
import { useMonitorListColumns } from './columns';
import * as labels from './labels';
@ -128,9 +129,13 @@ export const MonitorList = ({
</EuiPanel>
{monitorPendingDeletion && (
<DeleteMonitor
fields={monitorPendingDeletion}
reloadPage={reloadPage}
configId={monitorPendingDeletion[ConfigKey.CONFIG_ID]}
name={monitorPendingDeletion[ConfigKey.NAME] ?? ''}
setMonitorPendingDeletion={setMonitorPendingDeletion}
isProjectMonitor={
monitorPendingDeletion[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT
}
reloadPage={reloadPage}
/>
)}
</>

View file

@ -15,8 +15,12 @@ import {
EuiLoadingSpinner,
} from '@elastic/eui';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import {
manualTestMonitorAction,
manualTestRunInProgressSelector,
} from '../../../../state/manual_test_runs';
import { toggleStatusAlert } from '../../../../../../../common/runtime_types/monitor_management/alert_config';
import { useSelectedMonitor } from '../../../monitor_details/hooks/use_selected_monitor';
import { useMonitorAlertEnable } from '../../../../hooks/use_monitor_alert_enable';
@ -124,6 +128,8 @@ export function ActionsPopover({
monitor.isEnabled ? disableMonitorLabel : enableMonitorLabel
);
const testInProgress = useSelector(manualTestRunInProgressSelector(monitor.configId));
useEffect(() => {
if (status === FETCH_STATUS.LOADING) {
setEnableLabel(loadingLabel(monitor.isEnabled));
@ -140,7 +146,12 @@ export function ActionsPopover({
onClick: () => {
if (locationName) {
dispatch(
setFlyoutConfig({ configId: monitor.configId, location: locationName, id: monitor.id })
setFlyoutConfig({
configId: monitor.configId,
location: locationName,
id: monitor.id,
locationId: monitor.location.id,
})
);
setIsPopoverOpen(false);
}
@ -156,12 +167,16 @@ export function ActionsPopover({
href: detailUrl,
},
quickInspectPopoverItem,
// not rendering this for now because the manual test flyout is
// still in the design phase
// {
// name: 'Run test manually',
// icon: 'beaker',
// },
{
name: runTestManually,
icon: 'beaker',
disabled: testInProgress,
onClick: () => {
dispatch(manualTestMonitorAction.get(monitor.configId));
dispatch(setFlyoutConfig(null));
setIsPopoverOpen(false);
},
},
{
name: actionsMenuEditMonitorName,
icon: 'pencil',
@ -212,6 +227,7 @@ export function ActionsPopover({
size={iconSize}
display="empty"
onClick={() => setIsPopoverOpen((b: boolean) => !b)}
title={openActionsMenuAria}
/>
</IconPanel>
}
@ -240,6 +256,10 @@ const quickInspectName = i18n.translate('xpack.synthetics.overview.actions.quick
defaultMessage: 'Quick inspect',
});
const runTestManually = i18n.translate('xpack.synthetics.overview.actions.runTestManually.title', {
defaultMessage: 'Run test manually',
});
const openActionsMenuAria = i18n.translate(
'xpack.synthetics.overview.actions.openPopover.ariaLabel',
{

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as React from 'react';
import { EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import { useSelector } from 'react-redux';
import { manualTestRunSelector } from '../../../../state/manual_test_runs';
export const ManualTestRunProgress = ({ configId }: { configId: string }) => {
const testNowRun = useSelector(manualTestRunSelector(configId));
const inProgress = testNowRun?.status === 'in-progress' || testNowRun?.status === 'loading';
return inProgress ? (
<EuiToolTip position="top" content="Test is in progress">
<EuiLoadingSpinner />
</EuiToolTip>
) : null;
};

View file

@ -10,11 +10,19 @@ import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts';
import { EuiPanel } from '@elastic/eui';
import { DARK_THEME } from '@elastic/charts';
import { useTheme } from '@kbn/observability-plugin/public';
import { useDispatch, useSelector } from 'react-redux';
import moment from 'moment';
import { ManualTestRunProgress } from './manual_run_progress';
import { useLocationName, useStatusByLocationOverview } from '../../../../hooks';
import { formatDuration } from '../../../../utils/formatting';
import { MonitorOverviewItem } from '../../../../../../../common/runtime_types';
import { ActionsPopover } from './actions_popover';
import { OverviewGridItemLoader } from './overview_grid_item_loader';
import {
hideTestNowFlyoutAction,
manualTestRunInProgressSelector,
toggleTestNowFlyoutAction,
} from '../../../../state/manual_test_runs';
export const getColor = (
theme: ReturnType<typeof useTheme>,
@ -47,14 +55,18 @@ export const MetricItem = ({
data: Array<{ x: number; y: number }>;
averageDuration: number;
loaded: boolean;
onClick: (params: { id: string; configId: string; location: string }) => void;
onClick: (params: { id: string; configId: string; location: string; locationId: string }) => void;
}) => {
const [isMouseOver, setIsMouseOver] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const locationName = useLocationName({ locationId: monitor.location?.id });
const status = useStatusByLocationOverview(monitor.id, locationName);
const { status, timestamp } = useStatusByLocationOverview(monitor.id, locationName);
const theme = useTheme();
const testInProgress = useSelector(manualTestRunInProgressSelector(monitor.configId));
const dispatch = useDispatch();
return (
<div data-test-subj={`${monitor.name}-metric-item`} style={{ height: '160px' }}>
{loaded ? (
@ -74,14 +86,25 @@ export const MetricItem = ({
height: '100%',
overflow: 'hidden',
}}
title={moment(timestamp).format('LLL')}
>
<Chart>
<Settings
onElementClick={() =>
monitor.configId &&
locationName &&
onClick({ configId: monitor.configId, id: monitor.id, location: locationName })
}
onElementClick={() => {
if (testInProgress) {
dispatch(toggleTestNowFlyoutAction(monitor.configId));
} else {
dispatch(hideTestNowFlyoutAction());
}
if (!testInProgress && locationName) {
onClick({
configId: monitor.configId,
id: monitor.id,
location: locationName,
locationId: monitor.location?.id,
});
}
}}
baseTheme={DARK_THEME}
/>
<Metric
@ -89,6 +112,7 @@ export const MetricItem = ({
data={[
[
{
icon: () => <ManualTestRunProgress configId={monitor.configId} />,
title: monitor.name,
subtitle: locationName,
value: averageDuration,

View file

@ -53,6 +53,7 @@ describe('Monitor Detail Flyout', () => {
configId="test-id"
id="test-id"
location="US East"
locationId="us-east"
onClose={onCloseMock}
onEnabledChange={jest.fn()}
onLocationChange={jest.fn()}
@ -76,6 +77,7 @@ describe('Monitor Detail Flyout', () => {
configId="test-id"
id="test-id"
location="US East"
locationId="us-east"
onClose={jest.fn()}
onEnabledChange={jest.fn()}
onLocationChange={jest.fn()}
@ -95,6 +97,7 @@ describe('Monitor Detail Flyout', () => {
configId="test-id"
id="test-id"
location="US East"
locationId="us-east"
onClose={jest.fn()}
onEnabledChange={jest.fn()}
onLocationChange={jest.fn()}
@ -130,6 +133,7 @@ describe('Monitor Detail Flyout', () => {
configId="test-id"
id="test-id"
location="US East"
locationId="us-east"
onClose={jest.fn()}
onEnabledChange={jest.fn()}
onLocationChange={jest.fn()}

View file

@ -49,7 +49,7 @@ import { ClientPluginsStart } from '../../../../../../plugin';
import { useStatusByLocation } from '../../../../hooks/use_status_by_location';
import { MonitorEnabled } from '../../management/monitor_list_table/monitor_enabled';
import { ActionsPopover } from './actions_popover';
import { selectOverviewState } from '../../../../state';
import { selectOverviewState, selectServiceLocationsState } from '../../../../state';
import { useMonitorDetail } from '../../../../hooks/use_monitor_detail';
import {
ConfigKey,
@ -65,9 +65,15 @@ interface Props {
configId: string;
id: string;
location: string;
locationId: string;
onClose: () => void;
onEnabledChange: () => void;
onLocationChange: (params: { configId: string; id: string; location: string }) => void;
onLocationChange: (params: {
configId: string;
id: string;
location: string;
locationId: string;
}) => void;
currentDurationChartFrom?: string;
currentDurationChartTo?: string;
previousDurationChartFrom?: string;
@ -174,11 +180,13 @@ function LocationSelect({
configId: string;
monitor: EncryptedSyntheticsMonitor;
onEnabledChange: () => void;
setCurrentLocation: (location: string) => void;
setCurrentLocation: (location: string, locationId: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const status = locations.find((l) => l.observer?.geo?.name === currentLocation)?.monitor?.status;
const { locations: allLocations } = useSelector(selectServiceLocationsState);
return (
<EuiFlexGroup wrap={true} responsive={false}>
<EuiFlexItem grow={false}>
@ -213,13 +221,14 @@ function LocationSelect({
id: 0,
title: GO_TO_LOCATIONS_LABEL,
items: locations.map((l) => {
const loc = allLocations.find((ll) => ll.label === l.observer?.geo?.name);
return {
name: l.observer?.geo?.name,
icon: <EuiHealth color={!!l.summary?.down ? 'danger' : 'success'} />,
disabled: !l.observer?.geo?.name || l.observer.geo.name === currentLocation,
onClick: () => {
if (l.observer?.geo?.name && currentLocation !== l.observer.geo.name)
setCurrentLocation(l.observer?.geo?.name);
setCurrentLocation(l.observer?.geo?.name, loc?.id!);
},
};
}),
@ -256,7 +265,7 @@ export function LoadingState() {
}
export function MonitorDetailFlyout(props: Props) {
const { id, configId, onLocationChange } = props;
const { id, configId, onLocationChange, locationId } = props;
const {
data: { monitors },
} = useSelector(selectOverviewState);
@ -267,12 +276,14 @@ export function MonitorDetailFlyout(props: Props) {
}, [id, monitors]);
const setLocation = useCallback(
(location: string) => onLocationChange({ id, configId, location }),
(location: string, locationIdT: string) =>
onLocationChange({ id, configId, location, locationId: locationIdT }),
[id, configId, onLocationChange]
);
const detailLink = useMonitorDetailLocator({
configId: id,
locationId,
});
const {

View file

@ -58,8 +58,17 @@ export const OverviewGrid = memo(() => {
const dispatch = useDispatch();
const setFlyoutConfigCallback = useCallback(
({ configId, id, location }: { configId: string; id: string; location: string }) =>
dispatch(setFlyoutConfig({ configId, id, location })),
({
configId,
id,
location,
locationId,
}: {
configId: string;
id: string;
location: string;
locationId: string;
}) => dispatch(setFlyoutConfig({ configId, id, location, locationId })),
[dispatch]
);
const hideFlyout = useCallback(() => dispatch(setFlyoutConfig(null)), [dispatch]);
@ -159,6 +168,7 @@ export const OverviewGrid = memo(() => {
configId={flyoutConfig.configId}
id={flyoutConfig.id}
location={flyoutConfig.location}
locationId={flyoutConfig.locationId}
onClose={hideFlyout}
onEnabledChange={forceRefreshCallback}
onLocationChange={setFlyoutConfigCallback}

View file

@ -14,7 +14,7 @@ export const OverviewGridItem = ({
onClick,
}: {
monitor: MonitorOverviewItem;
onClick: (params: { id: string; configId: string; location: string }) => void;
onClick: (params: { id: string; configId: string; location: string; locationId: string }) => void;
}) => {
const { data, loading, averageDuration } = useLast50DurationChart({
locationId: monitor.location?.id,

View file

@ -14,7 +14,7 @@ describe('useLocationMonitors', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useLocationMonitors(), { wrapper: WrappedHelper });
expect(result.current).toStrictEqual({ locationMonitors: [], loading: true });
expect(result.current).toStrictEqual({ locationMonitors: [], loading: false });
expect(defaultCore.savedObjects.client.find).toHaveBeenCalledWith({
aggs: {
locations: {

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import * as React from 'react';
import { EuiAccordion, EuiText, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { BrowserStepsList } from '../../common/monitor_test_result/browser_steps_list';
import {
CheckGroupResult,
useBrowserRunOnceMonitors,
} from '../hooks/use_browser_run_once_monitors';
import { TestResultHeader } from '../test_result_header';
import { StdErrorLogs } from '../../common/components/stderr_logs';
interface Props {
testRunId: string;
expectPings: number;
onDone: (testRunId: string) => void;
}
export const BrowserTestRunResult = ({ expectPings, onDone, testRunId }: Props) => {
const { summariesLoading, expectedSummariesLoaded, stepLoadingInProgress, checkGroupResults } =
useBrowserRunOnceMonitors({
testRunId,
expectSummaryDocs: expectPings,
});
useEffect(() => {
if (expectedSummariesLoaded) {
onDone(testRunId);
}
}, [onDone, expectedSummariesLoaded, testRunId]);
return (
<>
{checkGroupResults.map((checkGroupResult) => {
const { checkGroupId, journeyStarted, summaryDoc, stepsLoading, steps, completedSteps } =
checkGroupResult;
const isStepsLoading = !summariesLoading && journeyStarted && summaryDoc && stepsLoading;
const isStepsLoadingFailed =
summaryDoc && !summariesLoading && !stepLoadingInProgress && steps.length === 0;
return (
<AccordionWrapper
key={'accordion-' + checkGroupId}
id={'accordion-' + checkGroupId}
element="fieldset"
className="euiAccordionForm"
buttonClassName="euiAccordionForm__button"
buttonContent={getButtonContent(checkGroupResult)}
paddingSize="s"
data-test-subj="expandResults"
initialIsOpen={checkGroupResults.length === 1}
>
{isStepsLoading && (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiText>{LOADING_STEPS}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
</EuiFlexGroup>
)}
{isStepsLoadingFailed && (
<EuiText color="danger">{summaryDoc?.error?.message ?? FAILED_TO_RUN}</EuiText>
)}
{isStepsLoadingFailed &&
summaryDoc?.error?.message?.includes('journey did not finish executing') && (
<StdErrorLogs checkGroup={summaryDoc.monitor.check_group} hideTitle={true} />
)}
{completedSteps > 0 && (
<BrowserStepsList
steps={steps}
loading={Boolean(stepLoadingInProgress)}
error={undefined}
showStepNumber={true}
compressed={true}
/>
)}
</AccordionWrapper>
);
})}
</>
);
};
const AccordionWrapper = styled(EuiAccordion)`
.euiAccordion__buttonContent {
width: 100%;
}
`;
function getButtonContent({
journeyDoc,
summaryDoc,
checkGroupId,
journeyStarted,
completedSteps,
}: CheckGroupResult) {
return (
<div>
<TestResultHeader
title={journeyDoc?.observer?.geo?.name}
summaryDocs={summaryDoc ? [summaryDoc] : []}
checkGroupId={checkGroupId}
journeyStarted={journeyStarted}
isCompleted={Boolean(summaryDoc)}
configId={journeyDoc?.config_id}
/>
<EuiText size="s">
<p>
<EuiText color="subdued">
{i18n.translate('xpack.synthetics.monitorManagement.stepCompleted', {
defaultMessage:
'{stepCount, number} {stepCount, plural, one {step} other {steps}} completed',
values: {
stepCount: completedSteps ?? 0,
},
})}
</EuiText>
</p>
</EuiText>
</div>
);
}
const FAILED_TO_RUN = i18n.translate('xpack.synthetics.monitorManagement.failedRun', {
defaultMessage: 'Failed to run steps',
});
const LOADING_STEPS = i18n.translate('xpack.synthetics.monitorManagement.loadingSteps', {
defaultMessage: 'Loading steps...',
});

View file

@ -0,0 +1,271 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useState, useRef } from 'react';
import { createEsParams, useEsSearch, useFetcher } from '@kbn/observability-plugin/public';
import { useTickTick } from './use_tick_tick';
import { isStepEnd } from '../../common/monitor_test_result/browser_steps_list';
import { SYNTHETICS_INDEX_PATTERN } from '../../../../../../common/constants';
import { JourneyStep } from '../../../../../../common/runtime_types';
import { fetchBrowserJourney } from '../../../state';
export interface CheckGroupResult {
checkGroupId: string;
journeyStarted: boolean;
journeyDoc?: JourneyStep;
summaryDoc?: JourneyStep;
steps: JourneyStep[];
stepsLoading: boolean;
completedSteps: number;
}
export const useBrowserEsResults = ({
testRunId,
lastRefresh,
}: {
testRunId: string;
lastRefresh: number;
}) => {
return useEsSearch(
createEsParams({
index: SYNTHETICS_INDEX_PATTERN,
body: {
sort: [
{
'@timestamp': 'desc',
},
],
query: {
bool: {
filter: [
{
terms: {
'synthetics.type': ['heartbeat/summary', 'journey/start'],
},
},
{
term: {
test_run_id: testRunId,
},
},
],
},
},
},
size: 1000,
}),
[testRunId, lastRefresh],
{ name: 'TestRunData' }
);
};
export const useBrowserRunOnceMonitors = ({
testRunId,
skipDetails = false,
refresh = true,
expectSummaryDocs,
}: {
testRunId: string;
refresh?: boolean;
skipDetails?: boolean;
expectSummaryDocs: number;
}) => {
const { refreshTimer, lastRefresh } = useTickTick(5 * 1000, refresh);
const [checkGroupResults, setCheckGroupResults] = useState<CheckGroupResult[]>(() => {
return new Array(expectSummaryDocs)
.fill({
checkGroupId: '',
journeyStarted: false,
steps: [],
stepsLoading: false,
completedSteps: 0,
} as CheckGroupResult)
.map((emptyCheckGroup, index) => ({
...emptyCheckGroup,
checkGroupId: `placeholder-check-group-${index}`,
}));
});
const lastUpdated = useRef<{ checksum: string; time: number }>({
checksum: '',
time: Date.now(),
});
const { data, loading: summariesLoading } = useBrowserEsResults({
testRunId,
lastRefresh,
});
useEffect(() => {
const hits = data?.hits.hits;
if (hits && hits.length > 0) {
const allDocs = (hits ?? []).map(({ _source }) => _source as JourneyStep);
const checkGroupsById = allDocs
.filter(
(doc) =>
doc.synthetics?.type === 'journey/start' || doc.synthetics?.type === 'heartbeat/summary'
)
.reduce(
(acc, cur) => ({
...acc,
[cur.monitor.check_group]: {
checkGroupId: cur.monitor.check_group,
journeyStarted: true,
journeyDoc: cur,
summaryDoc: null,
steps: [],
stepsLoading: false,
completedSteps: 0,
},
}),
{}
) as Record<string, CheckGroupResult>;
allDocs.forEach((step) => {
if (step.synthetics?.type === 'heartbeat/summary') {
checkGroupsById[step.monitor.check_group].summaryDoc = step;
}
});
const checkGroups = Object.values(checkGroupsById);
const finishedCheckGroups = checkGroups.filter((group) => !!group.summaryDoc);
if (finishedCheckGroups.length >= expectSummaryDocs) {
clearInterval(refreshTimer);
}
replaceCheckGroupResults(checkGroups);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expectSummaryDocs, data, refreshTimer]);
// Loading steps for browser runs
const checkGroupIds = checkGroupResults.map(({ checkGroupId }) => checkGroupId);
const checkGroupCheckSum = checkGroupIds.reduce((acc, cur) => acc + cur, '');
const { loading: stepLoadingInProgress } = useFetcher(() => {
if (checkGroupIds.length && !skipDetails) {
setCheckGroupResults((prevState) => {
return prevState.map((result) => ({ ...result, stepsLoading: true }));
});
return Promise.all(
checkGroupIds.map((id) => {
return fetchBrowserJourney({
checkGroup: id,
})
.then((stepsData) => {
updateCheckGroupResult(stepsData.checkGroup, {
steps: stepsData.steps,
completedSteps: stepsData.steps.filter(isStepEnd).length,
});
return stepsData;
})
.finally(() => {
updateCheckGroupResult(id, {
stepsLoading: false,
});
});
})
);
}
return Promise.resolve(null);
}, [checkGroupCheckSum, setCheckGroupResults, lastRefresh]);
// Whenever a new found document is fetched, update lastUpdated
useEffect(() => {
const currentChecksum = getCheckGroupChecksum(checkGroupResults);
if (checkGroupCheckSum !== lastUpdated.current.checksum) {
// Mutating lastUpdated
lastUpdated.current.checksum = currentChecksum;
lastUpdated.current.time = Date.now();
}
}, [checkGroupResults, checkGroupCheckSum]);
const updateCheckGroupResult = (id: string, result: Partial<CheckGroupResult>) => {
setCheckGroupResults((prevState) => {
return prevState.map((r) => {
if (id !== r.checkGroupId) {
return r;
}
return mergeCheckGroups(r, result);
}) as CheckGroupResult[];
});
};
const replaceCheckGroupResults = (curCheckGroups: CheckGroupResult[]) => {
const emptyCheckGroups = checkGroupResults.filter((group) =>
group.checkGroupId.startsWith('placeholder-check-group')
);
// Padding the collection with placeholders so that rows could be shown on UI with loading state
const paddedCheckGroups =
curCheckGroups.length < expectSummaryDocs
? [
...curCheckGroups,
...emptyCheckGroups.slice(-1 * (expectSummaryDocs - curCheckGroups.length)),
]
: curCheckGroups;
setCheckGroupResults((prevCheckGroups) => {
const newIds = paddedCheckGroups.map(({ checkGroupId }) => checkGroupId);
const newById: Record<string, CheckGroupResult> = paddedCheckGroups.reduce(
(acc, cur) => ({ ...acc, [cur.checkGroupId]: cur }),
{}
);
const oldById: Record<string, CheckGroupResult> = prevCheckGroups.reduce(
(acc, cur) => ({ ...acc, [cur.checkGroupId]: cur }),
{}
);
return newIds.map((id) => mergeCheckGroups(oldById[id], newById[id]));
});
};
return {
data,
summariesLoading,
stepLoadingInProgress,
expectedSummariesLoaded:
checkGroupResults.filter(({ summaryDoc }) => !!summaryDoc).length >= expectSummaryDocs,
checkGroupResults,
lastUpdated: lastUpdated.current.time,
};
};
function mergeCheckGroups(prev: CheckGroupResult, curr: Partial<CheckGroupResult>) {
// Once completed steps has been determined and shown, don't lower the number on UI due to re-fetch
const completedSteps = curr.completedSteps
? Math.max(prev?.completedSteps ?? 0, curr.completedSteps ?? 0)
: prev?.completedSteps ?? 0;
let steps = curr.steps ?? [];
if (steps.length === 0 && (prev?.steps ?? []).length > 0) {
steps = prev.steps;
}
return {
...(prev ?? {}),
...curr,
steps,
completedSteps,
};
}
function getCheckGroupChecksum(checkGroupResults: CheckGroupResult[]) {
return checkGroupResults.reduce((acc, cur) => {
return (
acc + cur?.journeyDoc?._id ??
'' + cur?.summaryDoc?._id ??
'' + (cur?.steps ?? []).reduce((stepAcc, { _id }) => stepAcc + _id, '')
);
}, '');
}

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { kibanaService } from '../../../../../utils/kibana_service';
import { Locations, ServiceLocationErrors } from '../../monitor_add_edit/types';
export function useRunOnceErrors({
testRunId,
serviceError,
errors,
locations,
}: {
testRunId: string;
serviceError?: Error;
errors: ServiceLocationErrors;
locations: Locations;
}) {
const [locationErrors, setLocationErrors] = useState<ServiceLocationErrors>([]);
const [runOnceServiceError, setRunOnceServiceError] = useState<Error | undefined | null>(null);
const publicLocations = useMemo(
() => (locations ?? []).filter((loc) => loc.isServiceManaged),
[locations]
);
useEffect(() => {
setLocationErrors([]);
setRunOnceServiceError(null);
}, [testRunId]);
useEffect(() => {
if (locationErrors.length || errors.length) {
setLocationErrors(errors);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [errors]);
useEffect(() => {
if (runOnceServiceError?.message !== serviceError?.message) {
setRunOnceServiceError(serviceError);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serviceError]);
const locationsById: Record<string, Locations[number]> = useMemo(
() => (publicLocations as Locations).reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}),
[publicLocations]
);
const expectPings =
publicLocations.length - (locationErrors ?? []).filter(({ locationId }) => !!locationId).length;
const hasBlockingError =
!!runOnceServiceError ||
(locationErrors?.length && locationErrors?.length === publicLocations.length);
const errorMessages = useMemo(() => {
if (hasBlockingError) {
return [{ name: 'Error', message: PushErrorService, title: PushErrorLabel }];
} else if (locationErrors?.length > 0) {
// If only some of the locations were unsuccessful
return locationErrors
.map(({ locationId }) => locationsById[locationId])
.filter((location) => !!location)
.map((location) => ({
name: 'Error',
message: getLocationTestErrorLabel(location.label),
title: RunErrorLabel,
}));
}
return [];
}, [locationsById, locationErrors, hasBlockingError]);
useEffect(() => {
errorMessages.forEach(
({ name, message, title }: { name: string; message: string; title: string }) => {
kibanaService.toasts.addError({ name, message }, { title });
}
);
}, [errorMessages]);
return {
expectPings,
hasBlockingError,
blockingErrorMessage: hasBlockingError ? PushErrorService : null,
errorMessages,
};
}
const PushErrorLabel = i18n.translate('xpack.synthetics.testRun.pushErrorLabel', {
defaultMessage: 'Push error',
});
const RunErrorLabel = i18n.translate('xpack.synthetics.testRun.runErrorLabel', {
defaultMessage: 'Error running test',
});
const getLocationTestErrorLabel = (locationName: string) =>
i18n.translate('xpack.synthetics.testRun.runErrorLocation', {
defaultMessage: 'Failed to run monitor on location {locationName}.',
values: { locationName },
});
const PushErrorService = i18n.translate('xpack.synthetics.testRun.pushError', {
defaultMessage: 'Failed to push the monitor to service.',
});

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo, useRef } from 'react';
import { createEsParams, useEsSearch } from '@kbn/observability-plugin/public';
import { SUMMARY_FILTER } from '../../../../../../common/constants/client_defaults';
import { Ping } from '../../../../../../common/runtime_types';
import { SYNTHETICS_INDEX_PATTERN } from '../../../../../../common/constants';
import { useTickTick } from './use_tick_tick';
export const useSimpleRunOnceMonitors = ({
expectSummaryDocs,
testRunId,
}: {
expectSummaryDocs: number;
testRunId: string;
}) => {
const { refreshTimer, lastRefresh } = useTickTick(2 * 1000, false);
const { data, loading } = useEsSearch(
createEsParams({
index: SYNTHETICS_INDEX_PATTERN,
body: {
sort: [
{
'@timestamp': 'desc',
},
],
query: {
bool: {
filter: [
SUMMARY_FILTER,
{
term: {
test_run_id: testRunId,
},
},
],
},
},
},
size: 1000,
}),
[testRunId, lastRefresh],
{ name: 'TestRunData' }
);
const lastUpdated = useRef<{ checksum: string; time: number }>({
checksum: '',
time: Date.now(),
});
return useMemo(() => {
const docs = data?.hits.hits ?? [];
// Whenever a new found document is fetched, update lastUpdated
const docsChecksum = docs
.map(({ _id }: { _id: string }) => _id)
.reduce((acc, cur) => acc + cur, '');
if (docsChecksum !== lastUpdated.current.checksum) {
// Mutating lastUpdated
lastUpdated.current.checksum = docsChecksum;
lastUpdated.current.time = Date.now();
}
if (docs.length > 0) {
if (docs.length >= expectSummaryDocs) {
clearInterval(refreshTimer);
}
return {
data,
loading,
summaryDocs: docs.map((doc) => ({
...(doc._source as Ping),
timestamp: (doc._source as Record<string, string>)?.['@timestamp'],
docId: doc._id,
})),
lastUpdated: lastUpdated.current.time,
};
}
return {
data,
loading,
summaryDocs: null,
lastUpdated: lastUpdated.current.time,
};
}, [expectSummaryDocs, data, loading, refreshTimer]);
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router-dom';
import { useEffect } from 'react';
import { OVERVIEW_ROUTE } from '../../../../../../common/constants';
import { hideTestNowFlyoutAction, testNowRunsSelector } from '../../../state/manual_test_runs';
export const useTestFlyoutOpen = () => {
const testNowRuns = useSelector(testNowRunsSelector);
const isOverview = useRouteMatch({
path: [OVERVIEW_ROUTE],
});
const dispatch = useDispatch();
const flyoutTestOpen = Object.values(testNowRuns).find((value) => {
return value.isTestNowFlyoutOpen;
});
useEffect(() => {
if (!isOverview?.isExact && flyoutTestOpen) {
dispatch(hideTestNowFlyoutAction());
}
});
return flyoutTestOpen;
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useState, useContext } from 'react';
import { SyntheticsRefreshContext } from '../../../contexts';
export function useTickTick(interval?: number, refresh = true) {
const { refreshApp } = useContext(SyntheticsRefreshContext);
const [nextTick, setNextTick] = useState(Date.now());
const [tickTick] = useState<NodeJS.Timer>(() =>
setInterval(() => {
if (refresh) {
refreshApp();
}
setNextTick(Date.now());
}, interval ?? 5 * 1000)
);
useEffect(() => {
return () => {
clearInterval(tickTick);
};
}, [tickTick]);
return { refreshTimer: tickTick, lastRefresh: nextTick };
}

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { useBrowserRunOnceMonitors } from '../hooks/use_browser_run_once_monitors';
interface Props {
testRunId: string;
expectPings: number;
onDone: (testRunId: string) => void;
onProgress: (message: string) => void;
}
export const BrowserTestRunResult = ({ expectPings, onDone, testRunId, onProgress }: Props) => {
const { summariesLoading, expectedSummariesLoaded, stepLoadingInProgress, checkGroupResults } =
useBrowserRunOnceMonitors({
testRunId,
expectSummaryDocs: expectPings,
});
useEffect(() => {
if (expectedSummariesLoaded) {
onDone(testRunId);
}
}, [onDone, expectedSummariesLoaded, testRunId]);
return (
<>
{checkGroupResults.map((checkGroupResult) => {
const { checkGroupId, journeyStarted, summaryDoc, stepsLoading, steps, completedSteps } =
checkGroupResult;
const isStepsLoading = !summariesLoading && journeyStarted && summaryDoc && stepsLoading;
const isStepsLoadingFailed =
summaryDoc && !summariesLoading && !stepLoadingInProgress && steps.length === 0;
if (completedSteps > 0) {
onProgress(
i18n.translate('xpack.synthetics.monitorManagement.stepCompleted', {
defaultMessage:
'{stepCount, number} {stepCount, plural, one {step} other {steps}} completed',
values: {
stepCount: completedSteps ?? 0,
},
})
);
}
if (isStepsLoading) {
onProgress(LOADING_STEPS);
}
if (isStepsLoadingFailed) {
// TODO: Add error toast
onProgress(summaryDoc?.error?.message ?? FAILED_TO_RUN);
// <EuiText color="danger">{summaryDoc?.error?.message ?? FAILED_TO_RUN}</EuiText>
}
if (
isStepsLoadingFailed &&
summaryDoc?.error?.message?.includes('journey did not finish executing')
) {
// TODO: Add error toast
// <StdErrorLogs checkGroup={summaryDoc.monitor.check_group} hideTitle={true} />;
}
return <Fragment key={'accordion-' + checkGroupId} />;
})}
</>
);
};
const FAILED_TO_RUN = i18n.translate('xpack.synthetics.monitorManagement.failedRun', {
defaultMessage: 'Failed to run steps',
});
const LOADING_STEPS = i18n.translate('xpack.synthetics.monitorManagement.loadingSteps', {
defaultMessage: 'Loading steps...',
});

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment } from 'react';
import { useRunOnceErrors } from '../hooks/use_run_once_errors';
import { ManualTestRun } from '../../../state/manual_test_runs';
import { BrowserTestRunResult } from './browser_test_results';
import { SimpleTestResults } from './simple_test_results';
import { Locations } from '../../../../../../common/runtime_types';
export function ManualTestRunMode({
manualTestRun,
onDone,
}: {
manualTestRun: ManualTestRun;
onDone: (testRunId: string) => void;
}) {
const { expectPings } = useRunOnceErrors({
testRunId: manualTestRun.testRunId!,
locations: (manualTestRun.monitor!.locations ?? []) as Locations,
errors: manualTestRun.errors ?? [],
});
if (!manualTestRun.testRunId || !manualTestRun.monitor) return null;
const isBrowserMonitor = manualTestRun.monitor.type === 'browser';
return (
<Fragment key={manualTestRun.testRunId}>
{isBrowserMonitor ? (
<BrowserTestRunResult
expectPings={expectPings}
onDone={onDone}
testRunId={manualTestRun.testRunId}
onProgress={() => {}}
/>
) : (
<SimpleTestResults
expectPings={expectPings}
onDone={onDone}
testRunId={manualTestRun.testRunId}
/>
)}
</Fragment>
);
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { useSimpleRunOnceMonitors } from '../hooks/use_simple_run_once_monitors';
interface Props {
testRunId: string;
expectPings: number;
onDone: (testRunId: string) => void;
}
export function SimpleTestResults({ testRunId, expectPings, onDone }: Props) {
const { summaryDocs } = useSimpleRunOnceMonitors({
testRunId,
expectSummaryDocs: expectPings,
});
useEffect(() => {
if (summaryDocs) {
if (summaryDocs.length >= expectPings) {
onDone(testRunId);
}
}
}, [testRunId, expectPings, summaryDocs, onDone]);
return <></>;
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon } from '@elastic/eui';
import { Ping } from '../../../../../../../../common/runtime_types';
import { PingListExpandedRowComponent } from '../expanded_row';
export const toggleDetails = (
ping: Ping,
expandedRows: Record<string, JSX.Element>,
setExpandedRows: (update: Record<string, JSX.Element>) => any
) => {
// If already expanded, collapse
if (expandedRows[ping.docId]) {
delete expandedRows[ping.docId];
setExpandedRows({ ...expandedRows });
return;
}
// Otherwise expand this row
setExpandedRows({
...expandedRows,
[ping.docId]: <PingListExpandedRowComponent ping={ping} />,
});
};
export function rowShouldExpand(item: Ping) {
const errorPresent = !!item.error;
const httpBodyPresent = item.http?.response?.body?.bytes ?? 0 > 0;
return (
errorPresent ||
httpBodyPresent ||
item?.http?.response?.headers ||
item?.http?.response?.redirects
);
}
interface Props {
item: Ping;
expandedRows: Record<string, JSX.Element>;
setExpandedRows: (val: Record<string, JSX.Element>) => void;
}
export const ExpandRowColumn = ({ item, expandedRows, setExpandedRows }: Props) => {
return (
<EuiButtonIcon
data-test-subj="uptimePingListExpandBtn"
onClick={() => toggleDetails(item, expandedRows, setExpandedRows)}
isDisabled={!rowShouldExpand(item)}
aria-label={
expandedRows[item.docId]
? i18n.translate('xpack.synthetics.pingList.collapseRow', {
defaultMessage: 'Collapse',
})
: i18n.translate('xpack.synthetics.pingList.expandRow', { defaultMessage: 'Expand' })
}
iconType={expandedRows[item.docId] ? 'arrowUp' : 'arrowDown'}
/>
);
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import styled from 'styled-components';
import { Ping } from '../../../../../../../../common/runtime_types';
const StyledSpan = styled.span`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
`;
interface Props {
errorType: string;
ping: Ping;
}
export const PingErrorCol = ({ errorType, ping }: Props) => {
if (!errorType) {
return <>--</>;
}
return (
<StyledSpan>
{errorType}:{ping.error?.message}
</StyledSpan>
);
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { EuiBadge, EuiSpacer, EuiText } from '@elastic/eui';
import { STATUS } from '../../../../../../../../common/constants';
import { Ping } from '../../../../../../../../common/runtime_types';
import {
STATUS_DOWN_LABEL,
STATUS_UP_LABEL,
} from '../../../../../../../../common/translations/translations';
interface Props {
pingStatus: string;
item: Ping;
}
const getPingStatusLabel = (status: string) => {
return status === 'up' ? STATUS_UP_LABEL : STATUS_DOWN_LABEL;
};
export const PingStatusColumn = ({ pingStatus, item }: Props) => {
const timeStamp = moment(item.timestamp);
let checkedTime = '';
if (moment().diff(timeStamp, 'd') > 1) {
checkedTime = timeStamp.format('ll LTS');
} else {
checkedTime = timeStamp.format('LTS');
}
return (
<div data-test-subj={`xpack.synthetics.pingList.ping-${item.docId}`}>
<EuiBadge className="eui-textCenter" color={pingStatus === STATUS.UP ? 'success' : 'danger'}>
{getPingStatusLabel(pingStatus)}
</EuiBadge>
<EuiSpacer size="xs" />
<EuiText size="xs" color="subdued">
{i18n.translate('xpack.synthetics.pingList.recencyMessage', {
values: { fromNow: checkedTime },
defaultMessage: 'Checked {fromNow}',
description:
'A string used to inform our users how long ago Heartbeat pinged the selected host.',
})}
</EuiText>
</div>
);
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import styled from 'styled-components';
import { EuiBadge } from '@elastic/eui';
const SpanWithMargin = styled.span`
margin-right: 16px;
`;
interface Props {
statusCode: string;
}
export const ResponseCodeColumn = ({ statusCode }: Props) => {
return (
<SpanWithMargin>
{statusCode ? <EuiBadge data-test-subj="pingResponseCode">{statusCode}</EuiBadge> : '--'}
</SpanWithMargin>
);
};

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// @ts-ignore formatNumber
import { formatNumber } from '@elastic/eui/lib/services/format';
import {
EuiCallOut,
EuiCodeBlock,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { HttpResponseBody, Ping } from '../../../../../../../common/runtime_types';
import { PingRedirects } from './ping_redirects';
import { PingHeaders } from './headers';
interface Props {
ping: Ping;
}
const BodyDescription = ({ body }: { body: HttpResponseBody }) => {
const contentBytes = body.content_bytes || 0;
const bodyBytes = body.bytes || 0;
const truncatedText =
contentBytes > 0 && contentBytes < bodyBytes
? i18n.translate('xpack.synthetics.pingList.expandedRow.truncated', {
defaultMessage: 'Showing first {contentBytes} bytes.',
values: { contentBytes },
})
: null;
const bodySizeText =
bodyBytes > 0
? i18n.translate('xpack.synthetics.pingList.expandedRow.bodySize', {
defaultMessage: 'Body size is {bodyBytes}.',
values: { bodyBytes: formatNumber(bodyBytes, '0b') },
})
: null;
const combinedText = [truncatedText, bodySizeText].filter((s) => s).join(' ');
return <EuiText>{combinedText}</EuiText>;
};
const BodyExcerpt = ({ content }: { content: string }) =>
content ? <EuiCodeBlock overflowHeight={250}>{content}</EuiCodeBlock> : null;
export const PingListExpandedRowComponent = ({ ping }: Props) => {
const listItems = [];
// Show the error block
if (ping.error) {
listItems.push({
title: i18n.translate('xpack.synthetics.pingList.expandedRow.error', {
defaultMessage: 'Error',
}),
description: <EuiText>{ping.error.message}</EuiText>,
});
}
// Show the body, if present
if (ping.http?.response?.body) {
const body = ping.http.response.body;
listItems.push({
title: i18n.translate('xpack.synthetics.pingList.expandedRow.response_body', {
defaultMessage: 'Response Body',
}),
description: (
<>
<BodyDescription body={body} />
<EuiSpacer size={'s'} />
{body.content ? (
<BodyExcerpt content={body.content || ''} />
) : (
<EuiText>
<FormattedMessage
id="xpack.synthetics.testResults.expandedRow.response_body.notRecorded"
defaultMessage='Body not recorded. Set index response body option to "On Always" in advanced options of monitor configuration to record body.'
/>
</EuiText>
)}
</>
),
});
}
return (
<EuiFlexGroup direction="column">
{ping?.http?.response?.redirects && (
<EuiFlexItem>
<PingRedirects monitorStatus={ping} showTitle={true} />
</EuiFlexItem>
)}
{ping?.http?.response?.headers && (
<EuiFlexItem>
<PingHeaders headers={ping?.http?.response?.headers} />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiCallOut color={ping?.error ? 'danger' : 'primary'}>
<EuiDescriptionList listItems={listItems} />
</EuiCallOut>
</EuiFlexItem>
</EuiFlexGroup>
);
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiAccordion, EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { PingHeaders as HeadersProp } from '../../../../../../../common/runtime_types';
interface Props {
headers: HeadersProp;
}
export const PingHeaders = ({ headers }: Props) => {
const headersList = Object.keys(headers)
.sort()
.map((header) => ({
title: header,
description: headers[header],
}));
return (
<>
<EuiSpacer size="s" />
<EuiAccordion
id="responseHeaderAccord"
buttonContent={
<EuiTitle size="xs">
<h3>
{i18n.translate('xpack.synthetics.pingList.headers.title', {
defaultMessage: 'Response headers',
})}
</h3>
</EuiTitle>
}
>
<EuiSpacer size="s" />
<EuiDescriptionList
titleProps={{ style: { width: '30%', paddingLeft: 30 } }}
compressed={true}
type="responsiveColumn"
listItems={headersList}
/>
</EuiAccordion>
</>
);
};

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { EuiBasicTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { formatDuration } from '../../../../utils/formatting';
import { Ping } from '../../../../../../../common/runtime_types';
import * as I18LABELS from './translations';
import { PingStatusColumn } from './columns/ping_status';
import { ERROR_LABEL, LOCATION_LABEL, RES_CODE_LABEL } from './translations';
import { PingErrorCol } from './columns/ping_error';
import { ResponseCodeColumn } from './columns/response_code';
import { ExpandRowColumn } from './columns/expand_row';
interface Props {
loading?: boolean;
pings: Ping[];
error?: Error;
onChange?: (criteria: any) => void;
}
export function PingListTable({ loading, error, pings, onChange }: 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 hasStatus = pings.reduce(
(hasHttpStatus: boolean, currentPing) =>
hasHttpStatus || !!currentPing.http?.response?.status_code,
false
);
const hasError = pings.reduce(
(errorType: boolean, currentPing) => errorType || !!currentPing.error?.type,
false
);
const columns: any[] = [
{
field: 'monitor.status',
name: I18LABELS.STATUS_LABEL,
render: (pingStatus: string, item: Ping) => (
<PingStatusColumn pingStatus={pingStatus} item={item} />
),
},
{
align: 'left',
field: 'observer.geo.name',
name: LOCATION_LABEL,
},
{
align: 'right',
field: 'monitor.ip',
name: i18n.translate('xpack.synthetics.pingList.ipAddressColumnLabel', {
defaultMessage: 'IP',
}),
},
{
align: 'center',
field: 'monitor.duration.us',
name: i18n.translate('xpack.synthetics.pingList.durationMsColumnLabel', {
defaultMessage: 'Duration',
}),
render: (duration: number | null) =>
duration ? (
formatDuration(duration)
) : (
<span data-test-subj="ping-list-duration-unavailable-tool-tip">{'--'}</span>
),
},
...(hasError
? [
{
field: 'error.type',
name: ERROR_LABEL,
width: '30%',
render: (errorType: string, item: Ping) => (
<PingErrorCol ping={item} errorType={errorType} />
),
},
]
: []),
// Only add this column is there is any status present in list
...(hasStatus
? [
{
field: 'http.response.status_code',
align: 'right',
name: <SpanWithMargin>{RES_CODE_LABEL}</SpanWithMargin>,
render: (statusCode: string) => <ResponseCodeColumn statusCode={statusCode} />,
},
]
: []),
{
align: 'right',
width: '24px',
isExpander: true,
render: (item: Ping) => (
<ExpandRowColumn
item={item}
expandedRows={expandedRows}
setExpandedRows={setExpandedRows}
/>
),
},
];
return (
<EuiBasicTable
loading={loading}
columns={columns}
error={error?.message}
isExpandable={true}
hasActions={true}
items={pings}
itemId="docId"
itemIdToExpandedRowMap={expandedRows}
noItemsMessage={
loading
? i18n.translate('xpack.synthetics.pingList.pingsLoadingMesssage', {
defaultMessage: 'Loading history...',
})
: i18n.translate('xpack.synthetics.pingList.pingsUnavailableMessage', {
defaultMessage: 'No history found',
})
}
tableLayout={'auto'}
onChange={onChange}
/>
);
}
export const SpanWithMargin = styled.span`
margin-right: 16px;
`;

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { EuiListGroup, EuiListGroupItemProps, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import { Ping } from '../../../../../../../common/runtime_types';
const ListGroup = styled(EuiListGroup)`
&&& {
a {
padding-left: 0;
}
}
`;
interface Props {
monitorStatus: Ping | null;
showTitle?: boolean;
}
export const PingRedirects: React.FC<Props> = ({ monitorStatus, showTitle }) => {
const monitorUrl = monitorStatus?.url?.full;
const list = monitorStatus?.http?.response?.redirects;
const listOfRedirects: EuiListGroupItemProps[] = [
{
label: monitorUrl,
href: monitorUrl,
iconType: 'globe',
size: 's',
target: '_blank',
extraAction: {
color: 'text',
iconType: 'popout',
iconSize: 's',
alwaysShow: true,
'aria-label': i18n.translate('xpack.synthetics.monitorList.redirects.openWindow', {
defaultMessage: 'Link will open in new window.',
}),
},
},
];
(list ?? []).forEach((url: string) => {
listOfRedirects.push({
label: url,
href: url,
iconType: 'sortDown',
size: 's',
target: '_blank',
extraAction: {
color: 'text',
iconType: 'popout',
iconSize: 's',
'aria-label': i18n.translate('xpack.synthetics.monitorList.redirects.openWindow', {
defaultMessage: 'Link will open in new window.',
}),
alwaysShow: true,
},
});
});
const Panel = showTitle ? EuiPanel : 'div';
return list ? (
<Panel data-test-subj="uptimeMonitorPingListRedirectInfo">
{showTitle && (
<EuiText size="xs">
<h3>
{i18n.translate('xpack.synthetics.monitorList.redirects.title', {
defaultMessage: 'Redirects',
})}
</h3>
</EuiText>
)}
<EuiSpacer size="xs" />
{
<EuiText>
{i18n.translate('xpack.synthetics.monitorList.redirects.description', {
defaultMessage: 'Heartbeat followed {number} redirects while executing ping.',
values: {
number: list?.length ?? 0,
},
})}
</EuiText>
}
<EuiSpacer size="s" />
<ListGroup gutterSize={'none'} listItems={listOfRedirects} />
</Panel>
) : null;
};

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const STATUS_LABEL = i18n.translate('xpack.synthetics.pingList.statusColumnLabel', {
defaultMessage: 'Status',
});
export const RES_CODE_LABEL = i18n.translate('xpack.synthetics.pingList.responseCodeColumnLabel', {
defaultMessage: 'Response code',
});
export const ERROR_TYPE_LABEL = i18n.translate('xpack.synthetics.pingList.errorTypeColumnLabel', {
defaultMessage: 'Error type',
});
export const ERROR_LABEL = i18n.translate('xpack.synthetics.pingList.errorColumnLabel', {
defaultMessage: 'Error',
});
export const LOCATION_LABEL = i18n.translate('xpack.synthetics.pingList.locationNameColumnLabel', {
defaultMessage: 'Location',
});
export const TIMESTAMP_LABEL = i18n.translate('xpack.synthetics.pingList.timestampColumnLabel', {
defaultMessage: 'Timestamp',
});

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { Ping } from '../../../../../../common/runtime_types';
import { useSimpleRunOnceMonitors } from '../hooks/use_simple_run_once_monitors';
import { TestResultHeader } from '../test_result_header';
import { PingListTable } from './ping_list/ping_list_table';
interface Props {
testRunId: string;
expectPings: number;
onDone: (testRunId: string) => void;
}
export function SimpleTestResults({ testRunId, expectPings, onDone }: Props) {
const [summaryDocsCache, setSummaryDocsCache] = useState<Ping[]>([]);
const { summaryDocs, loading } = useSimpleRunOnceMonitors({
testRunId,
expectSummaryDocs: expectPings,
});
useEffect(() => {
if (summaryDocs) {
setSummaryDocsCache((prevState: Ping[]) => {
const prevById: Record<string, Ping> = prevState.reduce(
(acc, cur) => ({ ...acc, [cur.docId]: cur }),
{}
);
return summaryDocs.map((updatedDoc) => ({
...updatedDoc,
...(prevById[updatedDoc.docId] ?? {}),
}));
});
if (summaryDocs.length >= expectPings) {
onDone(testRunId);
}
}
}, [testRunId, expectPings, summaryDocs, onDone]);
return (
<>
<TestResultHeader
summaryDocs={summaryDocsCache}
isCompleted={Boolean(summaryDocs && summaryDocs.length >= expectPings)}
/>
{summaryDocs && <PingListTable pings={summaryDocsCache} loading={loading} />}
</>
);
}

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import { useRunOnceErrors } from './hooks/use_run_once_errors';
import { BrowserTestRunResult } from './browser/browser_test_results';
import { TestRun } from './test_now_mode_flyout';
import { SimpleTestResults } from './simple/simple_test_results';
import { Locations, ServiceLocationErrors } from '../../../../../common/runtime_types';
export function TestNowMode({
testRun,
onDone,
isPushing,
serviceError,
errors,
}: {
serviceError?: Error;
errors: ServiceLocationErrors;
isPushing: boolean;
testRun: TestRun;
onDone: (testRunId: string) => void;
}) {
const { hasBlockingError, blockingErrorMessage, expectPings } = useRunOnceErrors({
testRunId: testRun.id,
serviceError,
errors: errors ?? [],
locations: (testRun.monitor.locations ?? []) as Locations,
});
useEffect(() => {
if (!isPushing && (!testRun.id || hasBlockingError)) {
onDone(testRun.id);
}
// we don't need onDone as a dependency since it's a function
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testRun.id, hasBlockingError, isPushing]);
const isBrowserMonitor = testRun.monitor.type === 'browser';
return (
<EuiPanel color="subdued" hasBorder={true}>
{(hasBlockingError && !isPushing && (
<EuiCallOut title={blockingErrorMessage} color="danger" iconType="alert" />
)) ||
null}
{testRun && !hasBlockingError && !isPushing && (
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem key={testRun.id}>
{isBrowserMonitor ? (
<BrowserTestRunResult
expectPings={expectPings}
onDone={onDone}
testRunId={testRun.id}
/>
) : (
<SimpleTestResults expectPings={expectPings} onDone={onDone} testRunId={testRun.id} />
)}
</EuiFlexItem>
</EuiFlexGroup>
)}
<EuiSpacer size="xs" />
</EuiPanel>
);
}

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiCallOut,
EuiErrorBoundary,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiLoadingSpinner,
EuiTitle,
} from '@elastic/eui';
import { LoadingState } from '../monitors_page/overview/overview/monitor_detail_flyout';
import { MonitorFields, ServiceLocationErrors } from '../../../../../common/runtime_types';
import { TestNowMode } from './test_now_mode';
export interface TestRun {
id: string;
monitor: MonitorFields;
}
export function TestNowModeFlyout({
testRun,
onClose,
onDone,
inProgress,
isPushing,
errors,
serviceError,
}: {
serviceError?: Error;
errors: ServiceLocationErrors;
testRun?: TestRun;
inProgress: boolean;
isPushing: boolean;
onClose: () => void;
onDone: (testRunId: string) => void;
}) {
const flyout = (
<EuiFlyout
type="push"
size="m"
paddingSize="m"
maxWidth="44%"
aria-labelledby={TEST_RESULT}
onClose={onClose}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2>{TEST_RESULTS}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiErrorBoundary>
{isPushing && (
<EuiCallOut color="primary">
{PushingLabel} <EuiLoadingSpinner />
</EuiCallOut>
)}
{testRun ? (
<TestNowMode
isPushing={isPushing}
errors={errors}
serviceError={serviceError}
testRun={testRun}
onDone={onDone}
/>
) : (
!isPushing && <LoadingState />
)}
</EuiErrorBoundary>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
{CLOSE_LABEL}
</EuiButtonEmpty>
</EuiFlyoutFooter>
</EuiFlyout>
);
return <>{(testRun || inProgress) && <EuiErrorBoundary>{flyout}</EuiErrorBoundary>}</>;
}
const TEST_RESULT = i18n.translate('xpack.synthetics.monitorManagement.testResult', {
defaultMessage: 'Test result',
});
const TEST_RESULTS = i18n.translate('xpack.synthetics.monitorManagement.testResults', {
defaultMessage: 'Test results',
});
const CLOSE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.closeButtonLabel', {
defaultMessage: 'Close',
});
const PushingLabel = i18n.translate('xpack.synthetics.testRun.pushing.description', {
defaultMessage: 'Pushing the monitor to service...',
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTestFlyoutOpen } from './hooks/use_test_flyout_open';
import { TestNowModeFlyout } from './test_now_mode_flyout';
import { ManualTestRunMode } from './manual_test_run_mode/manual_test_run_mode';
import { useSyntheticsRefreshContext } from '../../contexts';
import {
manualTestRunUpdateAction,
testNowRunsSelector,
TestRunStatus,
} from '../../state/manual_test_runs';
import { MonitorFields } from '../../../../../common/runtime_types';
export interface TestRun {
id: string;
monitor: MonitorFields;
runOnceMode: boolean;
}
export function TestNowModeFlyoutContainer() {
const dispatch = useDispatch();
const testNowRuns = useSelector(testNowRunsSelector);
const { refreshApp } = useSyntheticsRefreshContext();
const flyoutOpenTestRun = useTestFlyoutOpen();
const onDone = useCallback(
(testRunId) => {
dispatch(
manualTestRunUpdateAction({
testRunId,
status: TestRunStatus.COMPLETED,
})
);
refreshApp();
},
[dispatch, refreshApp]
);
const handleFlyoutClose = useCallback(
(testRunId) => {
dispatch(
manualTestRunUpdateAction({
testRunId,
isTestNowFlyoutOpen: false,
})
);
},
[dispatch]
);
const flyout = flyoutOpenTestRun ? (
<TestNowModeFlyout
testRun={
flyoutOpenTestRun?.testRunId && flyoutOpenTestRun?.monitor
? { id: flyoutOpenTestRun.testRunId, monitor: flyoutOpenTestRun.monitor }
: undefined
}
inProgress={
flyoutOpenTestRun.status === 'in-progress' || flyoutOpenTestRun.status === 'loading'
}
onClose={() => handleFlyoutClose(flyoutOpenTestRun.testRunId)}
onDone={onDone}
isPushing={flyoutOpenTestRun.status === 'loading'}
errors={flyoutOpenTestRun.errors ?? []}
/>
) : null;
return (
<>
{Object.values(testNowRuns)
.filter((val) => val.testRunId)
.map((manualTestRun) => (
<ManualTestRunMode
key={manualTestRun.testRunId}
manualTestRun={manualTestRun}
onDone={onDone}
/>
))}
{flyout}
</>
);
}

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiLoadingSpinner,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import * as React from 'react';
import { useSyntheticsSettingsContext } from '../../contexts';
import { JourneyStep, Ping } from '../../../../../common/runtime_types';
import { formatDuration } from '../../utils/formatting';
interface Props {
checkGroupId?: string;
summaryDocs?: Ping[] | JourneyStep[] | null;
journeyStarted?: boolean;
title?: string;
configId?: string;
isCompleted: boolean;
}
export function TestResultHeader({
checkGroupId,
title,
summaryDocs,
journeyStarted,
isCompleted,
configId,
}: Props) {
const { basePath } = useSyntheticsSettingsContext();
let duration = 0;
if (summaryDocs && summaryDocs.length > 0) {
summaryDocs.forEach((sDoc) => {
duration += sDoc.monitor.duration?.us ?? 0;
});
}
const summaryDoc = summaryDocs?.[0] as Ping;
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h3>{title ?? TEST_RESULT}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={true}>
{isCompleted ? (
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiBadge color={summaryDoc?.summary?.down! > 0 ? 'danger' : 'success'}>
{summaryDoc?.summary?.down! > 0 ? FAILED_LABEL : COMPLETED_LABEL}
</EuiBadge>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs" color="subdued">
{i18n.translate('xpack.synthetics.monitorManagement.timeTaken', {
defaultMessage: 'Took {timeTaken}',
values: { timeTaken: formatDuration(duration) },
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiBadge style={{ width: 100 }} color={journeyStarted ? 'primary' : 'warning'}>
{journeyStarted ? IN_PROGRESS_LABEL : PENDING_LABEL}
</EuiBadge>
</EuiFlexItem>
<EuiFlexItem>
<EuiLoadingSpinner />
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiFlexItem>
{checkGroupId && (
<EuiFlexItem grow={false}>
<EuiLink
href={`${basePath}/app/synthetics/monitor/${configId}/test-run/${checkGroupId}`}
target="_blank"
>
{VIEW_DETAILS}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
export const PENDING_LABEL = i18n.translate('xpack.synthetics.monitorManagement.pending', {
defaultMessage: 'PENDING',
});
const TEST_RESULT = i18n.translate('xpack.synthetics.monitorManagement.testResult', {
defaultMessage: 'Test result',
});
const COMPLETED_LABEL = i18n.translate('xpack.synthetics.monitorManagement.completed', {
defaultMessage: 'COMPLETED',
});
const FAILED_LABEL = i18n.translate('xpack.synthetics.monitorManagement.failed', {
defaultMessage: 'FAILED',
});
export const IN_PROGRESS_LABEL = i18n.translate('xpack.synthetics.monitorManagement.inProgress', {
defaultMessage: 'IN PROGRESS',
});
const VIEW_DETAILS = i18n.translate('xpack.synthetics.monitorManagement.viewTestRunDetails', {
defaultMessage: 'View test result details',
});

View file

@ -11,9 +11,10 @@ import { selectOverviewStatus } from '../state/overview';
export function useStatusByLocationOverview(configId: string, locationName?: string) {
const { status } = useSelector(selectOverviewStatus);
if (!locationName || !status) {
return 'unknown';
return { status: 'unknown' };
}
const allConfigs = status.allConfigs;
const config = allConfigs[`${configId}-${locationName}`];
return allConfigs[`${configId}-${locationName}`]?.status || 'unknown';
return { status: config?.status || 'unknown', timestamp: config?.timestamp };
}

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createAction } from '@reduxjs/toolkit';
import { ManualTestRun } from '.';
import { TestNowResponse } from '../../../../../common/types';
import { createAsyncAction } from '../utils/actions';
export const toggleTestNowFlyoutAction = createAction<string>('TOGGLE TEST NOW FLYOUT ACTION');
export const hideTestNowFlyoutAction = createAction('HIDE ALL TEST NOW FLYOUT ACTION');
export const manualTestMonitorAction = createAsyncAction<string, TestNowResponse | undefined>(
'TEST_NOW_MONITOR_ACTION'
);
export const manualTestRunUpdateAction = createAction<
Partial<ManualTestRun> & { testRunId: string }
>('manualTestRunUpdateAction');
export const clearTestNowMonitorAction = createAction<string>('CLEAR_TEST_NOW_MONITOR_ACTION');

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ServiceLocationErrors, SyntheticsMonitor } from '../../../../../common/runtime_types';
import { TestNowResponse } from '../../../../../common/types';
import { apiService } from '../../../../utils/api_service';
import { API_URLS } from '../../../../../common/constants';
export const triggerTestNowMonitor = async (
configId: string
): Promise<TestNowResponse | undefined> => {
return await apiService.get(API_URLS.TRIGGER_MONITOR + `/${configId}`);
};
export const runOnceMonitor = async ({
monitor,
id,
}: {
monitor: SyntheticsMonitor;
id: string;
}): Promise<{ errors: ServiceLocationErrors }> => {
return await apiService.post(API_URLS.RUN_ONCE_MONITOR + `/${id}`, monitor);
};

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { takeEvery } from 'redux-saga/effects';
import { i18n } from '@kbn/i18n';
import { fetchEffectFactory } from '../utils/fetch_effect';
import { manualTestMonitorAction } from './actions';
import { triggerTestNowMonitor } from './api';
export function* fetchManualTestRunsEffect() {
yield takeEvery(
manualTestMonitorAction.get,
fetchEffectFactory(
triggerTestNowMonitor,
manualTestMonitorAction.success,
manualTestMonitorAction.fail,
'',
FAILED_TEST
)
);
}
const FAILED_TEST = i18n.translate('xpack.synthetics.runTest.failure', {
defaultMessage: 'Failed to run test manually',
});

View file

@ -0,0 +1,169 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createReducer, PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { IHttpFetchError } from '@kbn/core-http-browser';
import { TestNowResponse } from '../../../../../common/types';
import {
clearTestNowMonitorAction,
hideTestNowFlyoutAction,
manualTestMonitorAction,
manualTestRunUpdateAction,
toggleTestNowFlyoutAction,
} from './actions';
import {
Locations,
ScheduleUnit,
ServiceLocationErrors,
SyntheticsMonitorSchedule,
} from '../../../../../common/runtime_types';
export enum TestRunStatus {
LOADING = 'loading',
IN_PROGRESS = 'in-progress',
COMPLETED = 'completed',
}
export interface ManualTestRun {
configId: string;
testRunId?: string;
status: TestRunStatus;
schedule: SyntheticsMonitorSchedule;
locations: Locations;
errors?: ServiceLocationErrors;
fetchError?: { name: string; message: string };
isTestNowFlyoutOpen: boolean;
monitor?: TestNowResponse['monitor'];
}
export interface ManualTestRunsState {
[configId: string]: ManualTestRun;
}
const initialState: ManualTestRunsState = {};
export const manualTestRunsReducer = createReducer(initialState, (builder) => {
builder
.addCase(
String(manualTestMonitorAction.get),
(state: WritableDraft<ManualTestRunsState>, action: PayloadAction<string>) => {
state = Object.values(state).reduce((acc, curr) => {
acc[curr.configId] = {
...curr,
isTestNowFlyoutOpen: false,
};
return acc;
}, state);
state[action.payload] = {
configId: action.payload,
status: TestRunStatus.LOADING,
schedule: { unit: ScheduleUnit.MINUTES, number: '3' },
locations: [],
isTestNowFlyoutOpen: true,
};
}
)
.addCase(
String(manualTestMonitorAction.success),
(state: WritableDraft<ManualTestRunsState>, { payload }: PayloadAction<TestNowResponse>) => {
state[payload.configId] = {
configId: payload.configId,
testRunId: payload.testRunId,
status: TestRunStatus.IN_PROGRESS,
errors: payload.errors,
schedule: payload.schedule,
locations: payload.locations,
isTestNowFlyoutOpen: true,
monitor: payload.monitor,
};
}
)
.addCase(
String(manualTestMonitorAction.fail),
(state: WritableDraft<ManualTestRunsState>, action: PayloadAction<TestNowResponse>) => {
const fetchError = action.payload as unknown as IHttpFetchError;
if (fetchError?.request.url) {
const { name, message } = fetchError;
const [, errorMonitor] =
Object.entries(state).find(
([key]) => fetchError.request.url.indexOf(key) > -1 ?? false
) ?? [];
if (errorMonitor) {
state[errorMonitor.configId] = {
...state[errorMonitor.configId],
status: TestRunStatus.COMPLETED,
errors: undefined,
fetchError: { name, message },
};
}
}
if (action.payload.configId) {
state[action.payload.configId] = {
...state[action.payload.configId],
status: TestRunStatus.COMPLETED,
errors: action.payload.errors,
fetchError: undefined,
};
}
return state;
}
)
.addCase(manualTestRunUpdateAction, (state: WritableDraft<ManualTestRunsState>, action) => {
const { testRunId, ...rest } = action.payload;
const configId = Object.keys(state).find((key) => state[key].testRunId === testRunId);
if (configId) {
state[configId] = {
...state[configId],
...rest,
};
}
})
.addCase(toggleTestNowFlyoutAction, (state: WritableDraft<ManualTestRunsState>, action) => {
state = Object.values(state).reduce((acc, curr) => {
acc[curr.configId] = {
...curr,
isTestNowFlyoutOpen: false,
};
return acc;
}, state);
state[action.payload] = {
...state[action.payload],
isTestNowFlyoutOpen: !state[action.payload].isTestNowFlyoutOpen,
};
})
.addCase(hideTestNowFlyoutAction, (state: WritableDraft<ManualTestRunsState>) => {
state = Object.values(state).reduce((acc, curr) => {
acc[curr.configId] = {
...curr,
isTestNowFlyoutOpen: false,
};
return acc;
}, state);
return state;
})
.addCase(
String(clearTestNowMonitorAction),
(state: WritableDraft<ManualTestRunsState>, action: PayloadAction<string>) => {
delete state[action.payload];
}
);
});
export * from './actions';
export * from './selectors';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SyntheticsAppState } from '../root_reducer';
export const testNowRunsSelector = ({ manualTestRuns }: SyntheticsAppState) => manualTestRuns;
export const manualTestRunSelector =
(configId?: string) =>
({ manualTestRuns }: SyntheticsAppState) =>
configId ? manualTestRuns[configId] : undefined;
export const manualTestRunInProgressSelector =
(configId: string) =>
({ manualTestRuns }: SyntheticsAppState) => {
return (
manualTestRuns[configId]?.status === 'in-progress' ||
manualTestRuns[configId]?.status === 'loading'
);
};

View file

@ -22,6 +22,7 @@ export type MonitorOverviewFlyoutConfig = {
configId: string;
id: string;
location: string;
locationId: string;
} | null;
export interface MonitorOverviewState {

View file

@ -6,6 +6,7 @@
*/
import { all, fork } from 'redux-saga/effects';
import { fetchManualTestRunsEffect } from './manual_test_runs/effects';
import { enableDefaultAlertingEffect, updateDefaultAlertingEffect } from './alert_rules/effects';
import { executeEsQueryEffect } from './elasticsearch';
import {
@ -53,5 +54,6 @@ export const rootEffect = function* root(): Generator {
fork(updateDefaultAlertingEffect),
fork(executeEsQueryEffect),
fork(fetchJourneyStepsEffect),
fork(fetchManualTestRunsEffect),
]);
};

View file

@ -9,6 +9,7 @@ import { combineReducers } from '@reduxjs/toolkit';
import { browserJourneyReducer } from './browser_journey';
import { defaultAlertingReducer, DefaultAlertingState } from './alert_rules';
import { manualTestRunsReducer, ManualTestRunsState } from './manual_test_runs';
import {
dynamicSettingsReducer,
DynamicSettingsState,
@ -38,10 +39,11 @@ export interface SyntheticsAppState {
overview: MonitorOverviewState;
networkEvents: NetworkEventsState;
agentPolicies: AgentPoliciesState;
manualTestRuns: ManualTestRunsState;
monitorDetails: MonitorDetailsState;
browserJourney: BrowserJourneyState;
dynamicSettings: DynamicSettingsState;
defaultAlerting: DefaultAlertingState;
dynamicSettings: DynamicSettingsState;
serviceLocations: ServiceLocationsState;
syntheticsEnablement: SyntheticsEnablementState;
}
@ -58,6 +60,7 @@ export const rootReducer = combineReducers<SyntheticsAppState>({
agentPolicies: agentPoliciesReducer,
monitorDetails: monitorDetailsReducer,
browserJourney: browserJourneyReducer,
manualTestRuns: manualTestRunsReducer,
defaultAlerting: defaultAlertingReducer,
dynamicSettings: dynamicSettingsReducer,
serviceLocations: serviceLocationsReducer,

View file

@ -91,7 +91,7 @@ export function fetchEffectFactory<T, R, S, F>(
if (typeof onSuccess === 'function') {
onSuccess?.(response as R);
} else if (typeof onSuccess === 'string') {
} else if (onSuccess && typeof onSuccess === 'string') {
kibanaService.core.notifications.toasts.addSuccess(onSuccess);
}
}

View file

@ -30,6 +30,7 @@ import { PageRouter } from './routes';
import { store, storage, setBasePath } from './state';
import { kibanaService } from '../../utils/kibana_service';
import { ActionMenu } from './components/common/header/action_menu';
import { TestNowModeFlyoutContainer } from './components/test_now_mode/test_now_mode_flyout_container';
const Application = (props: SyntheticsAppProps) => {
const {
@ -110,6 +111,7 @@ const Application = (props: SyntheticsAppProps) => {
<InspectorContextProvider>
<PageRouter />
<ActionMenu appMountParameters={appMountParameters} />
<TestNowModeFlyoutContainer />
</InspectorContextProvider>
</RedirectAppLinks>
</div>

View file

@ -134,6 +134,7 @@ export const mockState: SyntheticsAppState = {
loading: {},
error: {},
},
manualTestRuns: {},
};
function getBrowserJourneyMockSlice() {

View file

@ -10,7 +10,10 @@ import { renderWithIntl } from '@kbn/test-jest-helpers';
import { DonutChartLegend } from './donut_chart_legend';
import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../translations';
import {
STATUS_DOWN_LABEL,
STATUS_UP_LABEL,
} from '../../../../../common/translations/translations';
describe('DonutChartLegend', () => {
it('applies valid props as expected', () => {

View file

@ -10,7 +10,10 @@ import React, { useContext } from 'react';
import styled from 'styled-components';
import { DonutChartLegendRow } from './donut_chart_legend_row';
import { UptimeThemeContext } from '../../../contexts';
import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../translations';
import {
STATUS_DOWN_LABEL,
STATUS_UP_LABEL,
} from '../../../../../common/translations/translations';
const LegendContainer = styled.div`
max-width: 150px;

View file

@ -32,7 +32,7 @@ import { UptimeThemeContext } from '../../../contexts';
import { MONITOR_CHART_HEIGHT } from '../../monitor';
import { monitorStatusSelector } from '../../../state/selectors';
import { microToMilli, microToSec } from '../../../lib/formatting';
import { MS_LABEL, SECONDS_LABEL } from '../translations';
import { MS_LABEL, SECONDS_LABEL } from '../../../../../common/translations/translations';
interface DurationChartProps {
/**

View file

@ -9,7 +9,7 @@ import React from 'react';
import { LineSeries, CurveType, Fit, ScaleType } from '@elastic/charts';
import { LocationDurationLine } from '../../../../../common/types';
import { microToMilli, microToSec } from '../../../lib/formatting';
import { MS_LABEL, SEC_LABEL } from '../translations';
import { MS_LABEL, SEC_LABEL } from '../../../../../common/translations/translations';
interface Props {
monitorType: string;

View file

@ -30,7 +30,10 @@ import { HistogramResult } from '../../../../../common/runtime_types';
import { useUrlParams } from '../../../hooks';
import { ChartEmptyState } from './chart_empty_state';
import { getDateRangeFromChartElement } from './utils';
import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../translations';
import {
STATUS_DOWN_LABEL,
STATUS_UP_LABEL,
} from '../../../../../common/translations/translations';
export interface PingHistogramComponentProps {
/**

View file

@ -17,7 +17,7 @@ import {
STATUS_DOWN_LABEL,
STATUS_FAILED_LABEL,
STATUS_UP_LABEL,
} from '../../../common/translations';
} from '../../../../../../common/translations/translations';
interface Props {
pingStatus: string;

View file

@ -21,7 +21,11 @@ import * as labels from '../translations';
import { StatusByLocations } from './status_by_location';
import { useStatusBar } from './use_status_bar';
import { MonitorIDLabel, OverallAvailability } from '../translations';
import { PROJECT_LABEL, TAGS_LABEL, URL_LABEL } from '../../../common/translations';
import {
PROJECT_LABEL,
TAGS_LABEL,
URL_LABEL,
} from '../../../../../../common/translations/translations';
import { MonitorLocations } from '../../../../../../common/runtime_types/monitor';
import { formatAvailabilityValue } from '../availability_reporting/availability_reporting';
import { MonitorRedirects } from './monitor_redirects';

View file

@ -11,10 +11,12 @@ import { useLocationMonitors } from './use_location_monitors';
import { defaultCore, WrappedHelper } from '../../../../../apps/synthetics/utils/testing';
describe('useLocationMonitors', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useLocationMonitors(), { wrapper: WrappedHelper });
it('returns expected results', async () => {
const { result, waitFor } = renderHook(() => useLocationMonitors(), { wrapper: WrappedHelper });
expect(result.current).toStrictEqual({ locationMonitors: [], loading: true });
await waitFor(() => result.current.loading === false);
expect(result.current).toStrictEqual({ locationMonitors: [], loading: false });
expect(defaultCore.savedObjects.client.find).toHaveBeenCalledWith({
aggs: {
locations: {

View file

@ -18,7 +18,7 @@ import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/tab
import { i18n } from '@kbn/i18n';
import React, { useCallback, useContext, useMemo } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { PROJECT_LABEL } from '../../common/translations';
import { PROJECT_LABEL } from '../../../../../common/translations/translations';
import {
CommonFields,
ConfigKey,

View file

@ -31,7 +31,10 @@ import {
SHORT_TS_LOCALE,
} from '../../../../../../common/constants';
import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../../common/translations';
import {
STATUS_DOWN_LABEL,
STATUS_UP_LABEL,
} from '../../../../../../common/translations/translations';
import { MonitorProgress } from './progress/monitor_progress';
import { refreshedMonitorSelector } from '../../../../state/reducers/monitor_list';
import { testNowRunSelector } from '../../../../state/reducers/test_now_runs';

View file

@ -30,7 +30,7 @@ import { MonitorListProps } from './monitor_list_container';
import { MonitorList } from '../../../state/reducers/monitor_list';
import { CertStatusColumn } from './columns/cert_status_column';
import { MonitorListHeader } from './monitor_list_header';
import { TAGS_LABEL, URL_LABEL } from '../../common/translations';
import { TAGS_LABEL, URL_LABEL } from '../../../../../common/translations/translations';
import { EnableMonitorAlert } from './columns/enable_alert';
import { STATUS_ALERT_COLUMN, TEST_NOW_COLUMN } from './translations';
import { MonitorNameColumn } from './columns/monitor_name_col';

View file

@ -10,7 +10,10 @@ import { i18n } from '@kbn/i18n';
import { EuiFilterGroup } from '@elastic/eui';
import { FilterStatusButton } from './filter_status_button';
import { useGetUrlParams } from '../../../hooks';
import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../common/translations';
import {
STATUS_DOWN_LABEL,
STATUS_UP_LABEL,
} from '../../../../../common/translations/translations';
export const StatusFilter: React.FC = () => {
const { statusFilter } = useGetUrlParams();

View file

@ -6,12 +6,12 @@
*/
import { createAction } from 'redux-actions';
import { TestNowResponse } from '../../../../common/types';
import {
FetchMonitorStatesQueryArgs,
MonitorSummariesResult,
} from '../../../../common/runtime_types';
import { createAsyncAction } from './utils';
import { TestNowResponse } from '../api';
export const getMonitorList = createAction<FetchMonitorStatesQueryArgs>('GET_MONITOR_LIST');
export const getMonitorListSuccess = createAction<MonitorSummariesResult>(

View file

@ -18,12 +18,11 @@ import {
ServiceLocationsApiResponseCodec,
ServiceLocationErrors,
ThrottlingOptions,
Locations,
SyntheticsMonitorSchedule,
} from '../../../../common/runtime_types';
import {
DecryptedSyntheticsMonitorSavedObject,
SyntheticsServiceAllowed,
TestNowResponse,
} from '../../../../common/types';
import { apiService } from './utils';
@ -87,14 +86,6 @@ export const runOnceMonitor = async ({
return await apiService.post(API_URLS.RUN_ONCE_MONITOR + `/${id}`, monitor);
};
export interface TestNowResponse {
schedule: SyntheticsMonitorSchedule;
locations: Locations;
errors?: ServiceLocationErrors;
testRunId: string;
monitorId: string;
}
export const triggerTestNowMonitor = async (
configId: string
): Promise<TestNowResponse | undefined> => {

View file

@ -7,8 +7,9 @@
import { takeEvery } from 'redux-saga/effects';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { TestNowResponse } from '../../../../common/types';
import { testNowMonitorAction } from '../actions';
import { type TestNowResponse, triggerTestNowMonitor } from '../api';
import { triggerTestNowMonitor } from '../api';
import { fetchEffectFactory } from './fetch_effect';
export function* fetchTestNowMonitorEffect() {

View file

@ -7,6 +7,7 @@
import { handleActions, type Action } from 'redux-actions';
import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
import { TestNowResponse } from '../../../../common/types';
import {
getMonitorList,
getMonitorListSuccess,
@ -17,7 +18,6 @@ import {
} from '../actions';
import type { MonitorSummariesResult } from '../../../../common/runtime_types';
import type { AppState } from '..';
import type { TestNowResponse } from '../api';
export interface MonitorList {
loading: boolean;

View file

@ -8,6 +8,7 @@
import { createReducer, type PayloadAction } from '@reduxjs/toolkit';
import type { WritableDraft } from 'immer/dist/types/types-external';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { TestNowResponse } from '../../../../common/types';
import {
type Locations,
ScheduleUnit,
@ -15,7 +16,6 @@ import {
type SyntheticsMonitorSchedule,
} from '../../../../common/runtime_types';
import { clearTestNowMonitorAction, testNowMonitorAction } from '../actions';
import type { TestNowResponse } from '../api';
import type { AppState } from '..';
export enum TestRunStats {
@ -58,8 +58,8 @@ export const testNowRunsReducer = createReducer(initialState, (builder) => {
String(testNowMonitorAction.success),
(state: WritableDraft<TestNowRunsState>, { payload }: PayloadAction<TestNowResponse>) => ({
...state,
[payload.monitorId]: {
monitorId: payload.monitorId,
[payload.configId]: {
monitorId: payload.configId,
testRunId: payload.testRunId,
status: TestRunStats.IN_PROGRESS,
errors: payload.errors,
@ -93,11 +93,11 @@ export const testNowRunsReducer = createReducer(initialState, (builder) => {
}
}
if (action.payload.monitorId) {
if (action.payload.configId) {
return {
...state,
[action.payload.monitorId]: {
...state[action.payload.monitorId],
[action.payload.configId]: {
...state[action.payload.configId],
status: TestRunStats.COMPLETED,
errors: action.payload.errors,
fetchError: undefined,

View file

@ -42,7 +42,7 @@ export const getJourneyDetails: UMElasticsearchQueryFn<
const { body: thisJourney } = await uptimeEsClient.search(
{ body: baseParams },
'getJourneyDetails'
'getJourneyDetailsCurrentJourney'
);
if (thisJourney.hits.hits.length > 0) {
@ -108,8 +108,14 @@ export const getJourneyDetails: UMElasticsearchQueryFn<
sort: [{ '@timestamp': { order: 'asc' as const } }],
};
const { body: previousJourneyResult } = await uptimeEsClient.search({ body: previousParams });
const { body: nextJourneyResult } = await uptimeEsClient.search({ body: nextParams });
const { body: previousJourneyResult } = await uptimeEsClient.search(
{ body: previousParams },
'getJourneyDetailsNextJourney'
);
const { body: nextJourneyResult } = await uptimeEsClient.search(
{ body: nextParams },
'getJourneyDetailsPreviousJourney'
);
const previousJourney: any =
previousJourneyResult?.hits?.hits.length > 0 ? previousJourneyResult?.hits?.hits[0] : null;
const nextJourney: any =

View file

@ -110,7 +110,7 @@ export async function queryMonitorStatus(
response.body.aggregations?.id.buckets.forEach(
({ location, key: queryId }: { location: any; key: string }) => {
location.buckets.forEach(({ status }: { key: string; status: any }) => {
const ping = status.hits.hits[0]._source as Ping;
const ping = status.hits.hits[0]._source as Ping & { '@timestamp': string };
const locationName = ping.observer?.geo?.name!;
@ -132,6 +132,7 @@ export async function queryMonitorStatus(
monitorQueryId,
location: locationName,
status: 'up',
timestamp: ping['@timestamp'],
};
} else if (downCount > 0) {
down += 1;
@ -141,6 +142,7 @@ export async function queryMonitorStatus(
monitorQueryId,
location: locationName,
status: 'down',
timestamp: ping['@timestamp'],
};
}
});

View file

@ -184,6 +184,7 @@ describe('current status route', () => {
location: 'Asia/Pacific - Japan',
status: 'up',
ping: expect.any(Object),
timestamp: expect.any(String),
},
'id2-Asia/Pacific - Japan': {
configId: 'id2',
@ -191,6 +192,7 @@ describe('current status route', () => {
location: 'Asia/Pacific - Japan',
status: 'up',
ping: expect.any(Object),
timestamp: expect.any(String),
},
},
downConfigs: {
@ -200,6 +202,7 @@ describe('current status route', () => {
location: 'Europe - Germany',
status: 'down',
ping: expect.any(Object),
timestamp: expect.any(String),
},
},
});
@ -334,6 +337,7 @@ describe('current status route', () => {
location: 'Asia/Pacific - Japan',
status: 'up',
ping: expect.any(Object),
timestamp: expect.any(String),
},
'id2-Asia/Pacific - Japan': {
configId: 'id2',
@ -341,6 +345,7 @@ describe('current status route', () => {
location: 'Asia/Pacific - Japan',
status: 'up',
ping: expect.any(Object),
timestamp: expect.any(String),
},
},
downConfigs: {
@ -350,6 +355,7 @@ describe('current status route', () => {
location: 'Europe - Germany',
status: 'down',
ping: expect.any(Object),
timestamp: expect.any(String),
},
},
});

View file

@ -44,6 +44,7 @@ export const runOnceSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () =
monitorId,
heartbeatId: monitorId,
runOnce: true,
testRunId: monitorId,
params: paramsBySpace[spaceId],
}),
]);

View file

@ -6,10 +6,10 @@
*/
import { schema } from '@kbn/config-schema';
import { v4 as uuidv4 } from 'uuid';
import { TestNowResponse } from '../../../common/types';
import {
ConfigKey,
MonitorFields,
SyntheticsMonitor,
SyntheticsMonitorWithSecrets,
} from '../../../common/runtime_types';
import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types';
@ -26,18 +26,8 @@ export const testNowMonitorRoute: SyntheticsRestApiRouteFactory = () => ({
monitorId: schema.string({ minLength: 1, maxLength: 1024 }),
}),
},
handler: async ({
request,
savedObjectsClient,
server,
syntheticsMonitorClient,
}): Promise<any> => {
handler: async ({ request, server, syntheticsMonitorClient }): Promise<any> => {
const { monitorId } = request.params;
const monitor = await savedObjectsClient.get<SyntheticsMonitor>(
syntheticsMonitorType,
monitorId
);
const encryptedClient = server.encryptedSavedObjects.getClient();
const monitorWithSecrets =
@ -47,7 +37,8 @@ export const testNowMonitorRoute: SyntheticsRestApiRouteFactory = () => ({
);
const normalizedMonitor = normalizeSecrets(monitorWithSecrets);
const { [ConfigKey.SCHEDULE]: schedule, [ConfigKey.LOCATIONS]: locations } = monitor.attributes;
const { [ConfigKey.SCHEDULE]: schedule, [ConfigKey.LOCATIONS]: locations } =
monitorWithSecrets.attributes;
const { syntheticsService } = syntheticsMonitorClient;
@ -69,9 +60,22 @@ export const testNowMonitorRoute: SyntheticsRestApiRouteFactory = () => ({
]);
if (errors && errors?.length > 0) {
return { errors, testRunId, monitorId, schedule, locations };
return {
errors,
testRunId,
schedule,
locations,
configId: monitorId,
monitor: normalizedMonitor.attributes,
} as TestNowResponse;
}
return { testRunId, monitorId, schedule, locations };
return {
testRunId,
schedule,
locations,
configId: monitorId,
monitor: normalizedMonitor.attributes,
} as TestNowResponse;
},
});